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.

language.go · 402 lines · 12.1 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 5h ago1package app
2
3import (
4 "context"
5 "errors"
6 "path/filepath"
7 "sort"
8 "sync"
9
10 "codeberg.org/turbo-editors/turbo-core/lsp"
11 "codeberg.org/turbo-editors/turbo-core/profile"
12)
13
14// Language is the editor's side of the language-server conversation.
15//
16// Everything here is optional: when the language server is not installed, or
17// fails to start, every method is a no-op and the editor carries on without
18// completion. That is the whole point of the type — the rest of the app never
19// has to ask whether a language server exists.
20type Language struct {
21 mu sync.Mutex
22 server *lsp.Server // nil when a client was attached without a process
23 client *lsp.Client
24
25 // wanted is the server this editor talks to: gopls, rust-analyzer. It is
26 // fixed when the editor starts and never changes.
27 wanted profile.Server
28 // clientName is how the editor introduces itself in the handshake.
29 clientName string
30
31 serverPath string
32 root string
33 status string
34
35 diagnostics map[string][]lsp.Diagnostic
36 documents map[string]bool // what the server has been told about
37
38 // OnUpdate is called when something changed that the screen should show:
39 // new diagnostics, or a new status.
40 OnUpdate func()
41}
42
43// NewLanguage returns a Language that is not connected to anything, and that
44// will look for the given server when it is started, introducing itself as
45// clientName.
46func NewLanguage(server profile.Server, clientName string) *Language {
47 return &Language{
48 wanted: server,
49 clientName: clientName,
50 status: "LSP: off",
51 diagnostics: map[string][]lsp.Diagnostic{},
52 documents: map[string]bool{},
53 }
54}
55
56// Start looks for the editor's language server and, if it is there, starts it
57// in root.
58//
59// A missing server is reported through Status rather than as an error: it is a
60// perfectly ordinary state for the editor to run in.
61func (l *Language) Start(ctx context.Context, root string) {
62 l.mu.Lock()
63 l.root = root
64 l.mu.Unlock()
65 l.setStatus("LSP: starting…")
66
67 // The path is found first so that the status can name the executable, even
68 // when starting it then fails.
69 path, err := lsp.FindServer(l.wanted)
70 if err != nil {
71 l.setStatus(l.startupMessage(err))
72 return
73 }
74 l.mu.Lock()
75 l.serverPath = path
76 l.mu.Unlock()
77
78 server, err := lsp.StartServerCommand(ctx, path, l.wanted.Args, root, l.clientName)
79 if err != nil {
80 l.setStatus(l.startupMessage(err))
81 return
82 }
83
84 l.mu.Lock()
85 l.server = server
86 l.mu.Unlock()
87
88 l.attach(server.Client())
89}
90
91// attach adopts an initialised client and marks the editor ready to talk to
92// it. Start uses it for the client of a process it launched; a test uses it
93// for a client wired straight to a server in the same process.
94func (l *Language) attach(client *lsp.Client) {
95 client.OnDiagnostics = l.receiveDiagnostics
96
97 l.mu.Lock()
98 l.client = client
99 l.mu.Unlock()
100
101 l.setStatus("LSP: ready")
102}
103
104// startupMessage turns a start-up failure into something worth reading on a
105// status bar.
106//
107// A missing server is reported with the one command that installs it, because
108// "no rust-analyzer" on its own leaves the reader to go and look it up.
109func (l *Language) startupMessage(err error) string {
110 if errors.Is(err, lsp.ErrServerNotFound) {
111 return "LSP: no " + l.wanted.Command + " — " + l.wanted.InstallHint
112 }
113 return "LSP: " + err.Error()
114}
115
116// Stop shuts the language server down, if there is one.
117func (l *Language) Stop(ctx context.Context) {
118 l.mu.Lock()
119 server := l.server
120 l.server, l.client = nil, nil
121 l.mu.Unlock()
122
123 if server != nil {
124 _ = server.Stop(ctx)
125 }
126}
127
128// Report is everything worth knowing about the language server's state, in
129// enough detail to explain why a completion produced nothing.
130type Report struct {
131 Status string
132 ServerPath string
133 Root string
134 Ready bool
135}
136
137// Report returns the current state of the conversation.
138func (l *Language) Report() Report {
139 l.mu.Lock()
140 report := Report{Status: l.status, ServerPath: l.serverPath, Root: l.root}
141 l.mu.Unlock()
142
143 report.Ready = l.Ready()
144 return report
145}
146
147// Status returns the one-line state to show on the status bar.
148func (l *Language) Status() string {
149 l.mu.Lock()
150 defer l.mu.Unlock()
151 return l.status
152}
153
154// setStatus records a new status and asks for a redraw.
155func (l *Language) setStatus(status string) {
156 l.mu.Lock()
157 l.status = status
158 l.mu.Unlock()
159 l.notify()
160}
161
162// Ready reports whether a language server is connected and initialised.
163func (l *Language) Ready() bool {
164 client := l.connected()
165 return client != nil && client.Ready()
166}
167
168// connected returns the client to talk to, or nil when there is none.
169func (l *Language) connected() *lsp.Client {
170 l.mu.Lock()
171 defer l.mu.Unlock()
172 return l.client
173}
174
175// DidOpen tells the server about a file the editor has opened.
176func (l *Language) DidOpen(path, text string) {
177 client := l.connected()
178 if client == nil || path == "" {
179 return
180 }
181 if err := client.DidOpen(path, text); err != nil {
182 return
183 }
184
185 l.mu.Lock()
186 l.documents[pathKey(path)] = true
187 l.mu.Unlock()
188}
189
190// Knows reports whether the server has been told about a file. It is what the
191// status box uses to explain a completion that produced nothing, and what
192// saving uses to decide between announcing a document and reporting a write.
193//
194// The answer does not depend on how the path is spelt: a file opened as
195// `main.go` is known under its absolute name too, for the same reason
196// diagnostics are keyed that way.
197func (l *Language) Knows(path string) bool {
198 l.mu.Lock()
199 defer l.mu.Unlock()
200 return l.documents[pathKey(path)]
201}
202
203// DidChange tells the server about an edit.
204func (l *Language) DidChange(path, text string) {
205 if client := l.connected(); client != nil && path != "" {
206 _ = client.DidChange(path, text)
207 }
208}
209
210// DidSave tells the server a file has been written.
211func (l *Language) DidSave(path, text string) {
212 if client := l.connected(); client != nil && path != "" {
213 _ = client.DidSave(path, text)
214 }
215}
216
217// DidClose tells the server a file is no longer open, and forgets its
218// diagnostics.
219func (l *Language) DidClose(path string) {
220 if client := l.connected(); client != nil && path != "" {
221 _ = client.DidClose(path)
222 }
223
224 l.mu.Lock()
225 delete(l.diagnostics, pathKey(path))
226 delete(l.documents, pathKey(path))
227 l.mu.Unlock()
228}
229
230// Complete asks what could be typed at a place in a file, and returns nothing
231// at all when there is no server to ask.
232func (l *Language) Complete(ctx context.Context, path string, line, column int, lineText string) ([]lsp.CompletionItem, error) {
233 client := l.connected()
234 if client == nil {
235 return nil, lsp.ErrNotReady
236 }
237 return client.Complete(ctx, path, line, column, lineText)
238}
239
240// Hover asks what the thing under the cursor is.
241func (l *Language) Hover(ctx context.Context, path string, line, column int, lineText string) (string, error) {
242 client := l.connected()
243 if client == nil {
244 return "", lsp.ErrNotReady
245 }
246 return client.Hover(ctx, path, line, column, lineText)
247}
248
249// Definition asks where the thing under the cursor is declared.
250func (l *Language) Definition(ctx context.Context, path string, line, column int, lineText string) ([]lsp.Location, error) {
251 client := l.connected()
252 if client == nil {
253 return nil, lsp.ErrNotReady
254 }
255 return client.Definition(ctx, path, line, column, lineText)
256}
257
258// TypeDefinition asks where the type of the thing under the cursor is declared.
259func (l *Language) TypeDefinition(ctx context.Context, path string, line, column int, lineText string) ([]lsp.Location, error) {
260 client := l.connected()
261 if client == nil {
262 return nil, lsp.ErrNotReady
263 }
264 return client.TypeDefinition(ctx, path, line, column, lineText)
265}
266
267// Implementation asks what implements the thing under the cursor.
268func (l *Language) Implementation(ctx context.Context, path string, line, column int, lineText string) ([]lsp.Location, error) {
269 client := l.connected()
270 if client == nil {
271 return nil, lsp.ErrNotReady
272 }
273 return client.Implementation(ctx, path, line, column, lineText)
274}
275
276// References asks where the thing under the cursor is used, counting its
277// declaration as one of the answers.
278//
279// The declaration is included because the question a reader is asking is
280// "where does this name appear", and a list that leaves out the one place they
281// are already looking at reads as a list with something missing.
282func (l *Language) References(ctx context.Context, path string, line, column int, lineText string) ([]lsp.Location, error) {
283 client := l.connected()
284 if client == nil {
285 return nil, lsp.ErrNotReady
286 }
287 return client.References(ctx, path, line, column, lineText, true)
288}
289
290// DocumentSymbols asks what one file declares.
291func (l *Language) DocumentSymbols(ctx context.Context, path string) ([]lsp.Symbol, error) {
292 client := l.connected()
293 if client == nil {
294 return nil, lsp.ErrNotReady
295 }
296 return client.DocumentSymbols(ctx, path)
297}
298
299// WorkspaceSymbols searches the project for symbols matching a query.
300func (l *Language) WorkspaceSymbols(ctx context.Context, query string) ([]lsp.Symbol, error) {
301 client := l.connected()
302 if client == nil {
303 return nil, lsp.ErrNotReady
304 }
305 return client.WorkspaceSymbols(ctx, query)
306}
307
308// AllDiagnostics returns every problem the server has reported, for every file
309// it has spoken about, sorted by file and then by line.
310//
311// Every file, not only the one in front: a language server publishes for the
312// whole package it has loaded, so the file with the error is often not the
313// file being edited — which is exactly when a list is worth having.
314//
315// for _, problem := range editor.Language().AllDiagnostics() {
316// fmt.Println(problem.Path, problem.Diagnostic.Message)
317// }
318func (l *Language) AllDiagnostics() []FileDiagnostic {
319 l.mu.Lock()
320 var out []FileDiagnostic
321 for path, diagnostics := range l.diagnostics {
322 for _, diagnostic := range diagnostics {
323 out = append(out, FileDiagnostic{Path: path, Diagnostic: diagnostic})
324 }
325 }
326 l.mu.Unlock()
327
328 // Sorted here rather than by the caller because the map's order is
329 // random, and a list that reshuffles itself every time it is opened is
330 // one nobody can use twice.
331 sort.Slice(out, func(i, j int) bool {
332 if out[i].Path != out[j].Path {
333 return out[i].Path < out[j].Path
334 }
335 return out[i].Diagnostic.Range.Start.Line < out[j].Diagnostic.Range.Start.Line
336 })
337 return out
338}
339
340// FileDiagnostic is one problem, with the file it is in — which the diagnostic
341// itself does not carry.
342type FileDiagnostic struct {
343 Path string
344 Diagnostic lsp.Diagnostic
345}
346
347// Diagnostics returns the problems the server reported for a file.
348//
349// for _, problem := range editor.Language().Diagnostics(path) {
350// fmt.Println(problem.Range.Start.Line, problem.Message)
351// }
352func (l *Language) Diagnostics(path string) []lsp.Diagnostic {
353 l.mu.Lock()
354 defer l.mu.Unlock()
355 return l.diagnostics[pathKey(path)]
356}
357
358// receiveDiagnostics records a file's problems. It is called from the
359// connection's read loop, which is why everything here is behind the lock.
360func (l *Language) receiveDiagnostics(path string, diagnostics []lsp.Diagnostic) {
361 l.mu.Lock()
362 l.diagnostics[pathKey(path)] = diagnostics
363 l.mu.Unlock()
364 l.notify()
365}
366
367// pathKey is how a file is named in the maps of problems and of open
368// documents.
369//
370// Absolute, always, because the two sides spell it differently. A server
371// publishes absolute URIs; a buffer opened from the command line — `turbo-go
372// main.go` — holds the relative path it was given. Keyed by whatever arrived,
373// the two never meet: the status bar showed no error, and the gutter no mark,
374// for a file the server had plenty to say about.
375//
376// A path that cannot be made absolute is used as it is. That is better than
377// dropping the diagnostic, and it degrades to the behaviour there was before.
378func pathKey(path string) string {
379 absolute, err := filepath.Abs(path)
380 if err != nil {
381 return path
382 }
383 return absolute
384}
385
386// FirstError returns the first error-level diagnostic for a file, which is
387// what the status bar shows.
388func (l *Language) FirstError(path string) (lsp.Diagnostic, bool) {
389 for _, diagnostic := range l.Diagnostics(path) {
390 if diagnostic.Severity == lsp.SeverityError {
391 return diagnostic, true
392 }
393 }
394 return lsp.Diagnostic{}, false
395}
396
397// notify asks the editor to redraw.
398func (l *Language) notify() {
399 if l.OnUpdate != nil {
400 l.OnUpdate()
401 }
402}