turbo-editors/turbo-corepublic Fork 0
v1.0.2
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.

client.go · 467 lines · 15.7 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 16h ago1package lsp
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "sync"
11 "time"
12
📦 Turbo Core f3ade8d k33g 8h ago13 "rickub.com/turbo-editors/turbo-core/jsonrpc"
🛟 Updated. 28d5985 k33g 16h ago14)
15
16// RequestTimeout caps how long the editor waits for an answer before carrying
17// on without one. A completion that takes longer than this is no longer a
18// completion; it is an interruption.
19const RequestTimeout = 3 * time.Second
20
21// InitializeTimeout is longer, because a cold server has a project to load
22// before it can say hello.
23const InitializeTimeout = 30 * time.Second
24
25// ErrNotReady is returned by every request made before initialisation has
26// finished.
27var ErrNotReady = errors.New("lsp: the server is not ready yet")
28
29// Client is a conversation with one language server.
30//
31// It is safe for concurrent use. Every request is bounded by a timeout, so a
32// server that stops answering slows the editor down but never stops it.
33type Client struct {
34 conn *Conn
35 root string
36
37 // OnDiagnostics is called, from the connection's read loop, whenever the
38 // server reports the problems in a file.
39 OnDiagnostics func(path string, diagnostics []Diagnostic)
40 // OnLog is called with the messages the server wants shown.
41 OnLog func(message string)
42
43 mu sync.Mutex
44 ready bool
45 versions map[string]int
46
47 // name is what the client calls itself in the handshake. A language server
48 // logs it, and "turbo-go" in the logs of a session that was actually Turbo
49 // Rust is the kind of small lie that costs somebody an afternoon.
50 name string
51}
52
53// NewClient returns a client talking over stream, rooted at the directory root
54// and introducing itself as name. Call Initialize before anything else.
55//
56// Taking a stream rather than a command is what lets the whole client be
57// tested against a server in the same process, with no child process and no pipes.
58//
59// client := lsp.NewClient(stream, root, "Turbo Rust")
60func NewClient(stream io.ReadWriteCloser, root, name string) *Client {
61 if name == "" {
62 name = "turbo-editors"
63 }
64 client := &Client{root: root, name: name, versions: map[string]int{}}
65 client.conn = NewConn(stream, client.handleNotification, client.answerRequest)
66 return client
67}
68
69// Run reads from the server until the connection ends. Call it in a goroutine
70// of its own.
71func (c *Client) Run() error { return c.conn.Run() }
72
73// Ready reports whether initialisation has finished.
74func (c *Client) Ready() bool {
75 c.mu.Lock()
76 defer c.mu.Unlock()
77 return c.ready
78}
79
80// Initialize performs the protocol's opening handshake and marks the client
81// ready.
82func (c *Client) Initialize(ctx context.Context) error {
83 ctx, cancel := context.WithTimeout(ctx, InitializeTimeout)
84 defer cancel()
85
86 params := map[string]any{
87 "processId": nil,
88 "rootUri": PathToURI(c.root),
89 "capabilities": clientCapabilities(),
90 "clientInfo": map[string]string{"name": c.name},
91 }
92 if err := c.conn.Call(ctx, "initialize", params, nil); err != nil {
93 return fmt.Errorf("lsp: initialize: %w", err)
94 }
95 if err := c.conn.Notify("initialized", map[string]any{}); err != nil {
96 return fmt.Errorf("lsp: initialized: %w", err)
97 }
98
99 c.mu.Lock()
100 c.ready = true
101 c.mu.Unlock()
102 return nil
103}
104
105// clientCapabilities describes what this editor can actually make use of.
106//
107// It is deliberately modest: claiming a capability the editor cannot honour —
108// snippet placeholders, say — makes the server send things that then have to
109// be thrown away.
110func clientCapabilities() map[string]any {
111 return map[string]any{
112 "textDocument": map[string]any{
113 "synchronization": map[string]any{
114 "didSave": true,
115 },
116 "completion": map[string]any{
117 "completionItem": map[string]any{
118 "snippetSupport": false,
119 "documentationFormat": []string{"plaintext"},
120 },
121 },
122 "hover": map[string]any{
123 "contentFormat": []string{"plaintext", "markdown"},
124 },
125 // The three that answer "where else does this name appear". They
126 // take the same parameters as definition and return the same
127 // shape, which is why one decoder serves all four.
128 "references": map[string]any{},
129 "implementation": map[string]any{},
130 "typeDefinition": map[string]any{},
131 "publishDiagnostics": map[string]any{},
132 },
133 "workspace": map[string]any{
134 "configuration": true,
135 },
136 }
137}
138
139// DidOpen tells the server a file is being edited.
140func (c *Client) DidOpen(path, text string) error {
141 c.mu.Lock()
142 c.versions[path] = 1
143 c.mu.Unlock()
144
145 return c.conn.Notify("textDocument/didOpen", DidOpenParams{
146 TextDocument: TextDocumentItem{
147 URI: PathToURI(path),
148 LanguageID: "go",
149 Version: 1,
150 Text: text,
151 },
152 })
153}
154
155// DidChange sends a new version of a file. The whole text goes each time; see
156// ContentChange for why.
157func (c *Client) DidChange(path, text string) error {
158 c.mu.Lock()
159 c.versions[path]++
160 version := c.versions[path]
161 c.mu.Unlock()
162
163 return c.conn.Notify("textDocument/didChange", DidChangeParams{
164 TextDocument: VersionedTextDocumentIdentifier{URI: PathToURI(path), Version: version},
165 ContentChanges: []ContentChange{{Text: text}},
166 })
167}
168
169// DidSave tells the server a file has been written to disk.
170func (c *Client) DidSave(path, text string) error {
171 return c.conn.Notify("textDocument/didSave", DidSaveParams{
172 TextDocument: TextDocumentIdentifier{URI: PathToURI(path)},
173 Text: text,
174 })
175}
176
📦 Turbo Core — a save that creates a file tells the server (workspace/didChangeWatchedFiles), so moon-lsp diagnoses a new .mbt from its first save 3561e52 k33g 6h ago177// FileCreated tells the server a file has appeared on disk.
178//
179// A document being open (DidOpen) and a file existing are two different facts
180// to a server that works out a package's files from the directory: moon-lsp
181// diagnoses a .mbt file saved for the first time only once this has been sent,
182// however loudly the document was announced. The spec has clients send these
183// for files the server asked to watch; sending one unasked is harmless to the
184// servers this library has been driven against, and is the only way to tell a
185// server that asks for nothing.
186func (c *Client) FileCreated(path string) error {
187 return c.conn.Notify("workspace/didChangeWatchedFiles", DidChangeWatchedFilesParams{
188 Changes: []FileEvent{{URI: PathToURI(path), Type: FileCreated}},
189 })
190}
191
🛟 Updated. 28d5985 k33g 16h ago192// DidClose tells the server a file is no longer being edited.
193func (c *Client) DidClose(path string) error {
194 c.mu.Lock()
195 delete(c.versions, path)
196 c.mu.Unlock()
197
198 return c.conn.Notify("textDocument/didClose", DidCloseParams{
199 TextDocument: TextDocumentIdentifier{URI: PathToURI(path)},
200 })
201}
202
203// Complete asks what could be typed at a place in a file.
204//
205// line and runeColumn are the editor's own coordinates; lineText is that
206// line's content, which is what the rune-to-UTF-16 conversion needs.
207func (c *Client) Complete(ctx context.Context, path string, line, runeColumn int, lineText string) ([]CompletionItem, error) {
208 if !c.Ready() {
209 return nil, ErrNotReady
210 }
211 ctx, cancel := context.WithTimeout(ctx, RequestTimeout)
212 defer cancel()
213
214 var raw json.RawMessage
215 if err := c.conn.Call(ctx, "textDocument/completion", positionParams(path, line, runeColumn, lineText), &raw); err != nil {
216 return nil, err
217 }
218 return decodeCompletion(raw)
219}
220
221// decodeCompletion reads either shape the protocol allows: a bare array of
222// items, or a list object wrapping one.
223//
224// Which it is follows from the first character, not from trying one and
225// falling back: a list object whose items happen to be empty decodes as an
226// array "successfully" into nothing, and the failure would be silent.
227func decodeCompletion(raw json.RawMessage) ([]CompletionItem, error) {
228 trimmed := bytes.TrimSpace(raw)
229 if len(trimmed) == 0 || string(trimmed) == "null" {
230 return nil, nil
231 }
232
233 if trimmed[0] == '[' {
234 var items []CompletionItem
235 if err := json.Unmarshal(trimmed, &items); err != nil {
236 return nil, fmt.Errorf("lsp: unreadable completion result: %w", err)
237 }
238 return items, nil
239 }
240
241 var list CompletionList
242 if err := json.Unmarshal(trimmed, &list); err != nil {
243 return nil, fmt.Errorf("lsp: unreadable completion result: %w", err)
244 }
245 return list.Items, nil
246}
247
248// Hover asks what the thing under the cursor is, and returns the text to show
249// or the empty string when the server has nothing to say.
250func (c *Client) Hover(ctx context.Context, path string, line, runeColumn int, lineText string) (string, error) {
251 if !c.Ready() {
252 return "", ErrNotReady
253 }
254 ctx, cancel := context.WithTimeout(ctx, RequestTimeout)
255 defer cancel()
256
257 var hover *Hover
258 if err := c.conn.Call(ctx, "textDocument/hover", positionParams(path, line, runeColumn, lineText), &hover); err != nil {
259 return "", err
260 }
261 if hover == nil {
262 return "", nil
263 }
264 return hover.Contents.Value, nil
265}
266
267// Definition asks where the thing under the cursor is declared.
268func (c *Client) Definition(ctx context.Context, path string, line, runeColumn int, lineText string) ([]Location, error) {
269 return c.locationRequest(ctx, "textDocument/definition", positionParams(path, line, runeColumn, lineText))
270}
271
272// TypeDefinition asks where the *type* of the thing under the cursor is
273// declared, which is a different question from where the thing itself is.
274//
275// locations, err := client.TypeDefinition(ctx, path, line, column, lineText)
276//
277// The answer has the same shape as a definition's, and is read the same way.
278func (c *Client) TypeDefinition(ctx context.Context, path string, line, runeColumn int, lineText string) ([]Location, error) {
279 return c.locationRequest(ctx, "textDocument/typeDefinition", positionParams(path, line, runeColumn, lineText))
280}
281
282// Implementation asks what implements the thing under the cursor: the types
283// satisfying a Go interface, the impl blocks of a Rust trait.
284//
285// locations, err := client.Implementation(ctx, path, line, column, lineText)
286func (c *Client) Implementation(ctx context.Context, path string, line, runeColumn int, lineText string) ([]Location, error) {
287 return c.locationRequest(ctx, "textDocument/implementation", positionParams(path, line, runeColumn, lineText))
288}
289
290// References asks where the thing under the cursor is used.
291//
292// includeDeclaration says whether the declaration itself is one of the
293// answers. It is a parameter rather than a constant because the two readings
294// are both reasonable — "show me everything that mentions this" wants it, "show
295// me the callers" does not — and the caller is the one that knows which
296// question was asked.
297//
298// locations, err := client.References(ctx, path, line, column, lineText, true)
299func (c *Client) References(ctx context.Context, path string, line, runeColumn int, lineText string, includeDeclaration bool) ([]Location, error) {
300 params := referenceParams{
301 TextDocumentPositionParams: positionParams(path, line, runeColumn, lineText),
302 Context: referenceContext{IncludeDeclaration: includeDeclaration},
303 }
304 return c.locationRequest(ctx, "textDocument/references", params)
305}
306
307// referenceParams is a position with the one extra field references takes.
308type referenceParams struct {
309 TextDocumentPositionParams
310 Context referenceContext `json:"context"`
311}
312
313type referenceContext struct {
314 IncludeDeclaration bool `json:"includeDeclaration"`
315}
316
317// locationRequest sends one of the four requests that answer with places in
318// the code, and reads the answer.
319//
320// definition, typeDefinition, implementation and references differ only in
321// their method name and, for references, one extra parameter. Writing the
322// call once means a server that answers a bare object where the specification
323// says array — which happens — is handled the same way for all of them.
324func (c *Client) locationRequest(ctx context.Context, method string, params any) ([]Location, error) {
325 if !c.Ready() {
326 return nil, ErrNotReady
327 }
328 ctx, cancel := context.WithTimeout(ctx, RequestTimeout)
329 defer cancel()
330
331 var raw json.RawMessage
332 if err := c.conn.Call(ctx, method, params, &raw); err != nil {
333 return nil, err
334 }
335 return decodeLocations(raw)
336}
337
338// decodeLocations reads either shape a definition answer may take: one
339// location, or an array of them.
340func decodeLocations(raw json.RawMessage) ([]Location, error) {
341 trimmed := bytes.TrimSpace(raw)
342 if len(trimmed) == 0 || string(trimmed) == "null" {
343 return nil, nil
344 }
345
346 if trimmed[0] == '[' {
347 var many []Location
348 if err := json.Unmarshal(trimmed, &many); err != nil {
349 return nil, fmt.Errorf("lsp: unreadable definition result: %w", err)
350 }
351 return many, nil
352 }
353
354 var one Location
355 if err := json.Unmarshal(trimmed, &one); err != nil {
356 return nil, fmt.Errorf("lsp: unreadable definition result: %w", err)
357 }
358 return []Location{one}, nil
359}
360
361// positionParams builds the document-and-place parameters most requests take,
362// converting the column from runes to UTF-16 units on the way.
363func positionParams(path string, line, runeColumn int, lineText string) TextDocumentPositionParams {
364 return TextDocumentPositionParams{
365 TextDocument: TextDocumentIdentifier{URI: PathToURI(path)},
366 Position: Position{Line: line, Character: RuneToUTF16(lineText, runeColumn)},
367 }
368}
369
370// Shutdown asks the server to stop and closes the connection.
371//
372// The connection is closed whatever the server answers: a language server that
373// will not shut down politely is still one the editor is finished with.
374func (c *Client) Shutdown(ctx context.Context) error {
375 ctx, cancel := context.WithTimeout(ctx, RequestTimeout)
376 defer cancel()
377
378 callErr := c.conn.Call(ctx, "shutdown", nil, nil)
379 if callErr == nil {
380 _ = c.conn.Notify("exit", nil)
381 }
382
383 closeErr := c.conn.Close()
384 if callErr != nil {
385 return callErr
386 }
387 return closeErr
388}
389
390// handleNotification deals with what the server sends unprompted.
391func (c *Client) handleNotification(method string, params json.RawMessage) {
392 switch method {
393 case "textDocument/publishDiagnostics":
394 c.publishDiagnostics(params)
395 case "window/showMessage", "window/logMessage":
396 c.logMessage(params)
397 }
398}
399
400// publishDiagnostics passes a file's problems on to the editor.
401func (c *Client) publishDiagnostics(params json.RawMessage) {
402 if c.OnDiagnostics == nil {
403 return
404 }
405 var published PublishDiagnosticsParams
406 if err := json.Unmarshal(params, &published); err != nil {
407 return
408 }
409 c.OnDiagnostics(URIToPath(published.URI), published.Diagnostics)
410}
411
412// logMessage passes a server message on to the editor.
413func (c *Client) logMessage(params json.RawMessage) {
414 if c.OnLog == nil {
415 return
416 }
417 var message struct {
418 Message string `json:"message"`
419 }
420 if err := json.Unmarshal(params, &message); err != nil || message.Message == "" {
421 return
422 }
423 c.OnLog(message.Message)
424}
425
426// answerRequest replies to the server, on the spot.
427//
428// Every question a language server asks can be answered from what this client
429// already knows, so there is nothing to wait for and the deferred half of
430// jsonrpc.Request is unused here. An agent's questions cannot be answered that
431// way, which is what the deferred half exists for.
432func (c *Client) answerRequest(req *jsonrpc.Request) {
433 req.Reply(c.handleRequest(req.Method, req.Params))
434}
435
436// handleRequest answers the requests a language server makes of its client.
437//
438// Answering them matters: gopls and rust-analyzer both ask for configuration during start-up and
439// waits for the reply, so a client that ignores the question never finishes
440// initialising.
441func (c *Client) handleRequest(method string, params json.RawMessage) (any, error) {
442 switch method {
443 case "workspace/configuration":
444 return configurationReply(params), nil
445 case "window/workDoneProgress/create", "client/registerCapability", "client/unregisterCapability":
446 return nil, nil
447 default:
448 return nil, &ResponseError{Code: CodeMethodNotFound, Message: method}
449 }
450}
451
452// configurationReply answers a configuration request with one empty settings
453// object per item asked about, which means "use your defaults".
454func configurationReply(params json.RawMessage) []map[string]any {
455 var request struct {
456 Items []json.RawMessage `json:"items"`
457 }
458 if err := json.Unmarshal(params, &request); err != nil || len(request.Items) == 0 {
459 return []map[string]any{{}}
460 }
461
462 reply := make([]map[string]any, len(request.Items))
463 for i := range reply {
464 reply[i] = map[string]any{}
465 }
466 return reply
467}