mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
6a6d201a50
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.
1091 lines
28 KiB
Go
1091 lines
28 KiB
Go
//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 <file> 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("<lsp_diagnostics file=%q>\n%s\n Summary: %s\n</lsp_diagnostics>",
|
|
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 <path>",
|
|
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 <file-path>")
|
|
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
|
|
},
|
|
})
|
|
}
|