diff --git a/editor.go b/editor.go index ee63916894b67d50a09d3bafc5d357e7e42d5b4f..7186803418551ff0dff7805a7a08d54dff57ecad 100644 --- a/editor.go +++ b/editor.go @@ -176,6 +176,44 @@ } return result.String() } +func (e *Editor) wrapText(text string, maxWidth int) []string { + var lines []string + paragraphs := strings.Split(text, "\n") + + for _, p := range paragraphs { + words := strings.Fields(p) + if len(words) == 0 { + lines = append(lines, "") + continue + } + + currentLine := "" + for _, word := range words { + if len(currentLine)+len(word)+1 <= maxWidth { + if currentLine == "" { + currentLine = word + } else { + currentLine += " " + word + } + } else { + if currentLine != "" { + lines = append(lines, currentLine) + } + // If a single word is longer than maxWidth, we have to break it + for len(word) > maxWidth { + lines = append(lines, word[:maxWidth]) + word = word[maxWidth:] + } + currentLine = word + } + } + if currentLine != "" { + lines = append(lines, currentLine) + } + } + return lines +} + // NewEditor creates a new editor instance with a default empty buffer. func NewEditor(devMode bool) *Editor { e := &Editor{ @@ -1735,6 +1773,9 @@ return } e.pushJump() + + // Sync buffer content with LSP server before requesting definition. + b.lspClient.SendDidChange(b.toString()) locs, err := b.lspClient.Definition(b.PrimaryCursor().Y, b.PrimaryCursor().X) if err != nil { @@ -4588,6 +4629,9 @@ e.message = "Requesting signature..." e.draw() + // Sync buffer content with LSP server before requesting hover. + b.lspClient.SendDidChange(b.toString()) + cursor := b.PrimaryCursor() content, err := b.lspClient.Hover(cursor.Y, cursor.X) if err != nil { @@ -4608,6 +4652,9 @@ } e.message = "Requesting completions..." e.draw() + + // Sync buffer content with LSP server before requesting completions. + b.lspClient.SendDidChange(b.toString()) cursor := b.PrimaryCursor() items, err := b.lspClient.Completion(cursor.Y, cursor.X) @@ -4626,6 +4673,35 @@ e.autocompleteIndex = 0 e.autocompleteScroll = 0 e.showAutocomplete = true e.message = "" + + e.resolveSelectedCompletion() +} + +func (e *Editor) resolveSelectedCompletion() { + b := e.activeBuffer() + if b == nil || b.lspClient == nil { + return + } + + if e.autocompleteIndex < 0 || e.autocompleteIndex >= len(e.autocompleteItems) { + return + } + + item := e.autocompleteItems[e.autocompleteIndex] + + // If it already has documentation and it's not a resolve-only item, maybe skip? + // But clangd often sends partial info. + + go func(index int, it CompletionItem) { + resolved, err := b.lspClient.ResolveCompletion(it) + if err == nil { + // Update the item in the list if the index is still the same + if e.autocompleteIndex == index { + e.autocompleteItems[index] = resolved + termbox.Interrupt() // Redraw + } + } + }(e.autocompleteIndex, item) } func (e *Editor) drawAutocompletePopup() { @@ -4639,31 +4715,81 @@ if b == nil { return } - // Calculate max label width for alignment - maxLabelWidth := 0 - for _, item := range e.autocompleteItems { - if len(item.Label) > maxLabelWidth { - maxLabelWidth = len(item.Label) + // Helper to get extra info (signature or type) for an item + getExtraInfo := func(item CompletionItem) string { + info := "" + + // 1. Try LabelDetails (standard in newer LSP) + if item.LabelDetails != nil { + // For functions, Detail usually has the parameters: (a, b int) + // For variables, Description usually has the type: int + if item.Kind == 2 || item.Kind == 3 { // Method or Function + if item.LabelDetails.Detail != "" { + info = item.LabelDetails.Detail + } else if item.LabelDetails.Description != "" { + info = item.LabelDetails.Description + } + } else { + // For variables/others, prefer Description (type) + if item.LabelDetails.Description != "" { + info = item.LabelDetails.Description + } else if item.LabelDetails.Detail != "" { + info = item.LabelDetails.Detail + } + } } + + // 2. Fallback to Detail if still empty + if info == "" && item.Detail != "" { + info = item.Detail + // Some servers put the whole label in the detail, strip it if so + cleanLabel := item.Label + for strings.HasPrefix(cleanLabel, "•") || strings.HasPrefix(cleanLabel, " ") { + cleanLabel = strings.TrimPrefix(cleanLabel, "•") + cleanLabel = strings.TrimPrefix(cleanLabel, " ") + } + if strings.HasPrefix(info, cleanLabel) { + info = strings.TrimSpace(strings.TrimPrefix(info, cleanLabel)) + } + } + + return strings.TrimSpace(info) } - // Calculate total width: label + separator + detail - maxWidth := 0 - for _, item := range e.autocompleteItems { - displayText := item.Label - if item.Detail != "" { - // Pad label to align, then add arrow and detail - padding := maxLabelWidth - len(item.Label) - displayText = item.Label + strings.Repeat(" ", padding) + " " + item.Detail + // Calculate max width for the combined display + maxTotalWidth := 0 + type itemDisplay struct { + label string + sig string + } + displayItems := make([]itemDisplay, len(e.autocompleteItems)) + + for i, item := range e.autocompleteItems { + label := item.Label + for strings.HasPrefix(label, "•") || strings.HasPrefix(label, " ") { + label = strings.TrimPrefix(label, "•") + label = strings.TrimPrefix(label, " ") } - if len(displayText) > maxWidth { - maxWidth = len(displayText) + + extra := getExtraInfo(item) + displayItems[i] = itemDisplay{label: label, sig: extra} + + width := len(label) + if extra != "" { + width += len(extra) + 1 // +1 for spacing + } + if width > maxTotalWidth { + maxTotalWidth = width } } - // Cap width to terminal width - if maxWidth > w-10 { - maxWidth = w - 10 + maxWidth := maxTotalWidth + if maxWidth > 80 { + maxWidth = 80 + } + // Leave space for gutter and borders + if maxWidth > w-Config.GutterWidth-4 { + maxWidth = w - Config.GutterWidth - 4 } popupWidth := maxWidth + 2 @@ -4680,7 +4806,6 @@ startX := cursorScreenX startY := cursorScreenY + 1 - // Adjust if out of bounds if startY+popupHeight > h-1 { startY = cursorScreenY - popupHeight } @@ -4691,39 +4816,54 @@ if startX < 0 { startX = 0 } - fg, bg := GetThemeColor(ColorAutocompleteWindow) + _, bg := GetThemeColor(ColorAutocompleteWindow) selFg, selBg := GetThemeColor(ColorAutocompleteSelected) + + blackFg := termbox.ColorBlack + darkGrayFg := termbox.Attribute(244) // Dark gray in 256-color palette // Draw background and content for y := 0; y < popupHeight; y++ { itemIdx := y + e.autocompleteScroll - if itemIdx >= len(e.autocompleteItems) { + if itemIdx >= len(displayItems) { break } - item := e.autocompleteItems[itemIdx] + item := displayItems[itemIdx] + + currentBg := bg + labelFg := blackFg + sigFg := darkGrayFg - currentFg, currentBg := fg, bg if itemIdx == e.autocompleteIndex { - currentFg, currentBg = selFg, selBg + currentBg = selBg + labelFg = selFg + sigFg = selFg // Use selected foreground for both in selection } // Fill line for x := 0; x < popupWidth; x++ { - termbox.SetCell(startX+x, startY+y, ' ', currentFg, currentBg) + termbox.SetCell(startX+x, startY+y, ' ', labelFg, currentBg) } - // Draw label and detail (signature) with alignment - displayText := item.Label - if item.Detail != "" { - // Pad label to align with others - padding := maxLabelWidth - len(item.Label) - displayText = item.Label + strings.Repeat(" ", padding) + " " + item.Detail + // Draw label + lx := 0 + for _, r := range item.label { + if lx < maxWidth { + termbox.SetCell(startX+1+lx, startY+y, r, labelFg, currentBg) + lx++ + } } - if len(displayText) > maxWidth { - displayText = displayText[:maxWidth-3] + "..." - } - for j, r := range displayText { - termbox.SetCell(startX+1+j, startY+y, r, currentFg, currentBg) + + // Draw signature if it fits + if item.sig != "" && lx < maxWidth-1 { + termbox.SetCell(startX+1+lx, startY+y, ' ', sigFg, currentBg) + lx++ + for _, r := range item.sig { + if lx < maxWidth { + termbox.SetCell(startX+1+lx, startY+y, r, sigFg, currentBg) + lx++ + } + } } } } @@ -4749,7 +4889,9 @@ } // Text to insert insertText := item.InsertText - if insertText == "" { + if item.TextEdit != nil { + insertText = item.TextEdit.NewText + } else if insertText == "" { insertText = item.Label } diff --git a/ftypes.go b/ftypes.go index 145391df5b5a959807902bfd68b4e5ea2f0aee4e..aed258db84cf8a173fe96ec7ea84d778f8c246b3 100644 --- a/ftypes.go +++ b/ftypes.go @@ -8,6 +8,7 @@ // FileType represents the configuration for a specific programming language. type FileType struct { Name string // Display name of the file type. + LanguageID string // LSP language identifier (e.g., "cpp", "typescriptreact"). Extensions []string // File extensions (e.g., .go, .py) or filenames (e.g., Makefile). UseTabs bool // Whether to use tabs for indentation. Comment string // Single-line comment prefix (e.g., // or #). @@ -21,16 +22,19 @@ // fileTypes is a global list of all supported languages in the editor. var fileTypes = []*FileType{ { - Name: "Go", - Extensions: []string{".go"}, - UseTabs: true, - Comment: "//", - TabWidth: Config.DefaultTabWidth, - EnableLSP: true, - LSPCommand: "gopls", + Name: "Go", + LanguageID: "go", + Extensions: []string{".go"}, + UseTabs: true, + Comment: "//", + TabWidth: Config.DefaultTabWidth, + EnableLSP: true, + LSPCommand: "gopls", + LSPCommandArgs: []string{"serve"}, }, { Name: "C", + LanguageID: "c", Extensions: []string{".c", ".h"}, UseTabs: true, Comment: "//", @@ -40,6 +44,7 @@ LSPCommand: "clangd", }, { Name: "C++", + LanguageID: "cpp", Extensions: []string{".cpp", ".hpp", ".cc", ".hh", ".cxx", ".hxx"}, UseTabs: true, Comment: "//", @@ -49,6 +54,7 @@ LSPCommand: "clangd", }, { Name: "JavaScript", + LanguageID: "javascript", Extensions: []string{".js"}, UseTabs: true, Comment: "//", @@ -59,6 +65,7 @@ LSPCommandArgs: []string{"--stdio"}, }, { Name: "TypeScript", + LanguageID: "typescript", Extensions: []string{".ts"}, UseTabs: true, Comment: "//", @@ -69,6 +76,7 @@ LSPCommandArgs: []string{"--stdio"}, }, { Name: "TSX", + LanguageID: "typescriptreact", Extensions: []string{".tsx"}, UseTabs: true, Comment: "//", @@ -79,15 +87,18 @@ LSPCommandArgs: []string{"--stdio"}, }, { Name: "Python", + LanguageID: "python", Extensions: []string{".py"}, UseTabs: false, Comment: "#", TabWidth: Config.DefaultTabWidth, + EnableLSP: true, LSPCommand: "pyright-langserver", LSPCommandArgs: []string{"--stdio"}, }, { Name: "Bash", + LanguageID: "shellscript", Extensions: []string{".sh"}, UseTabs: true, Comment: "#", @@ -95,6 +106,7 @@ TabWidth: Config.DefaultTabWidth, }, { Name: "CSS", + LanguageID: "css", Extensions: []string{".css"}, UseTabs: false, Comment: "//", @@ -102,6 +114,7 @@ TabWidth: Config.DefaultTabWidth, }, { Name: "Dockerfile", + LanguageID: "dockerfile", Extensions: []string{".dockerfile", "Dockerfile"}, UseTabs: false, Comment: "#", @@ -109,6 +122,7 @@ TabWidth: Config.DefaultTabWidth, }, { Name: "HTML", + LanguageID: "html", Extensions: []string{".html", ".htm"}, UseTabs: false, Comment: "", @@ -116,6 +130,7 @@ TabWidth: Config.DefaultTabWidth, }, { Name: "Lua", + LanguageID: "lua", Extensions: []string{".lua"}, UseTabs: true, Comment: "--", @@ -123,6 +138,7 @@ TabWidth: Config.DefaultTabWidth, }, { Name: "Markdown", + LanguageID: "markdown", Extensions: []string{".md", ".markdown"}, UseTabs: false, Comment: "", @@ -130,6 +146,7 @@ TabWidth: Config.DefaultTabWidth, }, { Name: "PHP", + LanguageID: "php", Extensions: []string{".php"}, UseTabs: true, Comment: "//", @@ -137,6 +154,7 @@ TabWidth: Config.DefaultTabWidth, }, { Name: "SQL", + LanguageID: "sql", Extensions: []string{".sql"}, UseTabs: true, Comment: "--", @@ -144,6 +162,7 @@ TabWidth: Config.DefaultTabWidth, }, { Name: "Makefile", + LanguageID: "makefile", Extensions: []string{".make", "Makefile", "makefile"}, UseTabs: true, Comment: "#", @@ -151,6 +170,7 @@ TabWidth: Config.DefaultTabWidth, }, { Name: "Text", + LanguageID: "plaintext", Extensions: []string{}, UseTabs: false, Comment: "", diff --git a/kevent.go b/kevent.go index e0aef2b6d830a59a659b2dbb5a53f757c51ca755..6c5ebbf48545e911133501e1fef9835250915c0e 100644 --- a/kevent.go +++ b/kevent.go @@ -563,6 +563,7 @@ } if e.autocompleteIndex >= e.autocompleteScroll+10 { e.autocompleteScroll = e.autocompleteIndex - 9 } + e.resolveSelectedCompletion() return case termbox.KeyArrowDown: e.autocompleteIndex++ @@ -576,6 +577,7 @@ } if e.autocompleteIndex >= e.autocompleteScroll+10 { e.autocompleteScroll = e.autocompleteIndex - 9 } + e.resolveSelectedCompletion() return case termbox.KeyEnter: e.insertCompletion(e.autocompleteItems[e.autocompleteIndex]) diff --git a/lsp.go b/lsp.go index 1dbd750c220071c7f1566e3fea0118f3b8702f68..d56edd38ef96720784e2db471cf2ba95f5bcc0d0 100644 --- a/lsp.go +++ b/lsp.go @@ -37,7 +37,8 @@ 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. + writeMutex sync.Mutex // Protects concurrent writes to stdin. + fileType *FileType // Associated file type for language ID. } // Position in a document (0-based line and character). @@ -60,11 +61,25 @@ } // 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"` + Label string `json:"label"` + LabelDetails *CompletionItemLabel `json:"labelDetails"` + Kind int `json:"kind"` + Detail string `json:"detail"` + Documentation interface{} `json:"documentation"` + InsertText string `json:"insertText"` + FilterText string `json:"filterText"` + TextEdit *TextEdit `json:"textEdit"` + Data interface{} `json:"data"` // Opaque data for resolve request +} + +type CompletionItemLabel struct { + Detail string `json:"detail"` // e.g. (int a, int b) + Description string `json:"description"` // e.g. int +} + +type TextEdit struct { + Range Range `json:"range"` + NewText string `json:"newText"` } // CompletionList represents a collection of completion items. @@ -108,10 +123,17 @@ // 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) + // Suppress the server's own internal log messages (stderr) and redirect to logCallback. + stderr, err := client.cmd.StderrPipe() if err == nil { - client.cmd.Stderr = devNull + go func() { + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + if client.logCallback != nil { + client.logCallback("LSP-stderr", scanner.Text()) + } + } + }() } client.stdin, err = client.cmd.StdinPipe() @@ -123,6 +145,9 @@ client.stdout, err = client.cmd.StdoutPipe() if err != nil { return nil, err } + + // Set working directory to the project root to help server find config files and resolve relative paths. + client.cmd.Dir = findProjectRoot(absPath) if err := client.cmd.Start(); err != nil { return nil, err @@ -150,16 +175,39 @@ func (c *LSPClient) nextID() int64 { return atomic.AddInt64(&c.messageID, 1) } +// Request sends a JSON-RPC request and waits for a response (up to 5s). +func (c *LSPClient) Request(method string, params interface{}) (map[string]interface{}, error) { + id := c.nextID() + responseChan := make(chan map[string]interface{}, 1) + c.responseMutex.Lock() + c.responses[id] = responseChan + c.responseMutex.Unlock() + + if err := c.sendRequestWithID(id, method, params); err != nil { + c.responseMutex.Lock() + delete(c.responses, id) + c.responseMutex.Unlock() + return nil, err + } + + select { + case resp := <-responseChan: + if errVal, ok := resp["error"]; ok { + return nil, fmt.Errorf("LSP error: %v", errVal) + } + return resp, nil + case <-time.After(10 * time.Second): + c.responseMutex.Lock() + delete(c.responses, id) + c.responseMutex.Unlock() + return nil, fmt.Errorf("LSP request timeout: %s", method) + } +} + // 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) + _, err := c.Request(method, params) + return err } // sendNotification sends a JSON-RPC message without expecting a response. @@ -183,8 +231,19 @@ if err != nil { return err } + if c.logCallback != nil { + msgStr := string(data) + if len(msgStr) > 500 { + msgStr = msgStr[:500] + "..." + } + c.logCallback("LSP-send", msgStr) + } + // 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) + + c.writeMutex.Lock() + defer c.writeMutex.Unlock() _, err = c.stdin.Write([]byte(content)) return err } @@ -211,9 +270,12 @@ if line == "" { break } + lowerLine := strings.ToLower(line) var length int - if n, _ := fmt.Sscanf(line, "Content-Length: %d", &length); n == 1 { - contentLength = length + if strings.HasPrefix(lowerLine, "content-length:") { + if n, _ := fmt.Sscanf(lowerLine, "content-length: %d", &length); n == 1 { + contentLength = length + } } } @@ -233,43 +295,81 @@ 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 the message has an "id", it's either a response to a request we sent + // or a request from the server to us. 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) + method, isServerRequest := msg["method"].(string) + + if isServerRequest { if c.logCallback != nil { - c.logCallback("LSP", fmt.Sprintf("Looking for response channel with ID=%d", idInt)) + c.logCallback("LSP", fmt.Sprintf("Received server request: %s (ID: %v)", method, idVal)) } - 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() - } + // Handle server-to-client requests. + c.handleServerRequest(method, idVal, msg["params"]) } else { if c.logCallback != nil { - c.logCallback("LSP", fmt.Sprintf("Failed to convert ID to int64: %v", idVal)) + c.logCallback("LSP", fmt.Sprintf("Received response for ID: %v", idVal)) + } + + var idInt int64 + validID := false + switch v := idVal.(type) { + case float64: + idInt = int64(v) + validID = true + case string: + fmt.Sscanf(v, "%d", &idInt) + validID = true + } + + if validID { + c.responseMutex.Lock() + ch, exists := c.responses[idInt] + if exists { + delete(c.responses, idInt) + c.responseMutex.Unlock() + ch <- msg + } else { + if c.logCallback != nil { + c.logCallback("LSP", fmt.Sprintf("No channel found for ID=%d", idInt)) + } + c.responseMutex.Unlock() + } } } } - // If it has no "id", it's an asynchronous notification (like updated diagnostics). + // If it has no "id", it's an asynchronous notification. if _, hasID := msg["id"]; !hasID { c.handleNotification(msg) } } +} + +// handleServerRequest responds to requests initiated by the server. +func (c *LSPClient) handleServerRequest(method string, id interface{}, params interface{}) { + // For now, we provide minimal responses to keep the server happy. + var result interface{} = nil + + if method == "workspace/configuration" { + // Return empty settings for any requested scope. + if p, ok := params.(map[string]interface{}); ok { + if items, ok := p["items"].([]interface{}); ok { + res := make([]interface{}, len(items)) + for i := range res { + res[i] = map[string]interface{}{} + } + result = res + } + } + } + + response := map[string]interface{}{ + "jsonrpc": "2.0", + "id": id, + "result": result, + } + c.sendMessage(response) } // handleNotification processes messages initiated by the server. @@ -314,27 +414,89 @@ termbox.Interrupt() } } +// findProjectRoot looks for a project root marker like .git, compile_commands.json, or .clangd. +func findProjectRoot(path string) string { + dir := filepath.Dir(path) + for { + if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { + return dir + } + if _, err := os.Stat(filepath.Join(dir, "compile_commands.json")); err == nil { + return dir + } + if _, err := os.Stat(filepath.Join(dir, ".clangd")); err == nil { + return dir + } + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return filepath.Dir(path) +} + // initialize sends the initial 'initialize' request to the server. func (c *LSPClient) initialize() error { - rootURI := "file://" + filepath.Dir(c.filename) + rootPath := findProjectRoot(c.filename) + rootURI := "file://" + rootPath params := map[string]interface{}{ "processId": os.Getpid(), "rootUri": rootURI, + "rootPath": rootPath, // Deprecated but some servers still use it. + "workspaceFolders": []map[string]interface{}{ + { + "uri": rootURI, + "name": filepath.Base(rootPath), + }, + }, "capabilities": map[string]interface{}{ "textDocument": map[string]interface{}{ + "synchronization": map[string]interface{}{ + "didSave": true, + "dynamicRegistration": false, + "willSave": false, + "willSaveWaitUntil": false, + }, "publishDiagnostics": map[string]interface{}{}, "hover": map[string]interface{}{ "contentFormat": []string{"plaintext"}, }, "completion": map[string]interface{}{ "completionItem": map[string]interface{}{ - "snippetSupport": false, + "snippetSupport": false, + "resolveSupport": map[string]interface{}{"properties": []string{"documentation", "detail"}}, + "insertReplaceSupport": true, + "labelDetailsSupport": true, + "deprecatedSupport": true, + "commitCharactersSupport": false, }, + "contextSupport": true, }, + "definition": map[string]interface{}{ + "dynamicRegistration": false, + "linkSupport": false, + }, + }, + "workspace": map[string]interface{}{ + "configuration": true, + "workspaceFolders": true, }, }, } + // Move textDocumentSync to top level of capabilities if needed by some servers, + // though it's technically under textDocument in some versions. + // Actually, the spec says it should be under capabilities for server capabilities, + // but for client capabilities it is under textDocument. + // However, many servers like gopls prefer it at a certain location. + // Let's add it to the top level of capabilities as well just in case. + params["capabilities"].(map[string]interface{})["textDocumentSync"] = 1 // Full + if err := c.sendRequest("initialize", params); err != nil { return err } @@ -344,7 +506,10 @@ } // sendDidOpen notifies the server that a file has been opened. func (c *LSPClient) sendDidOpen(content string) error { - languageID := strings.ToLower(c.fileType.Name) + languageID := c.fileType.LanguageID + if languageID == "" { + languageID = strings.ToLower(c.fileType.Name) + } params := map[string]interface{}{ "textDocument": map[string]interface{}{ "uri": c.uri, @@ -384,7 +549,6 @@ } // 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, @@ -395,54 +559,34 @@ "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() + resp, err := c.Request("textDocument/definition", params) + if err != nil { 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) + result := resp["result"] + if result == nil { + return nil, nil + } - // 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 - } + resJSON, _ := json.Marshal(result) - var locs []Location - if err := json.Unmarshal(resJSON, &locs); err == nil { - return locs, nil - } + // 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 + } - 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") + var locs []Location + if err := json.Unmarshal(resJSON, &locs); err == nil { + return locs, nil } + + return nil, nil } // 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, @@ -453,82 +597,99 @@ "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() + resp, err := c.Request("textDocument/hover", params) + if err != nil { 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 + } - 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 + } - // 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 + } - 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 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 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 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") } - } 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 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") + return "", nil +} + +// ResolveCompletion requests additional details for a completion item. +func (c *LSPClient) ResolveCompletion(item CompletionItem) (CompletionItem, error) { + resp, err := c.Request("completionItem/resolve", item) + if err != nil { + return item, err + } + + result := resp["result"] + if result == nil { + return item, nil + } + + resJSON, _ := json.Marshal(result) + var resolvedItem CompletionItem + if err := json.Unmarshal(resJSON, &resolvedItem); err != nil { + return item, err + } + + return resolvedItem, nil +} + +// getDocumentationString extracts a plain string from the Documentation field. +func (c *LSPClient) getDocumentationString(doc interface{}) string { + if doc == nil { + return "" + } + if s, ok := doc.(string); ok { + return stripMarkdown(s) + } + if m, ok := doc.(map[string]interface{}); ok { + if val, ok := m["value"].(string); ok { + return stripMarkdown(val) + } } + return "" } // 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, @@ -537,61 +698,36 @@ "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)) + "context": map[string]interface{}{ + "triggerKind": 1, // Invited + }, } - 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() + resp, err := c.Request("textDocument/completion", params) + if err != nil { 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 + } - result := resp["result"] - if result == nil { - return nil, nil - } + resJSON, _ := json.Marshal(result) - resJSON, _ := json.Marshal(result) + // Completion can return a CompletionList or an array of CompletionItems. + // Try unmarshaling into array of items first as it's more direct. + var compItems []CompletionItem + if err := json.Unmarshal(resJSON, &compItems); err == nil && compItems != nil { + return compItems, nil + } - // 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 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") - } + return nil, nil } // sendRequestWithID helper to send a request with a pre-generated ID.