diff options
Diffstat (limited to 'lsp.go')
| -rw-r--r-- | lsp.go | 668 |
1 files changed, 668 insertions, 0 deletions
@@ -0,0 +1,668 @@ +package main + +// Basic Language Server Protocol (LSP) client. Communicates with external +// language servers (like gopls or clangd) via JSON-RPC over standard +// input/output. + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/nsf/termbox-go" +) + +// LSPClient manages the lifecycle and communication with an LSP server process. +type LSPClient struct { + cmd *exec.Cmd // The underlying server process. + stdin io.WriteCloser // Write messages to the server. + stdout io.ReadCloser // Read messages from the server. + scanner *bufio.Scanner + messageID int64 // Monotonically increasing ID for requests. + diagnostics []Diagnostic // Cached errors/warnings from the server. + diagMutex sync.RWMutex // Protects access to diagnostics. + filename string // The file this client is associated with. + uri string // The LSP-compatible URI of the file. + shutdown bool // Flag to indicate the client is closing. + shutdownOnce sync.Once + logCallback func(string, string) // Debug logging. + + responses map[int64]chan map[string]interface{} // Map of request IDs to response channels. + responseMutex sync.Mutex + fileType *FileType // Associated file type for language ID. +} + +// Position in a document (0-based line and character). +type Position struct { + Line int `json:"line"` + Character int `json:"character"` +} + +// Range represents a span of text in a document. +type Range struct { + Start Position `json:"start"` + End Position `json:"end"` +} + +// Location points to a specific range in a specific file. +type Location struct { + URI string `json:"uri"` + Range Range `json:"range"` +} + +// CompletionItem represents a suggestion for completion. +type CompletionItem struct { + Label string `json:"label"` + Kind int `json:"kind"` + Detail string `json:"detail"` + Documentation string `json:"documentation"` + InsertText string `json:"insertText"` +} + +// CompletionList represents a collection of completion items. +type CompletionList struct { + IsIncomplete bool `json:"isIncomplete"` + Items []CompletionItem `json:"items"` +} + +// Diagnostic represents an error, warning, or hint from the language server. +type Diagnostic struct { + Range struct { + Start struct { + Line int `json:"line"` + Character int `json:"character"` + } `json:"start"` + End struct { + Line int `json:"line"` + Character int `json:"character"` + } `json:"end"` + } `json:"range"` + Severity int `json:"severity"` // 1=Error, 2=Warning, 3=Info, 4=Hint. + Message string `json:"message"` +} + +// NewLSPClient starts a new LSP server process for the given file type. +func NewLSPClient(filename string, fileContent string, logCallback func(string, string), ft *FileType) (*LSPClient, error) { + absPath, err := filepath.Abs(filename) + if err != nil { + return nil, err + } + + client := &LSPClient{ + filename: absPath, + uri: "file://" + absPath, + diagnostics: []Diagnostic{}, + logCallback: logCallback, + responses: make(map[int64]chan map[string]interface{}), + fileType: ft, + } + + // Launch the language server's executable. + client.cmd = exec.Command(ft.LSPCommand, ft.LSPCommandArgs...) + + // Suppress the server's own internal log messages (stderr). + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err == nil { + client.cmd.Stderr = devNull + } + + client.stdin, err = client.cmd.StdinPipe() + if err != nil { + return nil, err + } + + client.stdout, err = client.cmd.StdoutPipe() + if err != nil { + return nil, err + } + + if err := client.cmd.Start(); err != nil { + return nil, err + } + + // Start a background goroutine to read messages from the server's stdout. + go client.readMessages() + + // Perform the LSP handshake: Initialize and Notify Open. + if err := client.initialize(); err != nil { + client.Shutdown() + return nil, err + } + + if err := client.sendDidOpen(fileContent); err != nil { + client.Shutdown() + return nil, err + } + + return client, nil +} + +// nextID increments and returns the next request ID. +func (c *LSPClient) nextID() int64 { + return atomic.AddInt64(&c.messageID, 1) +} + +// sendRequest sends a JSON-RPC request and expects a response. +func (c *LSPClient) sendRequest(method string, params interface{}) error { + id := c.nextID() + request := map[string]interface{}{ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + } + return c.sendMessage(request) +} + +// sendNotification sends a JSON-RPC message without expecting a response. +func (c *LSPClient) sendNotification(method string, params interface{}) error { + notification := map[string]interface{}{ + "jsonrpc": "2.0", + "method": method, + "params": params, + } + return c.sendMessage(notification) +} + +// sendMessage writes a JSON-encoded message to the server's stdin. +func (c *LSPClient) sendMessage(msg interface{}) error { + if c.shutdown { + return fmt.Errorf("client is shutdown") + } + + data, err := json.Marshal(msg) + if err != nil { + return err + } + + // LSP messages use a header similar to HTTP: Content-Length followed by \r\n\r\n. + content := fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(data), data) + _, err = c.stdin.Write([]byte(content)) + return err +} + +// readMessages loops forever, parsing messages from the server's stdout. +func (c *LSPClient) readMessages() { + reader := bufio.NewReader(c.stdout) + + for { + if c.shutdown { + return + } + + // Parse the Content-Length header to know how many bytes to read next. + contentLength := 0 + for { + line, err := reader.ReadString('\n') + if err != nil { + return + } + + line = strings.TrimSpace(line) + if line == "" { + break + } + + var length int + if n, _ := fmt.Sscanf(line, "Content-Length: %d", &length); n == 1 { + contentLength = length + } + } + + if contentLength == 0 { + continue + } + + // Read the JSON body. + buf := make([]byte, contentLength) + _, err := io.ReadFull(reader, buf) + if err != nil { + return + } + + var msg map[string]interface{} + if err := json.Unmarshal(buf, &msg); err != nil { + continue + } + + // If the message has an "id", it's a response to a request we sent. + if idVal, hasID := msg["id"]; hasID { + if c.logCallback != nil { + c.logCallback("LSP", fmt.Sprintf("Received response with ID: %v (type: %T)", idVal, idVal)) + } + if id, ok := idVal.(float64); ok { + idInt := int64(id) + if c.logCallback != nil { + c.logCallback("LSP", fmt.Sprintf("Looking for response channel with ID=%d", idInt)) + } + c.responseMutex.Lock() + ch, exists := c.responses[idInt] + if exists { + if c.logCallback != nil { + c.logCallback("LSP", fmt.Sprintf("Found channel for ID=%d, sending response", idInt)) + } + delete(c.responses, idInt) + c.responseMutex.Unlock() + ch <- msg // Send response to the goroutine waiting for it. + } else { + if c.logCallback != nil { + c.logCallback("LSP", fmt.Sprintf("No channel found for ID=%d", idInt)) + } + c.responseMutex.Unlock() + } + } else { + if c.logCallback != nil { + c.logCallback("LSP", fmt.Sprintf("Failed to convert ID to int64: %v", idVal)) + } + } + } + + // If it has no "id", it's an asynchronous notification (like updated diagnostics). + if _, hasID := msg["id"]; !hasID { + c.handleNotification(msg) + } + } +} + +// handleNotification processes messages initiated by the server. +func (c *LSPClient) handleNotification(msg map[string]interface{}) { + method, ok := msg["method"].(string) + if !ok { + return + } + + // Server is sending updated errors/warnings for the file. + if method == "textDocument/publishDiagnostics" { + params, ok := msg["params"].(map[string]interface{}) + if !ok { + return + } + + uri, _ := params["uri"].(string) + if uri != c.uri { + return + } + + diagsRaw, ok := params["diagnostics"].([]interface{}) + if !ok { + return + } + + var diags []Diagnostic + for _, d := range diagsRaw { + diagJSON, _ := json.Marshal(d) + var diag Diagnostic + if json.Unmarshal(diagJSON, &diag) == nil { + diags = append(diags, diag) + } + } + + c.diagMutex.Lock() + c.diagnostics = diags + c.diagMutex.Unlock() + + // Tell termbox to refresh the UI so signs appear in the gutter. + termbox.Interrupt() + } +} + +// initialize sends the initial 'initialize' request to the server. +func (c *LSPClient) initialize() error { + rootURI := "file://" + filepath.Dir(c.filename) + params := map[string]interface{}{ + "processId": os.Getpid(), + "rootUri": rootURI, + "capabilities": map[string]interface{}{ + "textDocument": map[string]interface{}{ + "publishDiagnostics": map[string]interface{}{}, + "hover": map[string]interface{}{ + "contentFormat": []string{"plaintext"}, + }, + "completion": map[string]interface{}{ + "completionItem": map[string]interface{}{ + "snippetSupport": false, + }, + }, + }, + }, + } + + if err := c.sendRequest("initialize", params); err != nil { + return err + } + + return c.sendNotification("initialized", map[string]interface{}{}) +} + +// sendDidOpen notifies the server that a file has been opened. +func (c *LSPClient) sendDidOpen(content string) error { + languageID := strings.ToLower(c.fileType.Name) + params := map[string]interface{}{ + "textDocument": map[string]interface{}{ + "uri": c.uri, + "languageId": languageID, + "version": 1, + "text": content, + }, + } + return c.sendNotification("textDocument/didOpen", params) +} + +// SendDidChange notifies the server of changes to the document content. +func (c *LSPClient) SendDidChange(content string) error { + params := map[string]interface{}{ + "textDocument": map[string]interface{}{ + "uri": c.uri, + "version": c.nextID(), + }, + "contentChanges": []interface{}{ + map[string]interface{}{ + "text": content, + }, + }, + } + return c.sendNotification("textDocument/didChange", params) +} + +// GetDiagnostics returns a copy of the current file diagnostics. +func (c *LSPClient) GetDiagnostics() []Diagnostic { + c.diagMutex.RLock() + defer c.diagMutex.RUnlock() + + result := make([]Diagnostic, len(c.diagnostics)) + copy(result, c.diagnostics) + return result +} + +// Definition requests the location of the definition of the symbol at cursor. +func (c *LSPClient) Definition(line, character int) ([]Location, error) { + id := c.nextID() + params := map[string]interface{}{ + "textDocument": map[string]interface{}{ + "uri": c.uri, + }, + "position": map[string]interface{}{ + "line": line, + "character": character, + }, + } + + responseChan := make(chan map[string]interface{}, 1) + c.responseMutex.Lock() + c.responses[id] = responseChan + c.responseMutex.Unlock() + + if err := c.sendRequestWithID(id, "textDocument/definition", params); err != nil { + c.responseMutex.Lock() + delete(c.responses, id) + c.responseMutex.Unlock() + return nil, err + } + + select { + case resp := <-responseChan: + if err, ok := resp["error"]; ok { + return nil, fmt.Errorf("LSP error: %v", err) + } + + result := resp["result"] + if result == nil { + return nil, nil + } + + resJSON, _ := json.Marshal(result) + + // Definition can return a single Location or an array of them. + var loc Location + if err := json.Unmarshal(resJSON, &loc); err == nil && loc.URI != "" { + return []Location{loc}, nil + } + + var locs []Location + if err := json.Unmarshal(resJSON, &locs); err == nil { + return locs, nil + } + + return nil, nil + case <-time.After(5 * time.Second): + c.responseMutex.Lock() + delete(c.responses, id) + c.responseMutex.Unlock() + return nil, fmt.Errorf("LSP request timeout") + } +} + +// Hover requests documentation information for the symbol at cursor. +func (c *LSPClient) Hover(line, character int) (string, error) { + id := c.nextID() + params := map[string]interface{}{ + "textDocument": map[string]interface{}{ + "uri": c.uri, + }, + "position": map[string]interface{}{ + "line": line, + "character": character, + }, + } + + responseChan := make(chan map[string]interface{}, 1) + c.responseMutex.Lock() + c.responses[id] = responseChan + c.responseMutex.Unlock() + + if err := c.sendRequestWithID(id, "textDocument/hover", params); err != nil { + c.responseMutex.Lock() + delete(c.responses, id) + c.responseMutex.Unlock() + return "", err + } + + select { + case resp := <-responseChan: + if err, ok := resp["error"]; ok { + return "", fmt.Errorf("LSP error: %v", err) + } + + result := resp["result"] + if result == nil { + return "", nil + } + + // Hover responses are complex: they can be strings, objects, or arrays. + resMap, ok := result.(map[string]interface{}) + if !ok { + return "", nil + } + + contents := resMap["contents"] + if contents == nil { + return "", nil + } + + if mc, ok := contents.(map[string]interface{}); ok { + if val, ok := mc["value"].(string); ok { + return stripMarkdown(val), nil + } + } + + if s, ok := contents.(string); ok { + return stripMarkdown(s), nil + } + + if ss, ok := contents.([]interface{}); ok { + var result strings.Builder + for i, s := range ss { + if str, ok := s.(string); ok { + result.WriteString(stripMarkdown(str)) + if i < len(ss)-1 { + result.WriteString("\n") + } + } else if m, ok := s.(map[string]interface{}); ok { + if val, ok := m["value"].(string); ok { + result.WriteString(stripMarkdown(val)) + if i < len(ss)-1 { + result.WriteString("\n") + } + } + } + } + return strings.TrimSpace(result.String()), nil + } + + return "", nil + case <-time.After(5 * time.Second): + c.responseMutex.Lock() + delete(c.responses, id) + c.responseMutex.Unlock() + return "", fmt.Errorf("LSP request timeout") + } +} + +// Completion requests a list of completion items for the symbol at cursor. +func (c *LSPClient) Completion(line, character int) ([]CompletionItem, error) { + id := c.nextID() + params := map[string]interface{}{ + "textDocument": map[string]interface{}{ + "uri": c.uri, + }, + "position": map[string]interface{}{ + "line": line, + "character": character, + }, + } + + if c.logCallback != nil { + c.logCallback("LSP", fmt.Sprintf("Requesting completion at %d:%d (ID=%d)", line, character, id)) + } + + responseChan := make(chan map[string]interface{}, 1) + c.responseMutex.Lock() + c.responses[id] = responseChan + c.responseMutex.Unlock() + + if err := c.sendRequestWithID(id, "textDocument/completion", params); err != nil { + c.responseMutex.Lock() + delete(c.responses, id) + c.responseMutex.Unlock() + return nil, err + } + + select { + case resp := <-responseChan: + if c.logCallback != nil { + c.logCallback("LSP", fmt.Sprintf("Received completion response (ID=%d)", id)) + } + if err, ok := resp["error"]; ok { + return nil, fmt.Errorf("LSP error: %v", err) + } + + result := resp["result"] + if result == nil { + return nil, nil + } + + resJSON, _ := json.Marshal(result) + + // Completion can return a CompletionList or an array of CompletionItems. + var compList CompletionList + if err := json.Unmarshal(resJSON, &compList); err == nil { + return compList.Items, nil + } + + var compItems []CompletionItem + if err := json.Unmarshal(resJSON, &compItems); err == nil { + return compItems, nil + } + + return nil, nil + case <-time.After(10 * time.Second): + if c.logCallback != nil { + c.logCallback("LSP", fmt.Sprintf("Completion request timed out (ID=%d)", id)) + } + c.responseMutex.Lock() + delete(c.responses, id) + c.responseMutex.Unlock() + return nil, fmt.Errorf("LSP request timeout") + } +} + +// sendRequestWithID helper to send a request with a pre-generated ID. +func (c *LSPClient) sendRequestWithID(id int64, method string, params interface{}) error { + request := map[string]interface{}{ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + } + return c.sendMessage(request) +} + +// Shutdown gracefully closes the LSP client and stops the server process. +func (c *LSPClient) Shutdown() { + c.shutdownOnce.Do(func() { + c.shutdown = true + + c.sendRequest("shutdown", nil) + c.sendNotification("exit", nil) + + if c.stdin != nil { + c.stdin.Close() + } + if c.stdout != nil { + c.stdout.Close() + } + + if c.cmd != nil && c.cmd.Process != nil { + c.cmd.Wait() + } + }) +} + +// stripMarkdown provides a very naive way to remove markdown formatting from LSP responses. +func stripMarkdown(s string) string { + lines := strings.Split(s, "\n") + var result []string + inCodeBlock := false + for _, line := range lines { + // Ignore code block markers. + if strings.HasPrefix(line, "```") { + inCodeBlock = !inCodeBlock + continue + } + if inCodeBlock { + result = append(result, line) + continue + } + + l := line + l = strings.ReplaceAll(l, "**", "") + l = strings.ReplaceAll(l, "__", "") + + // Naive link stripping: [text](url) -> text + for { + start := strings.Index(l, "[") + end := strings.Index(l, "](") + if start != -1 && end != -1 && end > start { + closeParen := strings.Index(l[end:], ")") + if closeParen != -1 { + l = l[:start] + l[start+1:end] + l[end+closeParen+1:] + continue + } + } + break + } + + l = strings.ReplaceAll(l, "`", "") + result = append(result, l) + } + + return strings.TrimSpace(strings.Join(result, "\n")) +} |
