# 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`](../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 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. - **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. - **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` / `CanonicalPath` | `file://` conversion, Windows drive letters included; the path is made canonical first — absolute, symbolic links resolved — because moon-lsp resolves them itself and would not recognise a document announced under another spelling (macOS's `/var` → `/private/var`) | | `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: ```sh make test # skips the gopls test if it is not installed go test -short ./lsp/ # never starts a language server ```