turbo-editors/turbo-corepublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

🛟 Updated. 28d5985 · on main · k33g · 4h ago
README.md · 76 lines · 5.7 KBmarkdown
Blame HistoryOpen raw

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 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 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
# 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` | `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:

```sh
make test                     # skips the gopls test if it is not installed
go test -short ./lsp/   # never starts a language server
```