package app import ( "context" "errors" "sort" "sync" "rickub.com/turbo-editors/turbo-core/lsp" "rickub.com/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) } } // FileCreated tells the server a file has appeared on disk — what a save under // a new name does, and what a server that lists a package's files from the // directory needs to hear before it will diagnose the document. func (l *Language) FileCreated(path string) { if client := l.connected(); client != nil && path != "" { _ = client.FileCreated(path) } } // 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. // // Canonical, always — absolute, links resolved — 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. And a server that resolves symbolic links — moon-lsp does — publishes // under /private/var/… what the editor opened as /var/…, which on macOS is // every temporary directory; absolute alone left those two apart as well. // // The same function builds the URIs the server is sent (lsp.PathToURI), so a // diagnostic comes back under the spelling it was announced by. func pathKey(path string) string { return lsp.CanonicalPath(path) } // 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() } }