summaryrefslogtreecommitdiff
path: root/lsp.go
diff options
context:
space:
mode:
Diffstat (limited to 'lsp.go')
-rw-r--r--lsp.go668
1 files changed, 668 insertions, 0 deletions
diff --git a/lsp.go b/lsp.go
new file mode 100644
index 0000000..1dbd750
--- /dev/null
+++ b/lsp.go
@@ -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"))
+}