| 🛟 Updated. 28d5985 k33g 18h ago | 1 | package app |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "path/filepath" |
| 7 | "sort" |
| 8 | "sync" |
| 9 | |
| 📦 Turbo Core f3ade8d k33g 11h ago | 10 | "rickub.com/turbo-editors/turbo-core/lsp" |
| 11 | "rickub.com/turbo-editors/turbo-core/profile" |
| 🛟 Updated. 28d5985 k33g 18h ago | 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. |
| 20 | type 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. |
| 46 | func 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. |
| 61 | func (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. |
| 94 | func (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. |
| 109 | func (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. |
| 117 | func (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. |
| 130 | type 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. |
| 138 | func (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. |
| 148 | func (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. |
| 155 | func (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. |
| 163 | func (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. |
| 169 | func (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. |
| 176 | func (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. |
| 197 | func (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. |
| 204 | func (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. |
| 211 | func (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. |
| 219 | func (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. |
| 232 | func (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. |
| 241 | func (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. |
| 250 | func (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. |
| 259 | func (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. |
| 268 | func (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. |
| 282 | func (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. |
| 291 | func (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. |
| 300 | func (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 | // } |
| 318 | func (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. |
| 342 | type 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 | // } |
| 352 | func (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. |
| 360 | func (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. |
| 378 | func 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. |
| 388 | func (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. |
| 398 | func (l *Language) notify() { |
| 399 | if l.OnUpdate != nil { |
| 400 | l.OnUpdate() |
| 401 | } |
| 402 | } |