| 🛟 Updated. 28d5985 k33g 5h ago | 1 | # lsp |
| 2 | |
| 3 | A small Language Server Protocol client: enough of it to ask **gopls** for completions, hovers, definitions and diagnostics. |
| 4 | |
| 5 | 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. |
| 6 | |
| 7 | The JSON-RPC half is **[`jsonrpc`](../jsonrpc/)**, shared with the Agent Client Protocol client. What is here is the framing, the methods, and the conversation a language server expects. |
| 8 | |
| 9 | ## Layers |
| 10 | |
| 11 | | File | What it is | |
| 12 | | --- | --- | |
| 13 | | `framing.go` | `Content-Length` frames on a stream, and the `Framing` that presents them as a `jsonrpc.Framer` | |
| 14 | | `conn.go` | The names this package re-exports from `jsonrpc`, and `NewConn` with the framing already chosen | |
| 15 | | `protocol.go` | The subset of LSP types this editor uses | |
| 16 | | `client.go` | The conversation: handshake, document sync, completion, hover, definition | |
| 17 | | `server.go` | Finding gopls and running it as a child process | |
| 18 | |
| 19 | `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. |
| 20 | |
| 21 | ## Two things that are easy to get wrong |
| 22 | |
| 23 | **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. |
| 24 | |
| 25 | **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. |
| 26 | |
| 27 | ## Three shapes for one answer |
| 28 | |
| 29 | `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. |
| 30 | |
| 31 | Three details in the flattening are decisions rather than mechanics: |
| 32 | |
| 33 | - **The two shapes are told apart by `selectionRange`, not by `children`.** 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. |
| 34 | - **A symbol is located by its `selectionRange`**, the name, not by `range`, the whole declaration. Jumping to a function should put the cursor on the function, not on the doc comment above it. |
| 35 | - **The nested shape carries no URI**, so the file that was asked about is put back in. Losing it means every jump goes nowhere. |
| 36 | |
| 37 | ## A missing server is not a failure |
| 38 | |
| 39 | `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: |
| 40 | |
| 41 | ``` |
| 42 | go install golang.org/x/tools/gopls@latest |
| 43 | ``` |
| 44 | |
| 45 | Editing and colouring work perfectly well without a language server; only completion is lost. |
| 46 | |
| 47 | 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. |
| 48 | |
| 49 | ## Public API |
| 50 | |
| 51 | | | | |
| 52 | | --- | --- | |
| 53 | | `FindServer(server profile.Server) (string, error)` | Where the editor's language server is, or `ErrServerNotFound` | |
| 54 | | `StartServer(ctx, server, root, clientName) (*Server, error)` | Find it, run it, complete the handshake | |
| 55 | | `StartServerCommand(ctx, command, args, root, clientName)` | The same with the executable named explicitly | |
| 56 | | `(*Server) Client() *Client` / `Stop(ctx) error` | | |
| 57 | | `NewClient(stream, root) *Client` | A client over any stream — what the tests use | |
| 58 | | `(*Client) Initialize / Run / Ready` | Handshake and read loop | |
| 59 | | `(*Client) DidOpen / DidChange / DidSave / DidClose` | Document synchronisation, whole-document | |
| 60 | | `(*Client) Complete / Hover` | The two requests answered with something to read | |
| 61 | | `(*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. | |
| 62 | | `(*Client) DocumentSymbols / WorkspaceSymbols` | What a file declares, and what a project holds matching a query | |
| 63 | | `Symbol`, `SymbolKind` | One shape for the three the protocol has | |
| 64 | | `(*Client) OnDiagnostics`, `OnLog` | What the server says unprompted | |
| 65 | | `PathToURI` / `URIToPath` | `file://` conversion, Windows drive letters included | |
| 66 | | `RuneToUTF16` / `UTF16ToRune` | Column conversion | |
| 67 | | `(CompletionItem) Insertion() string` | The text to insert, snippet placeholders stripped | |
| 68 | |
| 69 | ## Tests |
| 70 | |
| 71 | The suite runs against an in-process fake server, and — when gopls is installed — against the real one: |
| 72 | |
| 73 | ```sh |
| 74 | make test # skips the gopls test if it is not installed |
| 75 | go test -short ./lsp/ # never starts a language server |
| 76 | ``` |