Client
Drive a language server from Go: spawn it, query it, and know when to trust an empty answer.
Package client is the mirror of package server. Where server turns your handler into a language server, client turns a language server into something your program can ask questions of. Use it to run gopls in CI, build a code-aware tool, or drive a server from an editor or agent.
Connect
c, err := client.Dial(ctx, client.Config{
Command: []string{"gopls"},
Dir: root,
RootURI: lsp.DocumentURI("file://" + root),
Stderr: os.Stderr,
})
if err != nil {
log.Fatal(err)
}
defer c.Close()
The context passed to Dial bounds startup only. The connection lives until Close, so a request-scoped context will not kill the server underneath you.
Wait Before You Trust an Empty Result
A server accepts initialize long before it has finished indexing. Ask for references in that window and you get an empty list that looks exactly like a real answer. A human sees a spinner and waits; a program records it as fact.
if err := c.Ready(ctx); err != nil {
return err
}
refs, err := c.References(ctx, uri, pos, true)
Ready is a heuristic, because LSP has no readiness signal. It tracks outstanding work-done progress and, for servers that report none, waits out Config.ReadyGrace (2s by default). Requests are never blocked on it, so call it when a wrong answer would cost more than the wait.Query
if err := c.DidOpen(ctx, uri, "go", 1, text); err != nil {
return err
}
hover, err := c.Hover(ctx, uri, lsp.Position{Line: 10, Character: 4})
defs, err := c.Definition(ctx, uri, pos)
syms, err := c.DocumentSymbol(ctx, uri)
edits, err := c.Formatting(ctx, uri, lsp.FormattingOptions{TabSize: 4})
The client owns no document text. You hold the buffers and pass explicit versions, because whoever owns the text must own the version numbering. Replace a whole document with DidChangeFull; for incremental sync, build the params yourself and use Notify.
Ask What the Server Can Do
Supports considers both the capabilities from initialize and anything the server registered later, matching document selectors for the dynamic ones.
if c.Supports("textDocument/rename", uri, "go") {
edit, err := c.Rename(ctx, uri, pos, "NewName")
}
Enabled(), not a nil check. A server that sends "codeActionProvider": false decodes to a non-nil value whose Enabled() is false, so != nil reports support that is not there.Answer the Server
Servers ask their client for things: configuration, permission to edit files, messages to show. Implement Handler to answer. Embed NopHandler and override only what you care about; it declines everything by default.
type handler struct {
client.NopHandler
}
func (handler) Configuration(_ context.Context, items []lsp.ConfigurationItem) ([]json.RawMessage, error) {
out := make([]json.RawMessage, len(items))
for i := range out {
out[i] = json.RawMessage(`{"staticcheck": true}`)
}
return out, nil
}
func (handler) Diagnostics(params *lsp.PublishDiagnosticsParams) {
for _, d := range params.Diagnostics {
log.Printf("%s:%d %s", params.URI, d.Range.Start.Line+1, d.Message)
}
}
Diagnostics arrive as notifications rather than as a response, so there is nothing to wait on. Collect them in your Handler and match them against the document version you sent. Where the server supports pull diagnostics, DocumentDiagnostic is the more direct route.
Capability Registration Is Handled for You
Servers add and remove capabilities at runtime. Client answers client/registerCapability, client/unregisterCapability, progress creation and the refresh requests itself, so Supports stays accurate. These never reach your Handler.
Testing Without a Real Server
Config.Transport replaces the child process with any stream, so tests can run a server.Server in the same process over a pipe.
cfg := client.Config{
Transport: func(ctx context.Context) (io.ReadWriteCloser, error) {
clientConn, serverConn := net.Pipe()
srv := server.NewServer(&myHandler{})
go func() { _ = srv.Run(ctx, serverConn) }()
return clientConn, nil
},
}
When the Server Dies
select {
case <-c.Done():
log.Printf("server exited: %v", c.Err())
case <-ctx.Done():
}
Requests still in flight fail as soon as the connection drops, rather than waiting out their timeout. Close shuts the server down gracefully and kills it if it has not exited within Config.ShutdownTimeout.