turbo-editors/turbo-corepublic Fork 0
28d59854361aeda8541d853093e732126f3d7bff
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 28d59854361aeda8541d853093e732126f3d7bff · k33g · 16h ago
language.go · 402 lines · 12.1 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
package app

import (
	"context"
	"errors"
	"path/filepath"
	"sort"
	"sync"

	"codeberg.org/turbo-editors/turbo-core/lsp"
	"codeberg.org/turbo-editors/turbo-core/profile"
)

// Language is the editor's side of the language-server conversation.
//
// Everything here is optional: when the language server is not installed, or
// fails to start, every method is a no-op and the editor carries on without
// completion. That is the whole point of the type — the rest of the app never
// has to ask whether a language server exists.
type Language struct {
	mu     sync.Mutex
	server *lsp.Server // nil when a client was attached without a process
	client *lsp.Client

	// wanted is the server this editor talks to: gopls, rust-analyzer. It is
	// fixed when the editor starts and never changes.
	wanted profile.Server
	// clientName is how the editor introduces itself in the handshake.
	clientName string

	serverPath string
	root       string
	status     string

	diagnostics map[string][]lsp.Diagnostic
	documents   map[string]bool // what the server has been told about

	// OnUpdate is called when something changed that the screen should show:
	// new diagnostics, or a new status.
	OnUpdate func()
}

// NewLanguage returns a Language that is not connected to anything, and that
// will look for the given server when it is started, introducing itself as
// clientName.
func NewLanguage(server profile.Server, clientName string) *Language {
	return &Language{
		wanted:      server,
		clientName:  clientName,
		status:      "LSP: off",
		diagnostics: map[string][]lsp.Diagnostic{},
		documents:   map[string]bool{},
	}
}

// Start looks for the editor's language server and, if it is there, starts it
// in root.
//
// A missing server is reported through Status rather than as an error: it is a
// perfectly ordinary state for the editor to run in.
func (l *Language) Start(ctx context.Context, root string) {
	l.mu.Lock()
	l.root = root
	l.mu.Unlock()
	l.setStatus("LSP: starting…")

	// The path is found first so that the status can name the executable, even
	// when starting it then fails.
	path, err := lsp.FindServer(l.wanted)
	if err != nil {
		l.setStatus(l.startupMessage(err))
		return
	}
	l.mu.Lock()
	l.serverPath = path
	l.mu.Unlock()

	server, err := lsp.StartServerCommand(ctx, path, l.wanted.Args, root, l.clientName)
	if err != nil {
		l.setStatus(l.startupMessage(err))
		return
	}

	l.mu.Lock()
	l.server = server
	l.mu.Unlock()

	l.attach(server.Client())
}

// attach adopts an initialised client and marks the editor ready to talk to
// it. Start uses it for the client of a process it launched; a test uses it
// for a client wired straight to a server in the same process.
func (l *Language) attach(client *lsp.Client) {
	client.OnDiagnostics = l.receiveDiagnostics

	l.mu.Lock()
	l.client = client
	l.mu.Unlock()

	l.setStatus("LSP: ready")
}

// startupMessage turns a start-up failure into something worth reading on a
// status bar.
//
// A missing server is reported with the one command that installs it, because
// "no rust-analyzer" on its own leaves the reader to go and look it up.
func (l *Language) startupMessage(err error) string {
	if errors.Is(err, lsp.ErrServerNotFound) {
		return "LSP: no " + l.wanted.Command + " — " + l.wanted.InstallHint
	}
	return "LSP: " + err.Error()
}

// Stop shuts the language server down, if there is one.
func (l *Language) Stop(ctx context.Context) {
	l.mu.Lock()
	server := l.server
	l.server, l.client = nil, nil
	l.mu.Unlock()

	if server != nil {
		_ = server.Stop(ctx)
	}
}

// Report is everything worth knowing about the language server's state, in
// enough detail to explain why a completion produced nothing.
type Report struct {
	Status     string
	ServerPath string
	Root       string
	Ready      bool
}

// Report returns the current state of the conversation.
func (l *Language) Report() Report {
	l.mu.Lock()
	report := Report{Status: l.status, ServerPath: l.serverPath, Root: l.root}
	l.mu.Unlock()

	report.Ready = l.Ready()
	return report
}

// Status returns the one-line state to show on the status bar.
func (l *Language) Status() string {
	l.mu.Lock()
	defer l.mu.Unlock()
	return l.status
}

// setStatus records a new status and asks for a redraw.
func (l *Language) setStatus(status string) {
	l.mu.Lock()
	l.status = status
	l.mu.Unlock()
	l.notify()
}

// Ready reports whether a language server is connected and initialised.
func (l *Language) Ready() bool {
	client := l.connected()
	return client != nil && client.Ready()
}

// connected returns the client to talk to, or nil when there is none.
func (l *Language) connected() *lsp.Client {
	l.mu.Lock()
	defer l.mu.Unlock()
	return l.client
}

// DidOpen tells the server about a file the editor has opened.
func (l *Language) DidOpen(path, text string) {
	client := l.connected()
	if client == nil || path == "" {
		return
	}
	if err := client.DidOpen(path, text); err != nil {
		return
	}

	l.mu.Lock()
	l.documents[pathKey(path)] = true
	l.mu.Unlock()
}

// Knows reports whether the server has been told about a file. It is what the
// status box uses to explain a completion that produced nothing, and what
// saving uses to decide between announcing a document and reporting a write.
//
// The answer does not depend on how the path is spelt: a file opened as
// `main.go` is known under its absolute name too, for the same reason
// diagnostics are keyed that way.
func (l *Language) Knows(path string) bool {
	l.mu.Lock()
	defer l.mu.Unlock()
	return l.documents[pathKey(path)]
}

// DidChange tells the server about an edit.
func (l *Language) DidChange(path, text string) {
	if client := l.connected(); client != nil && path != "" {
		_ = client.DidChange(path, text)
	}
}

// DidSave tells the server a file has been written.
func (l *Language) DidSave(path, text string) {
	if client := l.connected(); client != nil && path != "" {
		_ = client.DidSave(path, text)
	}
}

// DidClose tells the server a file is no longer open, and forgets its
// diagnostics.
func (l *Language) DidClose(path string) {
	if client := l.connected(); client != nil && path != "" {
		_ = client.DidClose(path)
	}

	l.mu.Lock()
	delete(l.diagnostics, pathKey(path))
	delete(l.documents, pathKey(path))
	l.mu.Unlock()
}

// Complete asks what could be typed at a place in a file, and returns nothing
// at all when there is no server to ask.
func (l *Language) Complete(ctx context.Context, path string, line, column int, lineText string) ([]lsp.CompletionItem, error) {
	client := l.connected()
	if client == nil {
		return nil, lsp.ErrNotReady
	}
	return client.Complete(ctx, path, line, column, lineText)
}

// Hover asks what the thing under the cursor is.
func (l *Language) Hover(ctx context.Context, path string, line, column int, lineText string) (string, error) {
	client := l.connected()
	if client == nil {
		return "", lsp.ErrNotReady
	}
	return client.Hover(ctx, path, line, column, lineText)
}

// Definition asks where the thing under the cursor is declared.
func (l *Language) Definition(ctx context.Context, path string, line, column int, lineText string) ([]lsp.Location, error) {
	client := l.connected()
	if client == nil {
		return nil, lsp.ErrNotReady
	}
	return client.Definition(ctx, path, line, column, lineText)
}

// TypeDefinition asks where the type of the thing under the cursor is declared.
func (l *Language) TypeDefinition(ctx context.Context, path string, line, column int, lineText string) ([]lsp.Location, error) {
	client := l.connected()
	if client == nil {
		return nil, lsp.ErrNotReady
	}
	return client.TypeDefinition(ctx, path, line, column, lineText)
}

// Implementation asks what implements the thing under the cursor.
func (l *Language) Implementation(ctx context.Context, path string, line, column int, lineText string) ([]lsp.Location, error) {
	client := l.connected()
	if client == nil {
		return nil, lsp.ErrNotReady
	}
	return client.Implementation(ctx, path, line, column, lineText)
}

// References asks where the thing under the cursor is used, counting its
// declaration as one of the answers.
//
// The declaration is included because the question a reader is asking is
// "where does this name appear", and a list that leaves out the one place they
// are already looking at reads as a list with something missing.
func (l *Language) References(ctx context.Context, path string, line, column int, lineText string) ([]lsp.Location, error) {
	client := l.connected()
	if client == nil {
		return nil, lsp.ErrNotReady
	}
	return client.References(ctx, path, line, column, lineText, true)
}

// DocumentSymbols asks what one file declares.
func (l *Language) DocumentSymbols(ctx context.Context, path string) ([]lsp.Symbol, error) {
	client := l.connected()
	if client == nil {
		return nil, lsp.ErrNotReady
	}
	return client.DocumentSymbols(ctx, path)
}

// WorkspaceSymbols searches the project for symbols matching a query.
func (l *Language) WorkspaceSymbols(ctx context.Context, query string) ([]lsp.Symbol, error) {
	client := l.connected()
	if client == nil {
		return nil, lsp.ErrNotReady
	}
	return client.WorkspaceSymbols(ctx, query)
}

// AllDiagnostics returns every problem the server has reported, for every file
// it has spoken about, sorted by file and then by line.
//
// Every file, not only the one in front: a language server publishes for the
// whole package it has loaded, so the file with the error is often not the
// file being edited — which is exactly when a list is worth having.
//
//	for _, problem := range editor.Language().AllDiagnostics() {
//		fmt.Println(problem.Path, problem.Diagnostic.Message)
//	}
func (l *Language) AllDiagnostics() []FileDiagnostic {
	l.mu.Lock()
	var out []FileDiagnostic
	for path, diagnostics := range l.diagnostics {
		for _, diagnostic := range diagnostics {
			out = append(out, FileDiagnostic{Path: path, Diagnostic: diagnostic})
		}
	}
	l.mu.Unlock()

	// Sorted here rather than by the caller because the map's order is
	// random, and a list that reshuffles itself every time it is opened is
	// one nobody can use twice.
	sort.Slice(out, func(i, j int) bool {
		if out[i].Path != out[j].Path {
			return out[i].Path < out[j].Path
		}
		return out[i].Diagnostic.Range.Start.Line < out[j].Diagnostic.Range.Start.Line
	})
	return out
}

// FileDiagnostic is one problem, with the file it is in — which the diagnostic
// itself does not carry.
type FileDiagnostic struct {
	Path       string
	Diagnostic lsp.Diagnostic
}

// Diagnostics returns the problems the server reported for a file.
//
//	for _, problem := range editor.Language().Diagnostics(path) {
//		fmt.Println(problem.Range.Start.Line, problem.Message)
//	}
func (l *Language) Diagnostics(path string) []lsp.Diagnostic {
	l.mu.Lock()
	defer l.mu.Unlock()
	return l.diagnostics[pathKey(path)]
}

// receiveDiagnostics records a file's problems. It is called from the
// connection's read loop, which is why everything here is behind the lock.
func (l *Language) receiveDiagnostics(path string, diagnostics []lsp.Diagnostic) {
	l.mu.Lock()
	l.diagnostics[pathKey(path)] = diagnostics
	l.mu.Unlock()
	l.notify()
}

// pathKey is how a file is named in the maps of problems and of open
// documents.
//
// Absolute, always, because the two sides spell it differently. A server
// publishes absolute URIs; a buffer opened from the command line — `turbo-go
// main.go` — holds the relative path it was given. Keyed by whatever arrived,
// the two never meet: the status bar showed no error, and the gutter no mark,
// for a file the server had plenty to say about.
//
// A path that cannot be made absolute is used as it is. That is better than
// dropping the diagnostic, and it degrades to the behaviour there was before.
func pathKey(path string) string {
	absolute, err := filepath.Abs(path)
	if err != nil {
		return path
	}
	return absolute
}

// FirstError returns the first error-level diagnostic for a file, which is
// what the status bar shows.
func (l *Language) FirstError(path string) (lsp.Diagnostic, bool) {
	for _, diagnostic := range l.Diagnostics(path) {
		if diagnostic.Severity == lsp.SeverityError {
			return diagnostic, true
		}
	}
	return lsp.Diagnostic{}, false
}

// notify asks the editor to redraw.
func (l *Language) notify() {
	if l.OnUpdate != nil {
		l.OnUpdate()
	}
}