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