lsp
A small Language Server Protocol client: enough of it to ask gopls for completions, hovers, definitions and diagnostics.
The protocol is JSON-RPC 2.0 inside HTTP-style frames, which is little enough to implement directly. Doing so keeps the editor's dependency list at two modules and puts the whole conversation somewhere it can be read.
The JSON-RPC half is jsonrpc, shared with the Agent Client Protocol client. What is here is the framing, the methods, and the conversation a language server expects.
Layers
| File | What it is |
|---|---|
framing.go |
Content-Length frames on a stream, and the Framing that presents them as a jsonrpc.Framer |
conn.go |
The names this package re-exports from jsonrpc, and NewConn with the framing already chosen |
protocol.go |
The subset of LSP types this editor uses |
client.go |
The conversation: handshake, document sync, completion, hover, definition |
server.go |
Finding gopls and running it as a child process |
NewClient takes an io.ReadWriteCloser, not a command. That is what lets the entire client be tested against a server in the same process, over net.Pipe — real framing, real concurrency, real decoding, no subprocess and no timing to get lucky with. StartServer is the thin layer that builds that stream out of a child process's pipes.
Two things that are easy to get wrong
Columns are UTF-16 code units. The editor counts columns in runes; the protocol counts them in UTF-16 code units. RuneToUTF16 and UTF16ToRune convert at the boundary. On plain ASCII the two agree, which is exactly why getting this wrong survives testing until someone opens a file with an accent in it.
gopls asks its client questions. It requests workspace/configuration during start-up and waits for the reply, so a client that ignores server-to-client requests never finishes initialising. jsonrpc.Conn routes them to a handler, and the client answers with empty settings — "use your defaults". Every such question can be answered on the spot, which is why answerRequest replies immediately and the deferred half of jsonrpc.Request goes unused here.
Three shapes for one answer
textDocument/documentSymbol may be answered with a nested DocumentSymbol[] — a tree, no URI, two ranges per node — or with a flat SymbolInformation[], and workspace/symbol adds a third: a symbol whose location has a URI and no range at all. All three arrive here and one Symbol leaves.
Three details in the flattening are decisions rather than mechanics:
- The two shapes are told apart by
selectionRange, not bychildren. Children are optional, so a file whose symbols happen to have none would be read as flat — and then every symbol would lose its position, silently. - A symbol is located by its
selectionRange, the name, not byrange, the whole declaration. Jumping to a function should put the cursor on the function, not on the doc comment above it. - The nested shape carries no URI, so the file that was asked about is put back in. Losing it means every jump goes nowhere.
A missing server is not a failure
FindServer looks on PATH, then in the directories the profile names — GOPATH/bin for Go, ~/.cargo/bin for Rust — where each language's own installer puts things, and which are very often not on PATH. When it finds nothing it returns ErrServerNotFound naming the executable and every directory it searched, and the profile's InstallHint is the single command a user can copy:
go install golang.org/x/tools/gopls@latest
Editing and colouring work perfectly well without a language server; only completion is lost.
Every request is bounded: RequestTimeout (3 s) for the ordinary ones, InitializeTimeout (30 s) for the handshake, because a cold gopls has a module graph to load before it can say hello. A server that stops answering slows the editor down but never stops it.
Public API
FindServer(server profile.Server) (string, error) |
Where the editor's language server is, or ErrServerNotFound |
StartServer(ctx, server, root, clientName) (*Server, error) |
Find it, run it, complete the handshake |
StartServerCommand(ctx, command, args, root, clientName) |
The same with the executable named explicitly |
(*Server) Client() *Client / Stop(ctx) error |
|
NewClient(stream, root) *Client |
A client over any stream — what the tests use |
(*Client) Initialize / Run / Ready |
Handshake and read loop |
(*Client) DidOpen / DidChange / DidSave / DidClose |
Document synchronisation, whole-document |
(*Client) Complete / Hover |
The two requests answered with something to read |
(*Client) Definition / TypeDefinition / Implementation / References |
The four answered with places in the code. One decoder serves all four, because the protocol lets a server answer a single location as a bare object rather than an array of one — and several do. |
(*Client) DocumentSymbols / WorkspaceSymbols |
What a file declares, and what a project holds matching a query |
Symbol, SymbolKind |
One shape for the three the protocol has |
(*Client) OnDiagnostics, OnLog |
What the server says unprompted |
PathToURI / URIToPath |
file:// conversion, Windows drive letters included |
RuneToUTF16 / UTF16ToRune |
Column conversion |
(CompletionItem) Insertion() string |
The text to insert, snippet placeholders stripped |
Tests
The suite runs against an in-process fake server, and — when gopls is installed — against the real one:
make test # skips the gopls test if it is not installed
go test -short ./lsp/ # never starts a language server
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
|