From 6a6d201a504cde39da95db171e1cc49fe4fa358e Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Sun, 15 Mar 2026 14:29:27 +0300 Subject: [PATCH] add LSP diagnostics example extension Adds an extension that starts language servers on demand and surfaces diagnostics after file edits, following crush's LSP integration pattern. Hooks into the edit tool lifecycle to diff pre/post diagnostics, display a persistent widget, and expose lsp_diagnostics/lsp_hover tools plus /lsp and /lsp-check slash commands. --- examples/extensions/lsp-diagnostics.go | 1090 ++++++++++++++++++++++++ 1 file changed, 1090 insertions(+) create mode 100644 examples/extensions/lsp-diagnostics.go diff --git a/examples/extensions/lsp-diagnostics.go b/examples/extensions/lsp-diagnostics.go new file mode 100644 index 00000000..abd865a4 --- /dev/null +++ b/examples/extensions/lsp-diagnostics.go @@ -0,0 +1,1090 @@ +//go:build ignore + +// lsp-diagnostics.go — LSP-powered diagnostics for Kit's edit tool. +// +// Starts language servers on demand and surfaces diagnostics after file edits, +// following the same pattern used by Charm's crush editor: +// +// 1. After an edit, notify the LSP server of the file change +// 2. Wait for the server to publish fresh diagnostics +// 3. Append diagnostic output to the edit tool's result +// +// This gives the LLM immediate feedback when an edit introduces errors, +// enabling it to self-correct without a separate build/lint step. +// +// Features: +// - Auto-starts LSP servers per language on first file edit +// - Post-edit diagnostics appended to edit tool results +// - Pre/post diff highlights newly introduced errors +// - Persistent TUI widget showing diagnostic counts +// - lsp_diagnostics tool callable by the LLM +// - lsp_hover tool for type/documentation lookups +// - /lsp command to view active server status +// - /lsp-check command for manual diagnostics +// +// Configuration (via options, KIT_OPT_* env vars, or .kit.yml): +// +// lsp-go Go server command (default: gopls) +// lsp-ts TypeScript server cmd (default: typescript-language-server --stdio) +// lsp-python Python server command (default: pylsp) +// lsp-rust Rust server command (default: rust-analyzer) +// +// Usage: +// +// kit -e examples/extensions/lsp-diagnostics.go +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "kit/ext" +) + +// ── Package-level state ───────────────────────────────────────── + +var ( + manager *lspManager + preEditCache map[string][]diagEntry // abs path → diagnostics before edit + cacheMu sync.Mutex +) + +// ── Diagnostic entry ──────────────────────────────────────────── + +// diagEntry is a single diagnostic message from an LSP server. +type diagEntry struct { + File string + Line int // 0-indexed (LSP convention) + Char int + EndLine int + EndChar int + Severity int // 1=Error 2=Warning 3=Info 4=Hint + Source string + Message string +} + +func (d diagEntry) icon() string { + switch d.Severity { + case 1: + return "E" + case 2: + return "W" + case 3: + return "I" + case 4: + return "H" + default: + return "?" + } +} + +// key returns a comparable identity string for diffing diagnostics. +// Note: line shifts from edits above a diagnostic cause false "new" entries; +// this is acceptable — the diff is a hint, not a guarantee. +func (d diagEntry) key() string { + return fmt.Sprintf("%d:%d:%d:%s", d.Line, d.Char, d.Severity, d.Message) +} + +// ── LSP Client ────────────────────────────────────────────────── + +// lspClient manages a single LSP server process and its JSON-RPC transport. +type lspClient struct { + lang string + workDir string + cmd *exec.Cmd + stdin io.WriteCloser + stdout io.ReadCloser + writeMu sync.Mutex + + nextID int + idMu sync.Mutex + pending map[int]chan json.RawMessage + pendingMu sync.Mutex + + diagnostics map[string][]diagEntry // uri → diagnostics + diagVersion int + diagMu sync.Mutex + + openFiles map[string]int // uri → version + openFilesMu sync.Mutex + + ready bool + done chan struct{} +} + +func newLSPClient(lang, command, workDir string) (*lspClient, error) { + parts := strings.Fields(command) + if len(parts) == 0 { + return nil, fmt.Errorf("empty LSP command for %s", lang) + } + + if _, err := exec.LookPath(parts[0]); err != nil { + return nil, fmt.Errorf("%s: %s not found on PATH", lang, parts[0]) + } + + cmd := exec.Command(parts[0], parts[1:]...) + cmd.Dir = workDir + cmd.Stderr = nil + + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, fmt.Errorf("stdin pipe: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + stdin.Close() + return nil, fmt.Errorf("stdout pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("start %s: %w", parts[0], err) + } + + c := &lspClient{ + lang: lang, + workDir: workDir, + cmd: cmd, + stdin: stdin, + stdout: stdout, + pending: make(map[int]chan json.RawMessage), + diagnostics: make(map[string][]diagEntry), + openFiles: make(map[string]int), + done: make(chan struct{}), + } + + go c.readLoop() + return c, nil +} + +// readLoop reads Content-Length framed JSON-RPC messages from stdout. +func (c *lspClient) readLoop() { + defer close(c.done) + reader := bufio.NewReader(c.stdout) + for { + contentLen := 0 + for { + line, err := reader.ReadString('\n') + if err != nil { + return + } + line = strings.TrimSpace(line) + if line == "" { + break + } + if strings.HasPrefix(line, "Content-Length:") { + n, _ := strconv.Atoi(strings.TrimSpace(line[len("Content-Length:"):])) + contentLen = n + } + } + if contentLen == 0 { + continue + } + + body := make([]byte, contentLen) + if _, err := io.ReadFull(reader, body); err != nil { + return + } + c.handleMessage(body) + } +} + +func (c *lspClient) handleMessage(body []byte) { + var msg map[string]any + if json.Unmarshal(body, &msg) != nil { + return + } + + id, hasID := msg["id"] + method, _ := msg["method"].(string) + + // Server request (has both id and method) → acknowledge with minimal response. + if hasID && method != "" { + result := any(nil) + if method == "workspace/configuration" { + result = []map[string]any{{}} + } + c.sendJSON(map[string]any{"jsonrpc": "2.0", "id": id, "result": result}) + return + } + + // Response to one of our requests. + if hasID && method == "" { + idFloat, _ := id.(float64) + idInt := int(idFloat) + c.pendingMu.Lock() + ch, ok := c.pending[idInt] + if ok { + delete(c.pending, idInt) + } + c.pendingMu.Unlock() + if ok { + raw, _ := json.Marshal(msg["result"]) + ch <- raw + } + return + } + + // Notification from server. + if method == "textDocument/publishDiagnostics" { + raw, _ := json.Marshal(msg["params"]) + c.handlePublishDiagnostics(raw) + } +} + +func (c *lspClient) handlePublishDiagnostics(raw []byte) { + var params struct { + URI string `json:"uri"` + Diagnostics []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"` + Source string `json:"source"` + Message string `json:"message"` + } `json:"diagnostics"` + } + if json.Unmarshal(raw, ¶ms) != nil { + return + } + + entries := make([]diagEntry, len(params.Diagnostics)) + for i, d := range params.Diagnostics { + entries[i] = diagEntry{ + File: uriToPath(params.URI), + Line: d.Range.Start.Line, + Char: d.Range.Start.Character, + EndLine: d.Range.End.Line, + EndChar: d.Range.End.Character, + Severity: d.Severity, + Source: d.Source, + Message: d.Message, + } + } + + c.diagMu.Lock() + c.diagnostics[params.URI] = entries + c.diagVersion++ + c.diagMu.Unlock() +} + +// ── Transport helpers ─────────────────────────────────────────── + +func (c *lspClient) sendJSON(v any) error { + data, err := json.Marshal(v) + if err != nil { + return err + } + header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data)) + c.writeMu.Lock() + defer c.writeMu.Unlock() + if _, err := c.stdin.Write([]byte(header)); err != nil { + return err + } + _, err = c.stdin.Write(data) + return err +} + +func (c *lspClient) request(method string, params any) (json.RawMessage, error) { + c.idMu.Lock() + id := c.nextID + c.nextID++ + c.idMu.Unlock() + + ch := make(chan json.RawMessage, 1) + c.pendingMu.Lock() + c.pending[id] = ch + c.pendingMu.Unlock() + + err := c.sendJSON(map[string]any{ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + }) + if err != nil { + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() + return nil, err + } + + select { + case result := <-ch: + return result, nil + case <-time.After(30 * time.Second): + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() + return nil, fmt.Errorf("%s: %q timed out", c.lang, method) + case <-c.done: + return nil, fmt.Errorf("%s: server exited", c.lang) + } +} + +func (c *lspClient) notify(method string, params any) error { + return c.sendJSON(map[string]any{ + "jsonrpc": "2.0", + "method": method, + "params": params, + }) +} + +// ── LSP protocol methods ─────────────────────────────────────── + +func (c *lspClient) initialize() error { + params := map[string]any{ + "processId": os.Getpid(), + "rootUri": fileURI(c.workDir), + "capabilities": map[string]any{ + "textDocument": map[string]any{ + "publishDiagnostics": map[string]any{}, + "hover": map[string]any{ + "contentFormat": []string{"markdown", "plaintext"}, + }, + "synchronization": map[string]any{ + "didSave": true, + }, + }, + }, + } + if _, err := c.request("initialize", params); err != nil { + return fmt.Errorf("initialize: %w", err) + } + if err := c.notify("initialized", map[string]any{}); err != nil { + return fmt.Errorf("initialized notify: %w", err) + } + c.ready = true + return nil +} + +func (c *lspClient) openFile(absPath, langID, content string) { + uri := fileURI(absPath) + c.openFilesMu.Lock() + if _, ok := c.openFiles[uri]; ok { + c.openFilesMu.Unlock() + return + } + c.openFiles[uri] = 1 + c.openFilesMu.Unlock() + + c.notify("textDocument/didOpen", map[string]any{ + "textDocument": map[string]any{ + "uri": uri, + "languageId": langID, + "version": 1, + "text": content, + }, + }) +} + +func (c *lspClient) changeFile(absPath, content string) { + uri := fileURI(absPath) + c.openFilesMu.Lock() + ver := c.openFiles[uri] + 1 + c.openFiles[uri] = ver + c.openFilesMu.Unlock() + + c.notify("textDocument/didChange", map[string]any{ + "textDocument": map[string]any{ + "uri": uri, + "version": ver, + }, + "contentChanges": []map[string]any{ + {"text": content}, + }, + }) +} + +// waitForDiagnostics polls until the server publishes new diagnostics or +// the timeout elapses. Mirrors crush's WaitForDiagnostics pattern. +func (c *lspClient) waitForDiagnostics(timeout time.Duration) { + c.diagMu.Lock() + startVersion := c.diagVersion + c.diagMu.Unlock() + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + time.Sleep(200 * time.Millisecond) + c.diagMu.Lock() + changed := c.diagVersion != startVersion + c.diagMu.Unlock() + if changed { + time.Sleep(100 * time.Millisecond) // allow batched notifications + return + } + } +} + +func (c *lspClient) getDiagnostics(absPath string) []diagEntry { + uri := fileURI(absPath) + c.diagMu.Lock() + defer c.diagMu.Unlock() + src := c.diagnostics[uri] + out := make([]diagEntry, len(src)) + copy(out, src) + return out +} + +func (c *lspClient) hover(absPath string, line, char int) (string, error) { + if !c.ready { + return "", fmt.Errorf("server not ready") + } + result, err := c.request("textDocument/hover", map[string]any{ + "textDocument": map[string]any{"uri": fileURI(absPath)}, + "position": map[string]any{"line": line, "character": char}, + }) + if err != nil { + return "", err + } + if string(result) == "null" { + return "No hover information available.", nil + } + var hover map[string]any + if json.Unmarshal(result, &hover) != nil { + return "No hover information available.", nil + } + return parseHoverContents(hover["contents"]), nil +} + +func (c *lspClient) shutdown() { + if !c.ready { + if c.cmd.Process != nil { + c.cmd.Process.Kill() + c.cmd.Wait() + } + return + } + + // Graceful: send shutdown request then exit notification. + shutdownDone := make(chan struct{}) + go func() { + c.request("shutdown", nil) + c.notify("exit", nil) + close(shutdownDone) + }() + select { + case <-shutdownDone: + case <-time.After(5 * time.Second): + } + + c.stdin.Close() + if c.cmd.Process != nil { + c.cmd.Process.Kill() + } + c.cmd.Wait() +} + +// ── Hover content parsing ─────────────────────────────────────── + +func parseHoverContents(v any) string { + if v == nil { + return "No hover information available." + } + // MarkupContent: {"kind": "markdown", "value": "..."} + if m, ok := v.(map[string]any); ok { + if val, ok := m["value"].(string); ok { + return val + } + } + // Plain string + if s, ok := v.(string); ok { + return s + } + // Array of MarkedString + if arr, ok := v.([]any); ok { + var parts []string + for _, item := range arr { + if s, ok := item.(string); ok { + parts = append(parts, s) + } else if m, ok := item.(map[string]any); ok { + if val, ok := m["value"].(string); ok { + parts = append(parts, val) + } + } + } + if len(parts) > 0 { + return strings.Join(parts, "\n\n") + } + } + return "No hover information available." +} + +// ── LSP Manager ───────────────────────────────────────────────── + +// lspManager coordinates per-language LSP clients with lazy startup. +type lspManager struct { + clients map[string]*lspClient + commands map[string]string // language → server command + workDir string + mu sync.Mutex +} + +func newLSPManager(workDir string, ctx ext.Context) *lspManager { + m := &lspManager{ + clients: make(map[string]*lspClient), + commands: map[string]string{ + "go": "gopls", + "typescript": "typescript-language-server --stdio", + "python": "pylsp", + "rust": "rust-analyzer", + }, + workDir: workDir, + } + + // Override defaults from extension options. + if v := ctx.GetOption("lsp-go"); v != "" { + m.commands["go"] = v + } + if v := ctx.GetOption("lsp-ts"); v != "" { + m.commands["typescript"] = v + m.commands["typescriptreact"] = v + m.commands["javascript"] = v + m.commands["javascriptreact"] = v + } + if v := ctx.GetOption("lsp-python"); v != "" { + m.commands["python"] = v + } + if v := ctx.GetOption("lsp-rust"); v != "" { + m.commands["rust"] = v + } + + // Share the TS server command for JS variants if not explicitly set. + if ts, ok := m.commands["typescript"]; ok { + if _, ok := m.commands["javascript"]; !ok { + m.commands["javascript"] = ts + } + if _, ok := m.commands["typescriptreact"]; !ok { + m.commands["typescriptreact"] = ts + } + if _, ok := m.commands["javascriptreact"]; !ok { + m.commands["javascriptreact"] = ts + } + } + + return m +} + +// clientFor returns (or lazily starts) the LSP client for a file's language. +// Returns nil if unsupported or if the server fails to start. +func (m *lspManager) clientFor(absPath string) *lspClient { + lang := detectLanguage(absPath) + if lang == "" { + return nil + } + + m.mu.Lock() + defer m.mu.Unlock() + + if c, ok := m.clients[lang]; ok { + return c + } + + cmd, ok := m.commands[lang] + if !ok || cmd == "" { + return nil + } + + c, err := newLSPClient(lang, cmd, m.workDir) + if err != nil { + // Server binary not found or failed to start — skip silently. + return nil + } + if err := c.initialize(); err != nil { + c.shutdown() + return nil + } + + m.clients[lang] = c + return c +} + +// notifyChange opens the file (if needed), sends a didChange notification, +// waits for the server to publish diagnostics, and returns them. +func (m *lspManager) notifyChange(absPath string) []diagEntry { + client := m.clientFor(absPath) + if client == nil { + return nil + } + + content, err := os.ReadFile(absPath) + if err != nil { + return nil + } + + lang := detectLanguage(absPath) + client.openFile(absPath, lang, string(content)) + client.changeFile(absPath, string(content)) + client.waitForDiagnostics(5 * time.Second) + return client.getDiagnostics(absPath) +} + +// cachedDiagnostics returns whatever diagnostics are currently cached +// without triggering a refresh. Used for pre-edit snapshots. +func (m *lspManager) cachedDiagnostics(absPath string) []diagEntry { + client := m.clientFor(absPath) + if client == nil { + return nil + } + return client.getDiagnostics(absPath) +} + +func (m *lspManager) hoverAt(absPath string, line, char int) (string, error) { + client := m.clientFor(absPath) + if client == nil { + return "", fmt.Errorf("no LSP server for %s", filepath.Ext(absPath)) + } + + // Ensure the file is open before requesting hover. + content, err := os.ReadFile(absPath) + if err != nil { + return "", err + } + lang := detectLanguage(absPath) + client.openFile(absPath, lang, string(content)) + + return client.hover(absPath, line, char) +} + +func (m *lspManager) shutdownAll() { + m.mu.Lock() + defer m.mu.Unlock() + for lang, c := range m.clients { + c.shutdown() + delete(m.clients, lang) + } +} + +func (m *lspManager) status() string { + m.mu.Lock() + defer m.mu.Unlock() + + if len(m.clients) == 0 { + return "No active LSP servers." + } + var lines []string + for lang, c := range m.clients { + state := "ready" + if !c.ready { + state = "starting" + } + c.diagMu.Lock() + total := 0 + for _, diags := range c.diagnostics { + total += len(diags) + } + c.diagMu.Unlock() + lines = append(lines, fmt.Sprintf(" %s: %s (%d diagnostics)", lang, state, total)) + } + return "Active LSP servers:\n" + strings.Join(lines, "\n") +} + +// ── Helpers ───────────────────────────────────────────────────── + +func detectLanguage(path string) string { + switch strings.ToLower(filepath.Ext(path)) { + case ".go": + return "go" + case ".ts": + return "typescript" + case ".tsx": + return "typescriptreact" + case ".js": + return "javascript" + case ".jsx": + return "javascriptreact" + case ".py": + return "python" + case ".rs": + return "rust" + default: + return "" + } +} + +func fileURI(absPath string) string { + p, err := filepath.Abs(absPath) + if err != nil { + p = absPath + } + return "file://" + p +} + +func uriToPath(uri string) string { + return strings.TrimPrefix(uri, "file://") +} + +func resolveEditPath(input string, cwd string) string { + var args struct { + Path string `json:"path"` + } + json.Unmarshal([]byte(input), &args) + if args.Path == "" { + return "" + } + if filepath.IsAbs(args.Path) { + return args.Path + } + return filepath.Join(cwd, args.Path) +} + +func isEditTool(name string) bool { + return strings.EqualFold(name, "edit") +} + +func formatDiagnostics(diags []diagEntry, filePath string) string { + if len(diags) == 0 { + return "" + } + errors, warnings, infos := countSeverities(diags) + var lines []string + for _, d := range diags { + src := "" + if d.Source != "" { + src = "[" + d.Source + "] " + } + lines = append(lines, fmt.Sprintf(" %s %s:%d:%d %s%s", + d.icon(), filepath.Base(d.File), d.Line+1, d.Char+1, src, d.Message)) + } + summary := formatSummary(errors, warnings, infos) + return fmt.Sprintf("\n%s\n Summary: %s\n", + filepath.Base(filePath), strings.Join(lines, "\n"), summary) +} + +func formatDiagnosticsPlain(diags []diagEntry) string { + var lines []string + for _, d := range diags { + src := "" + if d.Source != "" { + src = "[" + d.Source + "] " + } + lines = append(lines, fmt.Sprintf("%s %s:%d:%d %s%s", + d.icon(), filepath.Base(d.File), d.Line+1, d.Char+1, src, d.Message)) + } + errors, warnings, infos := countSeverities(diags) + lines = append(lines, "") + lines = append(lines, formatSummary(errors, warnings, infos)) + return strings.Join(lines, "\n") +} + +func countSeverities(diags []diagEntry) (errors, warnings, infos int) { + for _, d := range diags { + switch d.Severity { + case 1: + errors++ + case 2: + warnings++ + default: + infos++ + } + } + return +} + +func formatSummary(errors, warnings, infos int) string { + var parts []string + if errors > 0 { + parts = append(parts, fmt.Sprintf("%d error(s)", errors)) + } + if warnings > 0 { + parts = append(parts, fmt.Sprintf("%d warning(s)", warnings)) + } + if infos > 0 { + parts = append(parts, fmt.Sprintf("%d info/hint(s)", infos)) + } + if len(parts) == 0 { + return "no issues" + } + return strings.Join(parts, ", ") +} + +func diagBorderColor(diags []diagEntry) string { + for _, d := range diags { + if d.Severity == 1 { + return "#f38ba8" // red + } + } + for _, d := range diags { + if d.Severity == 2 { + return "#f9e2af" // yellow + } + } + return "#a6e3a1" // green +} + +func diffDiagnostics(before, after []diagEntry) []diagEntry { + existing := make(map[string]bool) + for _, d := range before { + existing[d.key()] = true + } + var fresh []diagEntry + for _, d := range after { + if !existing[d.key()] { + fresh = append(fresh, d) + } + } + return fresh +} + +// ── Widget helpers ────────────────────────────────────────────── + +func updateWidget(ctx ext.Context, diags []diagEntry, filePath string) { + if len(diags) == 0 { + ctx.RemoveWidget("lsp-diag:status") + return + } + errors, warnings, _ := countSeverities(diags) + text := fmt.Sprintf("LSP %s: %d error(s), %d warning(s)", + filepath.Base(filePath), errors, warnings) + ctx.SetWidget(ext.WidgetConfig{ + ID: "lsp-diag:status", + Placement: ext.WidgetAbove, + Content: ext.WidgetContent{Text: text}, + Style: ext.WidgetStyle{BorderColor: diagBorderColor(diags)}, + }) +} + +// ── Extension Init ────────────────────────────────────────────── + +func Init(api ext.API) { + preEditCache = make(map[string][]diagEntry) + + // ── Options ───────────────────────────────────────────── + + api.RegisterOption(ext.OptionDef{ + Name: "lsp-go", + Description: "Command to start the Go language server", + Default: "gopls", + }) + api.RegisterOption(ext.OptionDef{ + Name: "lsp-ts", + Description: "Command to start the TypeScript/JavaScript language server", + Default: "typescript-language-server --stdio", + }) + api.RegisterOption(ext.OptionDef{ + Name: "lsp-python", + Description: "Command to start the Python language server", + Default: "pylsp", + }) + api.RegisterOption(ext.OptionDef{ + Name: "lsp-rust", + Description: "Command to start the Rust language server", + Default: "rust-analyzer", + }) + + // ── Session lifecycle ─────────────────────────────────── + + api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) { + manager = newLSPManager(ctx.CWD, ctx) + }) + + api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) { + if manager != nil { + manager.shutdownAll() + manager = nil + } + ctx.RemoveWidget("lsp-diag:status") + }) + + // ── Edit tool interception ────────────────────────────── + + // Pre-edit: snapshot current diagnostics so we can diff after. + api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult { + if !isEditTool(tc.ToolName) || manager == nil { + return nil + } + absPath := resolveEditPath(tc.Input, ctx.CWD) + if absPath == "" || detectLanguage(absPath) == "" { + return nil + } + + // Warm up the LSP server and capture pre-edit diagnostics. + diags := manager.cachedDiagnostics(absPath) + if diags == nil { + diags = manager.notifyChange(absPath) + } + cacheMu.Lock() + preEditCache[absPath] = diags + cacheMu.Unlock() + + return nil + }) + + // Post-edit: refresh diagnostics, diff, append to result, update widget. + api.OnToolResult(func(tr ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult { + if !isEditTool(tr.ToolName) || tr.IsError || manager == nil { + return nil + } + absPath := resolveEditPath(tr.Input, ctx.CWD) + if absPath == "" || detectLanguage(absPath) == "" { + return nil + } + + // Notify LSP of the change and get fresh diagnostics. + postDiags := manager.notifyChange(absPath) + + cacheMu.Lock() + preDiags := preEditCache[absPath] + delete(preEditCache, absPath) + cacheMu.Unlock() + + // Build enhanced result. + enhanced := tr.Content + + if len(postDiags) > 0 { + enhanced += "\n\n" + formatDiagnostics(postDiags, absPath) + + // Highlight newly introduced errors. + newDiags := diffDiagnostics(preDiags, postDiags) + newErrors := 0 + for _, d := range newDiags { + if d.Severity == 1 { + newErrors++ + } + } + if newErrors > 0 { + enhanced += fmt.Sprintf( + "\n\nThis edit introduced %d new error(s). Review the diagnostics above and fix them.", + newErrors) + } + } else if len(preDiags) > 0 { + // Errors were resolved by this edit. + enhanced += "\n\nAll previous LSP diagnostics have been resolved." + } + + updateWidget(ctx, postDiags, absPath) + + if enhanced == tr.Content { + return nil + } + return &ext.ToolResultResult{Content: &enhanced} + }) + + // ── LLM-callable tools ────────────────────────────────── + + api.RegisterTool(ext.ToolDef{ + Name: "lsp_diagnostics", + Description: "Get LSP diagnostics (errors, warnings) for a file. Use after editing to verify changes are correct.", + Parameters: `{"type":"object","properties":{"file":{"type":"string","description":"File path to check for diagnostics"}},"required":["file"]}`, + Execute: func(input string) (string, error) { + if manager == nil { + return "LSP not initialized.", nil + } + var args struct { + File string `json:"file"` + } + if err := json.Unmarshal([]byte(input), &args); err != nil { + return "", fmt.Errorf("invalid input: %w", err) + } + absPath := args.File + if !filepath.IsAbs(absPath) { + absPath = filepath.Join(manager.workDir, absPath) + } + diags := manager.notifyChange(absPath) + if len(diags) == 0 { + return fmt.Sprintf("No diagnostics for %s.", args.File), nil + } + return formatDiagnostics(diags, absPath), nil + }, + }) + + api.RegisterTool(ext.ToolDef{ + Name: "lsp_hover", + Description: "Get type information and documentation for a symbol at a specific file position.", + Parameters: `{"type":"object","properties":{` + + `"file":{"type":"string","description":"File path"},` + + `"line":{"type":"integer","description":"Line number (1-indexed)"},` + + `"character":{"type":"integer","description":"Character offset (1-indexed)"}` + + `},"required":["file","line","character"]}`, + Execute: func(input string) (string, error) { + if manager == nil { + return "LSP not initialized.", nil + } + var args struct { + File string `json:"file"` + Line int `json:"line"` + Character int `json:"character"` + } + if err := json.Unmarshal([]byte(input), &args); err != nil { + return "", fmt.Errorf("invalid input: %w", err) + } + absPath := args.File + if !filepath.IsAbs(absPath) { + absPath = filepath.Join(manager.workDir, absPath) + } + // Convert 1-indexed (user-facing) to 0-indexed (LSP protocol). + result, err := manager.hoverAt(absPath, args.Line-1, args.Character-1) + if err != nil { + return fmt.Sprintf("Hover failed: %s", err), nil + } + return result, nil + }, + }) + + // ── Slash commands ────────────────────────────────────── + + api.RegisterCommand(ext.CommandDef{ + Name: "lsp", + Description: "Show active LSP server status", + Execute: func(args string, ctx ext.Context) (string, error) { + if manager == nil { + ctx.PrintInfo("LSP manager not initialized.") + return "", nil + } + ctx.PrintInfo(manager.status()) + return "", nil + }, + }) + + api.RegisterCommand(ext.CommandDef{ + Name: "lsp-check", + Description: "Run LSP diagnostics on a file: /lsp-check ", + Execute: func(args string, ctx ext.Context) (string, error) { + if manager == nil { + ctx.PrintError("LSP manager not initialized.") + return "", nil + } + path := strings.TrimSpace(args) + if path == "" { + ctx.PrintError("Usage: /lsp-check ") + return "", nil + } + absPath := path + if !filepath.IsAbs(absPath) { + absPath = filepath.Join(ctx.CWD, absPath) + } + if _, err := os.Stat(absPath); err != nil { + ctx.PrintError(fmt.Sprintf("File not found: %s", path)) + return "", nil + } + + diags := manager.notifyChange(absPath) + if len(diags) == 0 { + ctx.PrintInfo(fmt.Sprintf("No diagnostics for %s", path)) + } else { + ctx.PrintBlock(ext.PrintBlockOpts{ + Text: formatDiagnosticsPlain(diags), + BorderColor: diagBorderColor(diags), + Subtitle: "lsp-diagnostics", + }) + } + updateWidget(ctx, diags, absPath) + return "", nil + }, + }) +}