turbo-editors/turbo-corepublic Fork 0
v0.9.0
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 v0.9.0 · k33g · 14h ago
client.go · 452 lines · 15.0 KBGo Blame HistoryRaw
  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
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
package lsp

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"sync"
	"time"

	"codeberg.org/turbo-editors/turbo-core/jsonrpc"
)

// RequestTimeout caps how long the editor waits for an answer before carrying
// on without one. A completion that takes longer than this is no longer a
// completion; it is an interruption.
const RequestTimeout = 3 * time.Second

// InitializeTimeout is longer, because a cold server has a project to load
// before it can say hello.
const InitializeTimeout = 30 * time.Second

// ErrNotReady is returned by every request made before initialisation has
// finished.
var ErrNotReady = errors.New("lsp: the server is not ready yet")

// Client is a conversation with one language server.
//
// It is safe for concurrent use. Every request is bounded by a timeout, so a
// server that stops answering slows the editor down but never stops it.
type Client struct {
	conn *Conn
	root string

	// OnDiagnostics is called, from the connection's read loop, whenever the
	// server reports the problems in a file.
	OnDiagnostics func(path string, diagnostics []Diagnostic)
	// OnLog is called with the messages the server wants shown.
	OnLog func(message string)

	mu       sync.Mutex
	ready    bool
	versions map[string]int

	// name is what the client calls itself in the handshake. A language server
	// logs it, and "turbo-go" in the logs of a session that was actually Turbo
	// Rust is the kind of small lie that costs somebody an afternoon.
	name string
}

// NewClient returns a client talking over stream, rooted at the directory root
// and introducing itself as name. Call Initialize before anything else.
//
// Taking a stream rather than a command is what lets the whole client be
// tested against a server in the same process, with no child process and no pipes.
//
//	client := lsp.NewClient(stream, root, "Turbo Rust")
func NewClient(stream io.ReadWriteCloser, root, name string) *Client {
	if name == "" {
		name = "turbo-editors"
	}
	client := &Client{root: root, name: name, versions: map[string]int{}}
	client.conn = NewConn(stream, client.handleNotification, client.answerRequest)
	return client
}

// Run reads from the server until the connection ends. Call it in a goroutine
// of its own.
func (c *Client) Run() error { return c.conn.Run() }

// Ready reports whether initialisation has finished.
func (c *Client) Ready() bool {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.ready
}

// Initialize performs the protocol's opening handshake and marks the client
// ready.
func (c *Client) Initialize(ctx context.Context) error {
	ctx, cancel := context.WithTimeout(ctx, InitializeTimeout)
	defer cancel()

	params := map[string]any{
		"processId":    nil,
		"rootUri":      PathToURI(c.root),
		"capabilities": clientCapabilities(),
		"clientInfo":   map[string]string{"name": c.name},
	}
	if err := c.conn.Call(ctx, "initialize", params, nil); err != nil {
		return fmt.Errorf("lsp: initialize: %w", err)
	}
	if err := c.conn.Notify("initialized", map[string]any{}); err != nil {
		return fmt.Errorf("lsp: initialized: %w", err)
	}

	c.mu.Lock()
	c.ready = true
	c.mu.Unlock()
	return nil
}

// clientCapabilities describes what this editor can actually make use of.
//
// It is deliberately modest: claiming a capability the editor cannot honour —
// snippet placeholders, say — makes the server send things that then have to
// be thrown away.
func clientCapabilities() map[string]any {
	return map[string]any{
		"textDocument": map[string]any{
			"synchronization": map[string]any{
				"didSave": true,
			},
			"completion": map[string]any{
				"completionItem": map[string]any{
					"snippetSupport":      false,
					"documentationFormat": []string{"plaintext"},
				},
			},
			"hover": map[string]any{
				"contentFormat": []string{"plaintext", "markdown"},
			},
			// The three that answer "where else does this name appear". They
			// take the same parameters as definition and return the same
			// shape, which is why one decoder serves all four.
			"references":         map[string]any{},
			"implementation":     map[string]any{},
			"typeDefinition":     map[string]any{},
			"publishDiagnostics": map[string]any{},
		},
		"workspace": map[string]any{
			"configuration": true,
		},
	}
}

// DidOpen tells the server a file is being edited.
func (c *Client) DidOpen(path, text string) error {
	c.mu.Lock()
	c.versions[path] = 1
	c.mu.Unlock()

	return c.conn.Notify("textDocument/didOpen", DidOpenParams{
		TextDocument: TextDocumentItem{
			URI:        PathToURI(path),
			LanguageID: "go",
			Version:    1,
			Text:       text,
		},
	})
}

// DidChange sends a new version of a file. The whole text goes each time; see
// ContentChange for why.
func (c *Client) DidChange(path, text string) error {
	c.mu.Lock()
	c.versions[path]++
	version := c.versions[path]
	c.mu.Unlock()

	return c.conn.Notify("textDocument/didChange", DidChangeParams{
		TextDocument:   VersionedTextDocumentIdentifier{URI: PathToURI(path), Version: version},
		ContentChanges: []ContentChange{{Text: text}},
	})
}

// DidSave tells the server a file has been written to disk.
func (c *Client) DidSave(path, text string) error {
	return c.conn.Notify("textDocument/didSave", DidSaveParams{
		TextDocument: TextDocumentIdentifier{URI: PathToURI(path)},
		Text:         text,
	})
}

// DidClose tells the server a file is no longer being edited.
func (c *Client) DidClose(path string) error {
	c.mu.Lock()
	delete(c.versions, path)
	c.mu.Unlock()

	return c.conn.Notify("textDocument/didClose", DidCloseParams{
		TextDocument: TextDocumentIdentifier{URI: PathToURI(path)},
	})
}

// Complete asks what could be typed at a place in a file.
//
// line and runeColumn are the editor's own coordinates; lineText is that
// line's content, which is what the rune-to-UTF-16 conversion needs.
func (c *Client) Complete(ctx context.Context, path string, line, runeColumn int, lineText string) ([]CompletionItem, error) {
	if !c.Ready() {
		return nil, ErrNotReady
	}
	ctx, cancel := context.WithTimeout(ctx, RequestTimeout)
	defer cancel()

	var raw json.RawMessage
	if err := c.conn.Call(ctx, "textDocument/completion", positionParams(path, line, runeColumn, lineText), &raw); err != nil {
		return nil, err
	}
	return decodeCompletion(raw)
}

// decodeCompletion reads either shape the protocol allows: a bare array of
// items, or a list object wrapping one.
//
// Which it is follows from the first character, not from trying one and
// falling back: a list object whose items happen to be empty decodes as an
// array "successfully" into nothing, and the failure would be silent.
func decodeCompletion(raw json.RawMessage) ([]CompletionItem, error) {
	trimmed := bytes.TrimSpace(raw)
	if len(trimmed) == 0 || string(trimmed) == "null" {
		return nil, nil
	}

	if trimmed[0] == '[' {
		var items []CompletionItem
		if err := json.Unmarshal(trimmed, &items); err != nil {
			return nil, fmt.Errorf("lsp: unreadable completion result: %w", err)
		}
		return items, nil
	}

	var list CompletionList
	if err := json.Unmarshal(trimmed, &list); err != nil {
		return nil, fmt.Errorf("lsp: unreadable completion result: %w", err)
	}
	return list.Items, nil
}

// Hover asks what the thing under the cursor is, and returns the text to show
// or the empty string when the server has nothing to say.
func (c *Client) Hover(ctx context.Context, path string, line, runeColumn int, lineText string) (string, error) {
	if !c.Ready() {
		return "", ErrNotReady
	}
	ctx, cancel := context.WithTimeout(ctx, RequestTimeout)
	defer cancel()

	var hover *Hover
	if err := c.conn.Call(ctx, "textDocument/hover", positionParams(path, line, runeColumn, lineText), &hover); err != nil {
		return "", err
	}
	if hover == nil {
		return "", nil
	}
	return hover.Contents.Value, nil
}

// Definition asks where the thing under the cursor is declared.
func (c *Client) Definition(ctx context.Context, path string, line, runeColumn int, lineText string) ([]Location, error) {
	return c.locationRequest(ctx, "textDocument/definition", positionParams(path, line, runeColumn, lineText))
}

// TypeDefinition asks where the *type* of the thing under the cursor is
// declared, which is a different question from where the thing itself is.
//
//	locations, err := client.TypeDefinition(ctx, path, line, column, lineText)
//
// The answer has the same shape as a definition's, and is read the same way.
func (c *Client) TypeDefinition(ctx context.Context, path string, line, runeColumn int, lineText string) ([]Location, error) {
	return c.locationRequest(ctx, "textDocument/typeDefinition", positionParams(path, line, runeColumn, lineText))
}

// Implementation asks what implements the thing under the cursor: the types
// satisfying a Go interface, the impl blocks of a Rust trait.
//
//	locations, err := client.Implementation(ctx, path, line, column, lineText)
func (c *Client) Implementation(ctx context.Context, path string, line, runeColumn int, lineText string) ([]Location, error) {
	return c.locationRequest(ctx, "textDocument/implementation", positionParams(path, line, runeColumn, lineText))
}

// References asks where the thing under the cursor is used.
//
// includeDeclaration says whether the declaration itself is one of the
// answers. It is a parameter rather than a constant because the two readings
// are both reasonable — "show me everything that mentions this" wants it, "show
// me the callers" does not — and the caller is the one that knows which
// question was asked.
//
//	locations, err := client.References(ctx, path, line, column, lineText, true)
func (c *Client) References(ctx context.Context, path string, line, runeColumn int, lineText string, includeDeclaration bool) ([]Location, error) {
	params := referenceParams{
		TextDocumentPositionParams: positionParams(path, line, runeColumn, lineText),
		Context:                    referenceContext{IncludeDeclaration: includeDeclaration},
	}
	return c.locationRequest(ctx, "textDocument/references", params)
}

// referenceParams is a position with the one extra field references takes.
type referenceParams struct {
	TextDocumentPositionParams
	Context referenceContext `json:"context"`
}

type referenceContext struct {
	IncludeDeclaration bool `json:"includeDeclaration"`
}

// locationRequest sends one of the four requests that answer with places in
// the code, and reads the answer.
//
// definition, typeDefinition, implementation and references differ only in
// their method name and, for references, one extra parameter. Writing the
// call once means a server that answers a bare object where the specification
// says array — which happens — is handled the same way for all of them.
func (c *Client) locationRequest(ctx context.Context, method string, params any) ([]Location, error) {
	if !c.Ready() {
		return nil, ErrNotReady
	}
	ctx, cancel := context.WithTimeout(ctx, RequestTimeout)
	defer cancel()

	var raw json.RawMessage
	if err := c.conn.Call(ctx, method, params, &raw); err != nil {
		return nil, err
	}
	return decodeLocations(raw)
}

// decodeLocations reads either shape a definition answer may take: one
// location, or an array of them.
func decodeLocations(raw json.RawMessage) ([]Location, error) {
	trimmed := bytes.TrimSpace(raw)
	if len(trimmed) == 0 || string(trimmed) == "null" {
		return nil, nil
	}

	if trimmed[0] == '[' {
		var many []Location
		if err := json.Unmarshal(trimmed, &many); err != nil {
			return nil, fmt.Errorf("lsp: unreadable definition result: %w", err)
		}
		return many, nil
	}

	var one Location
	if err := json.Unmarshal(trimmed, &one); err != nil {
		return nil, fmt.Errorf("lsp: unreadable definition result: %w", err)
	}
	return []Location{one}, nil
}

// positionParams builds the document-and-place parameters most requests take,
// converting the column from runes to UTF-16 units on the way.
func positionParams(path string, line, runeColumn int, lineText string) TextDocumentPositionParams {
	return TextDocumentPositionParams{
		TextDocument: TextDocumentIdentifier{URI: PathToURI(path)},
		Position:     Position{Line: line, Character: RuneToUTF16(lineText, runeColumn)},
	}
}

// Shutdown asks the server to stop and closes the connection.
//
// The connection is closed whatever the server answers: a language server that
// will not shut down politely is still one the editor is finished with.
func (c *Client) Shutdown(ctx context.Context) error {
	ctx, cancel := context.WithTimeout(ctx, RequestTimeout)
	defer cancel()

	callErr := c.conn.Call(ctx, "shutdown", nil, nil)
	if callErr == nil {
		_ = c.conn.Notify("exit", nil)
	}

	closeErr := c.conn.Close()
	if callErr != nil {
		return callErr
	}
	return closeErr
}

// handleNotification deals with what the server sends unprompted.
func (c *Client) handleNotification(method string, params json.RawMessage) {
	switch method {
	case "textDocument/publishDiagnostics":
		c.publishDiagnostics(params)
	case "window/showMessage", "window/logMessage":
		c.logMessage(params)
	}
}

// publishDiagnostics passes a file's problems on to the editor.
func (c *Client) publishDiagnostics(params json.RawMessage) {
	if c.OnDiagnostics == nil {
		return
	}
	var published PublishDiagnosticsParams
	if err := json.Unmarshal(params, &published); err != nil {
		return
	}
	c.OnDiagnostics(URIToPath(published.URI), published.Diagnostics)
}

// logMessage passes a server message on to the editor.
func (c *Client) logMessage(params json.RawMessage) {
	if c.OnLog == nil {
		return
	}
	var message struct {
		Message string `json:"message"`
	}
	if err := json.Unmarshal(params, &message); err != nil || message.Message == "" {
		return
	}
	c.OnLog(message.Message)
}

// answerRequest replies to the server, on the spot.
//
// Every question a language server asks can be answered from what this client
// already knows, so there is nothing to wait for and the deferred half of
// jsonrpc.Request is unused here. An agent's questions cannot be answered that
// way, which is what the deferred half exists for.
func (c *Client) answerRequest(req *jsonrpc.Request) {
	req.Reply(c.handleRequest(req.Method, req.Params))
}

// handleRequest answers the requests a language server makes of its client.
//
// Answering them matters: gopls and rust-analyzer both ask for configuration during start-up and
// waits for the reply, so a client that ignores the question never finishes
// initialising.
func (c *Client) handleRequest(method string, params json.RawMessage) (any, error) {
	switch method {
	case "workspace/configuration":
		return configurationReply(params), nil
	case "window/workDoneProgress/create", "client/registerCapability", "client/unregisterCapability":
		return nil, nil
	default:
		return nil, &ResponseError{Code: CodeMethodNotFound, Message: method}
	}
}

// configurationReply answers a configuration request with one empty settings
// object per item asked about, which means "use your defaults".
func configurationReply(params json.RawMessage) []map[string]any {
	var request struct {
		Items []json.RawMessage `json:"items"`
	}
	if err := json.Unmarshal(params, &request); err != nil || len(request.Items) == 0 {
		return []map[string]any{{}}
	}

	reply := make([]map[string]any, len(request.Items))
	for i := range reply {
		reply[i] = map[string]any{}
	}
	return reply
}