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.

client.go · 452 lines · 15.0 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 5h ago1package lsp
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "sync"
11 "time"
12
13 "codeberg.org/turbo-editors/turbo-core/jsonrpc"
14)
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
177// DidClose tells the server a file is no longer being edited.
178func (c *Client) DidClose(path string) error {
179 c.mu.Lock()
180 delete(c.versions, path)
181 c.mu.Unlock()
182
183 return c.conn.Notify("textDocument/didClose", DidCloseParams{
184 TextDocument: TextDocumentIdentifier{URI: PathToURI(path)},
185 })
186}
187
188// Complete asks what could be typed at a place in a file.
189//
190// line and runeColumn are the editor's own coordinates; lineText is that
191// line's content, which is what the rune-to-UTF-16 conversion needs.
192func (c *Client) Complete(ctx context.Context, path string, line, runeColumn int, lineText string) ([]CompletionItem, error) {
193 if !c.Ready() {
194 return nil, ErrNotReady
195 }
196 ctx, cancel := context.WithTimeout(ctx, RequestTimeout)
197 defer cancel()
198
199 var raw json.RawMessage
200 if err := c.conn.Call(ctx, "textDocument/completion", positionParams(path, line, runeColumn, lineText), &raw); err != nil {
201 return nil, err
202 }
203 return decodeCompletion(raw)
204}
205
206// decodeCompletion reads either shape the protocol allows: a bare array of
207// items, or a list object wrapping one.
208//
209// Which it is follows from the first character, not from trying one and
210// falling back: a list object whose items happen to be empty decodes as an
211// array "successfully" into nothing, and the failure would be silent.
212func decodeCompletion(raw json.RawMessage) ([]CompletionItem, error) {
213 trimmed := bytes.TrimSpace(raw)
214 if len(trimmed) == 0 || string(trimmed) == "null" {
215 return nil, nil
216 }
217
218 if trimmed[0] == '[' {
219 var items []CompletionItem
220 if err := json.Unmarshal(trimmed, &items); err != nil {
221 return nil, fmt.Errorf("lsp: unreadable completion result: %w", err)
222 }
223 return items, nil
224 }
225
226 var list CompletionList
227 if err := json.Unmarshal(trimmed, &list); err != nil {
228 return nil, fmt.Errorf("lsp: unreadable completion result: %w", err)
229 }
230 return list.Items, nil
231}
232
233// Hover asks what the thing under the cursor is, and returns the text to show
234// or the empty string when the server has nothing to say.
235func (c *Client) Hover(ctx context.Context, path string, line, runeColumn int, lineText string) (string, error) {
236 if !c.Ready() {
237 return "", ErrNotReady
238 }
239 ctx, cancel := context.WithTimeout(ctx, RequestTimeout)
240 defer cancel()
241
242 var hover *Hover
243 if err := c.conn.Call(ctx, "textDocument/hover", positionParams(path, line, runeColumn, lineText), &hover); err != nil {
244 return "", err
245 }
246 if hover == nil {
247 return "", nil
248 }
249 return hover.Contents.Value, nil
250}
251
252// Definition asks where the thing under the cursor is declared.
253func (c *Client) Definition(ctx context.Context, path string, line, runeColumn int, lineText string) ([]Location, error) {
254 return c.locationRequest(ctx, "textDocument/definition", positionParams(path, line, runeColumn, lineText))
255}
256
257// TypeDefinition asks where the *type* of the thing under the cursor is
258// declared, which is a different question from where the thing itself is.
259//
260// locations, err := client.TypeDefinition(ctx, path, line, column, lineText)
261//
262// The answer has the same shape as a definition's, and is read the same way.
263func (c *Client) TypeDefinition(ctx context.Context, path string, line, runeColumn int, lineText string) ([]Location, error) {
264 return c.locationRequest(ctx, "textDocument/typeDefinition", positionParams(path, line, runeColumn, lineText))
265}
266
267// Implementation asks what implements the thing under the cursor: the types
268// satisfying a Go interface, the impl blocks of a Rust trait.
269//
270// locations, err := client.Implementation(ctx, path, line, column, lineText)
271func (c *Client) Implementation(ctx context.Context, path string, line, runeColumn int, lineText string) ([]Location, error) {
272 return c.locationRequest(ctx, "textDocument/implementation", positionParams(path, line, runeColumn, lineText))
273}
274
275// References asks where the thing under the cursor is used.
276//
277// includeDeclaration says whether the declaration itself is one of the
278// answers. It is a parameter rather than a constant because the two readings
279// are both reasonable — "show me everything that mentions this" wants it, "show
280// me the callers" does not — and the caller is the one that knows which
281// question was asked.
282//
283// locations, err := client.References(ctx, path, line, column, lineText, true)
284func (c *Client) References(ctx context.Context, path string, line, runeColumn int, lineText string, includeDeclaration bool) ([]Location, error) {
285 params := referenceParams{
286 TextDocumentPositionParams: positionParams(path, line, runeColumn, lineText),
287 Context: referenceContext{IncludeDeclaration: includeDeclaration},
288 }
289 return c.locationRequest(ctx, "textDocument/references", params)
290}
291
292// referenceParams is a position with the one extra field references takes.
293type referenceParams struct {
294 TextDocumentPositionParams
295 Context referenceContext `json:"context"`
296}
297
298type referenceContext struct {
299 IncludeDeclaration bool `json:"includeDeclaration"`
300}
301
302// locationRequest sends one of the four requests that answer with places in
303// the code, and reads the answer.
304//
305// definition, typeDefinition, implementation and references differ only in
306// their method name and, for references, one extra parameter. Writing the
307// call once means a server that answers a bare object where the specification
308// says array — which happens — is handled the same way for all of them.
309func (c *Client) locationRequest(ctx context.Context, method string, params any) ([]Location, error) {
310 if !c.Ready() {
311 return nil, ErrNotReady
312 }
313 ctx, cancel := context.WithTimeout(ctx, RequestTimeout)
314 defer cancel()
315
316 var raw json.RawMessage
317 if err := c.conn.Call(ctx, method, params, &raw); err != nil {
318 return nil, err
319 }
320 return decodeLocations(raw)
321}
322
323// decodeLocations reads either shape a definition answer may take: one
324// location, or an array of them.
325func decodeLocations(raw json.RawMessage) ([]Location, error) {
326 trimmed := bytes.TrimSpace(raw)
327 if len(trimmed) == 0 || string(trimmed) == "null" {
328 return nil, nil
329 }
330
331 if trimmed[0] == '[' {
332 var many []Location
333 if err := json.Unmarshal(trimmed, &many); err != nil {
334 return nil, fmt.Errorf("lsp: unreadable definition result: %w", err)
335 }
336 return many, nil
337 }
338
339 var one Location
340 if err := json.Unmarshal(trimmed, &one); err != nil {
341 return nil, fmt.Errorf("lsp: unreadable definition result: %w", err)
342 }
343 return []Location{one}, nil
344}
345
346// positionParams builds the document-and-place parameters most requests take,
347// converting the column from runes to UTF-16 units on the way.
348func positionParams(path string, line, runeColumn int, lineText string) TextDocumentPositionParams {
349 return TextDocumentPositionParams{
350 TextDocument: TextDocumentIdentifier{URI: PathToURI(path)},
351 Position: Position{Line: line, Character: RuneToUTF16(lineText, runeColumn)},
352 }
353}
354
355// Shutdown asks the server to stop and closes the connection.
356//
357// The connection is closed whatever the server answers: a language server that
358// will not shut down politely is still one the editor is finished with.
359func (c *Client) Shutdown(ctx context.Context) error {
360 ctx, cancel := context.WithTimeout(ctx, RequestTimeout)
361 defer cancel()
362
363 callErr := c.conn.Call(ctx, "shutdown", nil, nil)
364 if callErr == nil {
365 _ = c.conn.Notify("exit", nil)
366 }
367
368 closeErr := c.conn.Close()
369 if callErr != nil {
370 return callErr
371 }
372 return closeErr
373}
374
375// handleNotification deals with what the server sends unprompted.
376func (c *Client) handleNotification(method string, params json.RawMessage) {
377 switch method {
378 case "textDocument/publishDiagnostics":
379 c.publishDiagnostics(params)
380 case "window/showMessage", "window/logMessage":
381 c.logMessage(params)
382 }
383}
384
385// publishDiagnostics passes a file's problems on to the editor.
386func (c *Client) publishDiagnostics(params json.RawMessage) {
387 if c.OnDiagnostics == nil {
388 return
389 }
390 var published PublishDiagnosticsParams
391 if err := json.Unmarshal(params, &published); err != nil {
392 return
393 }
394 c.OnDiagnostics(URIToPath(published.URI), published.Diagnostics)
395}
396
397// logMessage passes a server message on to the editor.
398func (c *Client) logMessage(params json.RawMessage) {
399 if c.OnLog == nil {
400 return
401 }
402 var message struct {
403 Message string `json:"message"`
404 }
405 if err := json.Unmarshal(params, &message); err != nil || message.Message == "" {
406 return
407 }
408 c.OnLog(message.Message)
409}
410
411// answerRequest replies to the server, on the spot.
412//
413// Every question a language server asks can be answered from what this client
414// already knows, so there is nothing to wait for and the deferred half of
415// jsonrpc.Request is unused here. An agent's questions cannot be answered that
416// way, which is what the deferred half exists for.
417func (c *Client) answerRequest(req *jsonrpc.Request) {
418 req.Reply(c.handleRequest(req.Method, req.Params))
419}
420
421// handleRequest answers the requests a language server makes of its client.
422//
423// Answering them matters: gopls and rust-analyzer both ask for configuration during start-up and
424// waits for the reply, so a client that ignores the question never finishes
425// initialising.
426func (c *Client) handleRequest(method string, params json.RawMessage) (any, error) {
427 switch method {
428 case "workspace/configuration":
429 return configurationReply(params), nil
430 case "window/workDoneProgress/create", "client/registerCapability", "client/unregisterCapability":
431 return nil, nil
432 default:
433 return nil, &ResponseError{Code: CodeMethodNotFound, Message: method}
434 }
435}
436
437// configurationReply answers a configuration request with one empty settings
438// object per item asked about, which means "use your defaults".
439func configurationReply(params json.RawMessage) []map[string]any {
440 var request struct {
441 Items []json.RawMessage `json:"items"`
442 }
443 if err := json.Unmarshal(params, &request); err != nil || len(request.Items) == 0 {
444 return []map[string]any{{}}
445 }
446
447 reply := make([]map[string]any, len(request.Items))
448 for i := range reply {
449 reply[i] = map[string]any{}
450 }
451 return reply
452}