package lsp import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "sync" "time" "rickub.com/turbo-editors/turbo-core/jsonrpc" ) // RequestTimeout caps how long the editor waits for an answer before carrying // on without one. A completion that takes longer than this is no longer a // completion; it is an interruption. const RequestTimeout = 3 * time.Second // InitializeTimeout is longer, because a cold server has a project to load // before it can say hello. const InitializeTimeout = 30 * time.Second // ErrNotReady is returned by every request made before initialisation has // finished. var ErrNotReady = errors.New("lsp: the server is not ready yet") // Client is a conversation with one language server. // // It is safe for concurrent use. Every request is bounded by a timeout, so a // server that stops answering slows the editor down but never stops it. type Client struct { conn *Conn root string // OnDiagnostics is called, from the connection's read loop, whenever the // server reports the problems in a file. OnDiagnostics func(path string, diagnostics []Diagnostic) // OnLog is called with the messages the server wants shown. OnLog func(message string) mu sync.Mutex ready bool versions map[string]int // name is what the client calls itself in the handshake. A language server // logs it, and "turbo-go" in the logs of a session that was actually Turbo // Rust is the kind of small lie that costs somebody an afternoon. name string } // NewClient returns a client talking over stream, rooted at the directory root // and introducing itself as name. Call Initialize before anything else. // // Taking a stream rather than a command is what lets the whole client be // tested against a server in the same process, with no child process and no pipes. // // client := lsp.NewClient(stream, root, "Turbo Rust") func NewClient(stream io.ReadWriteCloser, root, name string) *Client { if name == "" { name = "turbo-editors" } client := &Client{root: root, name: name, versions: map[string]int{}} client.conn = NewConn(stream, client.handleNotification, client.answerRequest) return client } // Run reads from the server until the connection ends. Call it in a goroutine // of its own. func (c *Client) Run() error { return c.conn.Run() } // Ready reports whether initialisation has finished. func (c *Client) Ready() bool { c.mu.Lock() defer c.mu.Unlock() return c.ready } // Initialize performs the protocol's opening handshake and marks the client // ready. func (c *Client) Initialize(ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, InitializeTimeout) defer cancel() params := map[string]any{ "processId": nil, "rootUri": PathToURI(c.root), "capabilities": clientCapabilities(), "clientInfo": map[string]string{"name": c.name}, } if err := c.conn.Call(ctx, "initialize", params, nil); err != nil { return fmt.Errorf("lsp: initialize: %w", err) } if err := c.conn.Notify("initialized", map[string]any{}); err != nil { return fmt.Errorf("lsp: initialized: %w", err) } c.mu.Lock() c.ready = true c.mu.Unlock() return nil } // clientCapabilities describes what this editor can actually make use of. // // It is deliberately modest: claiming a capability the editor cannot honour — // snippet placeholders, say — makes the server send things that then have to // be thrown away. func clientCapabilities() map[string]any { return map[string]any{ "textDocument": map[string]any{ "synchronization": map[string]any{ "didSave": true, }, "completion": map[string]any{ "completionItem": map[string]any{ "snippetSupport": false, "documentationFormat": []string{"plaintext"}, }, }, "hover": map[string]any{ "contentFormat": []string{"plaintext", "markdown"}, }, // The three that answer "where else does this name appear". They // take the same parameters as definition and return the same // shape, which is why one decoder serves all four. "references": map[string]any{}, "implementation": map[string]any{}, "typeDefinition": map[string]any{}, "publishDiagnostics": map[string]any{}, }, "workspace": map[string]any{ "configuration": true, }, } } // DidOpen tells the server a file is being edited. func (c *Client) DidOpen(path, text string) error { c.mu.Lock() c.versions[path] = 1 c.mu.Unlock() return c.conn.Notify("textDocument/didOpen", DidOpenParams{ TextDocument: TextDocumentItem{ URI: PathToURI(path), LanguageID: "go", Version: 1, Text: text, }, }) } // DidChange sends a new version of a file. The whole text goes each time; see // ContentChange for why. func (c *Client) DidChange(path, text string) error { c.mu.Lock() c.versions[path]++ version := c.versions[path] c.mu.Unlock() return c.conn.Notify("textDocument/didChange", DidChangeParams{ TextDocument: VersionedTextDocumentIdentifier{URI: PathToURI(path), Version: version}, ContentChanges: []ContentChange{{Text: text}}, }) } // DidSave tells the server a file has been written to disk. func (c *Client) DidSave(path, text string) error { return c.conn.Notify("textDocument/didSave", DidSaveParams{ TextDocument: TextDocumentIdentifier{URI: PathToURI(path)}, Text: text, }) } // FileCreated tells the server a file has appeared on disk. // // A document being open (DidOpen) and a file existing are two different facts // to a server that works out a package's files from the directory: moon-lsp // diagnoses a .mbt file saved for the first time only once this has been sent, // however loudly the document was announced. The spec has clients send these // for files the server asked to watch; sending one unasked is harmless to the // servers this library has been driven against, and is the only way to tell a // server that asks for nothing. func (c *Client) FileCreated(path string) error { return c.conn.Notify("workspace/didChangeWatchedFiles", DidChangeWatchedFilesParams{ Changes: []FileEvent{{URI: PathToURI(path), Type: FileCreated}}, }) } // DidClose tells the server a file is no longer being edited. func (c *Client) DidClose(path string) error { c.mu.Lock() delete(c.versions, path) c.mu.Unlock() return c.conn.Notify("textDocument/didClose", DidCloseParams{ TextDocument: TextDocumentIdentifier{URI: PathToURI(path)}, }) } // Complete asks what could be typed at a place in a file. // // line and runeColumn are the editor's own coordinates; lineText is that // line's content, which is what the rune-to-UTF-16 conversion needs. func (c *Client) Complete(ctx context.Context, path string, line, runeColumn int, lineText string) ([]CompletionItem, error) { if !c.Ready() { return nil, ErrNotReady } ctx, cancel := context.WithTimeout(ctx, RequestTimeout) defer cancel() var raw json.RawMessage if err := c.conn.Call(ctx, "textDocument/completion", positionParams(path, line, runeColumn, lineText), &raw); err != nil { return nil, err } return decodeCompletion(raw) } // decodeCompletion reads either shape the protocol allows: a bare array of // items, or a list object wrapping one. // // Which it is follows from the first character, not from trying one and // falling back: a list object whose items happen to be empty decodes as an // array "successfully" into nothing, and the failure would be silent. func decodeCompletion(raw json.RawMessage) ([]CompletionItem, error) { trimmed := bytes.TrimSpace(raw) if len(trimmed) == 0 || string(trimmed) == "null" { return nil, nil } if trimmed[0] == '[' { var items []CompletionItem if err := json.Unmarshal(trimmed, &items); err != nil { return nil, fmt.Errorf("lsp: unreadable completion result: %w", err) } return items, nil } var list CompletionList if err := json.Unmarshal(trimmed, &list); err != nil { return nil, fmt.Errorf("lsp: unreadable completion result: %w", err) } return list.Items, nil } // Hover asks what the thing under the cursor is, and returns the text to show // or the empty string when the server has nothing to say. func (c *Client) Hover(ctx context.Context, path string, line, runeColumn int, lineText string) (string, error) { if !c.Ready() { return "", ErrNotReady } ctx, cancel := context.WithTimeout(ctx, RequestTimeout) defer cancel() var hover *Hover if err := c.conn.Call(ctx, "textDocument/hover", positionParams(path, line, runeColumn, lineText), &hover); err != nil { return "", err } if hover == nil { return "", nil } return hover.Contents.Value, nil } // Definition asks where the thing under the cursor is declared. func (c *Client) Definition(ctx context.Context, path string, line, runeColumn int, lineText string) ([]Location, error) { return c.locationRequest(ctx, "textDocument/definition", positionParams(path, line, runeColumn, lineText)) } // TypeDefinition asks where the *type* of the thing under the cursor is // declared, which is a different question from where the thing itself is. // // locations, err := client.TypeDefinition(ctx, path, line, column, lineText) // // The answer has the same shape as a definition's, and is read the same way. func (c *Client) TypeDefinition(ctx context.Context, path string, line, runeColumn int, lineText string) ([]Location, error) { return c.locationRequest(ctx, "textDocument/typeDefinition", positionParams(path, line, runeColumn, lineText)) } // Implementation asks what implements the thing under the cursor: the types // satisfying a Go interface, the impl blocks of a Rust trait. // // locations, err := client.Implementation(ctx, path, line, column, lineText) func (c *Client) Implementation(ctx context.Context, path string, line, runeColumn int, lineText string) ([]Location, error) { return c.locationRequest(ctx, "textDocument/implementation", positionParams(path, line, runeColumn, lineText)) } // References asks where the thing under the cursor is used. // // includeDeclaration says whether the declaration itself is one of the // answers. It is a parameter rather than a constant because the two readings // are both reasonable — "show me everything that mentions this" wants it, "show // me the callers" does not — and the caller is the one that knows which // question was asked. // // locations, err := client.References(ctx, path, line, column, lineText, true) func (c *Client) References(ctx context.Context, path string, line, runeColumn int, lineText string, includeDeclaration bool) ([]Location, error) { params := referenceParams{ TextDocumentPositionParams: positionParams(path, line, runeColumn, lineText), Context: referenceContext{IncludeDeclaration: includeDeclaration}, } return c.locationRequest(ctx, "textDocument/references", params) } // referenceParams is a position with the one extra field references takes. type referenceParams struct { TextDocumentPositionParams Context referenceContext `json:"context"` } type referenceContext struct { IncludeDeclaration bool `json:"includeDeclaration"` } // locationRequest sends one of the four requests that answer with places in // the code, and reads the answer. // // definition, typeDefinition, implementation and references differ only in // their method name and, for references, one extra parameter. Writing the // call once means a server that answers a bare object where the specification // says array — which happens — is handled the same way for all of them. func (c *Client) locationRequest(ctx context.Context, method string, params any) ([]Location, error) { if !c.Ready() { return nil, ErrNotReady } ctx, cancel := context.WithTimeout(ctx, RequestTimeout) defer cancel() var raw json.RawMessage if err := c.conn.Call(ctx, method, params, &raw); err != nil { return nil, err } return decodeLocations(raw) } // decodeLocations reads either shape a definition answer may take: one // location, or an array of them. func decodeLocations(raw json.RawMessage) ([]Location, error) { trimmed := bytes.TrimSpace(raw) if len(trimmed) == 0 || string(trimmed) == "null" { return nil, nil } if trimmed[0] == '[' { var many []Location if err := json.Unmarshal(trimmed, &many); err != nil { return nil, fmt.Errorf("lsp: unreadable definition result: %w", err) } return many, nil } var one Location if err := json.Unmarshal(trimmed, &one); err != nil { return nil, fmt.Errorf("lsp: unreadable definition result: %w", err) } return []Location{one}, nil } // positionParams builds the document-and-place parameters most requests take, // converting the column from runes to UTF-16 units on the way. func positionParams(path string, line, runeColumn int, lineText string) TextDocumentPositionParams { return TextDocumentPositionParams{ TextDocument: TextDocumentIdentifier{URI: PathToURI(path)}, Position: Position{Line: line, Character: RuneToUTF16(lineText, runeColumn)}, } } // Shutdown asks the server to stop and closes the connection. // // The connection is closed whatever the server answers: a language server that // will not shut down politely is still one the editor is finished with. func (c *Client) Shutdown(ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, RequestTimeout) defer cancel() callErr := c.conn.Call(ctx, "shutdown", nil, nil) if callErr == nil { _ = c.conn.Notify("exit", nil) } closeErr := c.conn.Close() if callErr != nil { return callErr } return closeErr } // handleNotification deals with what the server sends unprompted. func (c *Client) handleNotification(method string, params json.RawMessage) { switch method { case "textDocument/publishDiagnostics": c.publishDiagnostics(params) case "window/showMessage", "window/logMessage": c.logMessage(params) } } // publishDiagnostics passes a file's problems on to the editor. func (c *Client) publishDiagnostics(params json.RawMessage) { if c.OnDiagnostics == nil { return } var published PublishDiagnosticsParams if err := json.Unmarshal(params, &published); err != nil { return } c.OnDiagnostics(URIToPath(published.URI), published.Diagnostics) } // logMessage passes a server message on to the editor. func (c *Client) logMessage(params json.RawMessage) { if c.OnLog == nil { return } var message struct { Message string `json:"message"` } if err := json.Unmarshal(params, &message); err != nil || message.Message == "" { return } c.OnLog(message.Message) } // answerRequest replies to the server, on the spot. // // Every question a language server asks can be answered from what this client // already knows, so there is nothing to wait for and the deferred half of // jsonrpc.Request is unused here. An agent's questions cannot be answered that // way, which is what the deferred half exists for. func (c *Client) answerRequest(req *jsonrpc.Request) { req.Reply(c.handleRequest(req.Method, req.Params)) } // handleRequest answers the requests a language server makes of its client. // // Answering them matters: gopls and rust-analyzer both ask for configuration during start-up and // waits for the reply, so a client that ignores the question never finishes // initialising. func (c *Client) handleRequest(method string, params json.RawMessage) (any, error) { switch method { case "workspace/configuration": return configurationReply(params), nil case "window/workDoneProgress/create", "client/registerCapability", "client/unregisterCapability": return nil, nil default: return nil, &ResponseError{Code: CodeMethodNotFound, Message: method} } } // configurationReply answers a configuration request with one empty settings // object per item asked about, which means "use your defaults". func configurationReply(params json.RawMessage) []map[string]any { var request struct { Items []json.RawMessage `json:"items"` } if err := json.Unmarshal(params, &request); err != nil || len(request.Items) == 0 { return []map[string]any{{}} } reply := make([]map[string]any, len(request.Items)) for i := range reply { reply[i] = map[string]any{} } return reply }