v0.38.0 58133ea

lsp.go RawHistoryFolder

1package tools
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"os"
8	"path/filepath"
9	"strings"
10	"time"
11	"unicode/utf8"
12
13	"github.com/bnema/zut/packages/agent/lsp"
14	"github.com/bnema/zut/packages/core"
15	"github.com/bnema/zut/packages/provider"
16)
17
18const (
19	maxLSPToolOutput    = 60 * 1024
20	defaultLSPTimeout   = 30 * time.Second
21	maxLSPTimeout       = 2 * time.Minute
22	maxLSPTimeoutMillis = int(maxLSPTimeout / time.Millisecond)
23)
24
25// LSPTool exposes diagnostics, language navigation, and server management to
26// the model. Manager owns processes and is normally shared by all tool calls
27// in one agent session.
28type LSPTool struct {
29	CWD            string
30	Manager        *lsp.Manager
31	Sandbox        *Sandbox
32	LSPDiagnostics bool
33}
34
35// NewLSPTool constructs the model-facing LSP tool.
36func NewLSPTool(cwd string, manager *lsp.Manager) *LSPTool {
37	if manager == nil {
38		manager = lsp.NewManager()
39	}
40	return &LSPTool{CWD: cwd, Manager: manager}
41}
42
43type lspArgs struct {
44	Action  string          `json:"action"`
45	Path    string          `json:"path,omitempty"`
46	Line    int             `json:"line,omitempty"`
47	Column  int             `json:"column,omitempty"`
48	Query   string          `json:"query,omitempty"`
49	NewName string          `json:"new_name,omitempty"`
50	Server  string          `json:"server,omitempty"`
51	Method  string          `json:"method,omitempty"`
52	Params  json.RawMessage `json:"params,omitempty"`
53	Apply   *bool           `json:"apply,omitempty"`
54	Timeout int             `json:"timeout_ms,omitempty"`
55	Max     int             `json:"max,omitempty"`
56	RunCLI  *bool           `json:"run_cli,omitempty"`
57}
58
59const lspSchema = `{"type":"object","properties":{"action":{"type":"string","enum":["diagnostics","definition","references","hover","symbols","rename","code_actions","type_definition","implementation","status","reload","capabilities","request"]},"path":{"type":"string","description":"File path relative to the workspace, required for document actions."},"line":{"type":"integer","minimum":1,"description":"1-based line for a document action."},"column":{"type":"integer","minimum":1,"description":"1-based column for a document action."},"query":{"type":"string","description":"Workspace symbol query; symbols uses document symbols when omitted."},"new_name":{"type":"string","description":"New identifier for rename."},"server":{"type":"string","description":"Optional server id for request or capabilities."},"method":{"type":"string","description":"Raw LSP method for request."},"params":{"type":"object","description":"Raw JSON-RPC params for request."},"apply":{"type":"boolean","description":"Apply a returned rename/code-action WorkspaceEdit. Only files inside the workspace are accepted."},"timeout_ms":{"type":"integer","minimum":1,"maximum":120000,"description":"Request timeout in milliseconds (default 30000, maximum 120000)."},"max":{"type":"integer","minimum":1,"maximum":50},"run_cli":{"type":"boolean","description":"Run configured CLI linters for diagnostics (default true)."}},"required":["action"]}`
60
61func (t *LSPTool) Name() string { return "lsp" }
62func (t *LSPTool) Description() string {
63	return "Query configured language servers and linters for diagnostics, navigation, refactors, symbols, capabilities, and raw LSP requests."
64}
65func (t *LSPTool) Schema() json.RawMessage { return json.RawMessage(lspSchema) }
66
67func (t *LSPTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
68	if ctx == nil {
69		ctx = context.Background()
70	}
71	var args lspArgs
72	if err := json.Unmarshal(raw, &args); err != nil {
73		return core.ToolResult{}, fmt.Errorf("invalid lsp args: %w", err)
74	}
75	args.Action = strings.ToLower(strings.TrimSpace(args.Action))
76	if args.Action == "" {
77		return core.ToolResult{}, fmt.Errorf("action is required")
78	}
79	if t.Manager == nil {
80		return core.ToolResult{}, fmt.Errorf("lsp manager is unavailable")
81	}
82	cwd := t.CWD
83	if cwd == "" {
84		cwd, _ = os.Getwd()
85	}
86	absCWD, err := filepath.Abs(cwd)
87	if err != nil {
88		return core.ToolResult{}, err
89	}
90	path := ""
91	if args.Path != "" && args.Path != "*" {
92		path = resolvePath(absCWD, args.Path)
93		if t.Sandbox != nil {
94			if err := t.Sandbox.CheckPath(path); err != nil {
95				return core.ToolResult{}, err
96			}
97		}
98	}
99	runCtx, cancel := context.WithTimeout(ctx, lspToolTimeout(args.Timeout))
100	defer cancel()
101	var text string
102	var details any
103	var actionErr error
104	switch args.Action {
105	case "diagnostics":
106		runCLI := true
107		if args.RunCLI != nil {
108			runCLI = *args.RunCLI
109		}
110		var diagnostics []lsp.Diagnostic
111		if path != "" && hasGlobMeta(args.Path) {
112			matches, globErr := filepath.Glob(path)
113			if globErr != nil {
114				actionErr = globErr
115				break
116			}
117			for _, match := range matches {
118				if t.Sandbox != nil {
119					if checkErr := t.Sandbox.CheckPath(match); checkErr != nil {
120						if actionErr == nil {
121							actionErr = checkErr
122						}
123						continue
124					}
125				}
126				values, diagErr := t.Manager.DiagnosticsWithOptions(runCtx, absCWD, match, runCLI)
127				if actionErr == nil {
128					actionErr = diagErr
129				}
130				diagnostics = append(diagnostics, values...)
131			}
132		} else {
133			diagnostics, actionErr = t.Manager.DiagnosticsWithOptions(runCtx, absCWD, path, runCLI)
134		}
135		max := boundedMax(args.Max)
136		text = lsp.SummarizeDiagnostics(diagnostics, absCWD, max)
137		visible := diagnostics
138		if len(visible) > max {
139			visible = visible[:max]
140		}
141		details = map[string]any{"diagnostics": visible, "count": len(diagnostics), "truncated": len(visible) < len(diagnostics), "run_cli": runCLI}
142	case "status":
143		var statuses []lsp.ServerStatus
144		statuses, actionErr = t.Manager.Status(absCWD, path)
145		text = prettyJSON(statuses)
146		details = statuses
147	case "reload":
148		actionErr = t.Manager.Reload(absCWD)
149		text = "Reloaded LSP and linter processes and configuration."
150	case "capabilities":
151		var capabilities map[string]json.RawMessage
152		capabilities, actionErr = t.Manager.Capabilities(runCtx, absCWD, path, args.Server)
153		text = prettyJSON(capabilities)
154		details = capabilities
155	case "request":
156		text, details, actionErr = t.executeRawRequest(runCtx, absCWD, path, args)
157	case "definition", "references", "hover", "symbols", "rename", "code_actions", "type_definition", "implementation":
158		text, details, actionErr = t.executeLanguageAction(runCtx, absCWD, path, args)
159	default:
160		return core.ToolResult{}, fmt.Errorf("unknown lsp action %q", args.Action)
161	}
162	if actionErr != nil && text == "" {
163		return core.ToolResult{}, actionErr
164	}
165	text = boundLSPText(text)
166	result := core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: text}}, IsError: actionErr != nil, Details: details}
167	if values, ok := details.(map[string]any); ok {
168		if paths, ok := values["modified_paths"].([]string); ok {
169			result.Context.Mutates = paths
170		}
171	}
172	return result, nil
173}
174
175func (t *LSPTool) executeLanguageAction(ctx context.Context, cwd, path string, args lspArgs) (string, any, error) {
176	if path == "" && (args.Action != "symbols" || args.Query == "") {
177		return "", nil, fmt.Errorf("path is required for %s", args.Action)
178	}
179	if args.Action != "symbols" && (args.Line < 1 || args.Column < 1) {
180		return "", nil, fmt.Errorf("line and column (1-based) are required for %s", args.Action)
181	}
182	position := lsp.Position{Line: args.Line - 1, Character: args.Column - 1}
183	method := map[string]string{"definition": "textDocument/definition", "references": "textDocument/references", "hover": "textDocument/hover", "symbols": "textDocument/documentSymbol", "rename": "textDocument/rename", "code_actions": "textDocument/codeAction", "type_definition": "textDocument/typeDefinition", "implementation": "textDocument/implementation"}[args.Action]
184	var params map[string]any
185	if args.Action == "symbols" {
186		if args.Query != "" {
187			method, params = "workspace/symbol", map[string]any{"query": args.Query}
188		} else {
189			params = map[string]any{"textDocument": map[string]any{"uri": fileURI(path)}}
190		}
191	} else {
192		params = map[string]any{"textDocument": map[string]any{"uri": fileURI(path)}, "position": position}
193		if args.Action == "references" {
194			params["context"] = map[string]any{"includeDeclaration": true}
195		}
196		if args.Action == "code_actions" {
197			params = map[string]any{"textDocument": map[string]any{"uri": fileURI(path)}, "range": lsp.Range{Start: position, End: position}, "context": map[string]any{"diagnostics": []any{}}}
198		}
199		if args.Action == "rename" {
200			if strings.TrimSpace(args.NewName) == "" {
201				return "", nil, fmt.Errorf("new_name is required for rename")
202			}
203			params["newName"] = args.NewName
204		}
205	}
206	responses := t.Manager.RequestAll(ctx, cwd, path, method, params)
207	apply := args.Apply != nil && *args.Apply
208	if args.Action == "rename" && args.Apply == nil {
209		apply = true
210	}
211	text, applyCount, modifiedPaths, applyErr := formatResponses(cwd, responses, args.Action, apply, t.Manager)
212	if applyCount > 0 {
213		text += fmt.Sprintf("\nApplied %d workspace edit(s).", applyCount)
214	}
215	details := map[string]any{"action": args.Action, "responses": responses, "applied": applyCount, "modified_paths": modifiedPaths}
216	if applyCount > 0 && t.LSPDiagnostics {
217		result := core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: text}}, Details: details}
218		attachMutationDiagnostics(ctx, cwd, modifiedPaths, t.Manager, &result)
219		text = result.Content[0].(provider.TextBlock).Text
220	}
221	return text, details, applyErr
222}
223
224func (t *LSPTool) executeRawRequest(ctx context.Context, cwd, path string, args lspArgs) (string, any, error) {
225	if strings.TrimSpace(args.Method) == "" {
226		return "", nil, fmt.Errorf("method is required for request")
227	}
228	var params any = map[string]any{}
229	if len(args.Params) > 0 && string(args.Params) != "null" {
230		if err := json.Unmarshal(args.Params, &params); err != nil {
231			return "", nil, fmt.Errorf("params must be JSON: %w", err)
232		}
233	}
234	var responses []lsp.Response
235	if path != "" {
236		_ = t.Manager.Open(ctx, cwd, path)
237	}
238	if args.Server != "" {
239		result, err := t.Manager.Request(ctx, cwd, args.Server, args.Method, params)
240		responses = []lsp.Response{{Server: args.Server, Result: result, Error: err}}
241	} else {
242		responses = t.Manager.RequestAll(ctx, cwd, path, args.Method, params)
243	}
244	return formatRawResponses(responses), map[string]any{"method": args.Method, "responses": responses}, firstResponseError(responses)
245}
246
247func formatResponses(cwd string, responses []lsp.Response, action string, apply bool, manager *lsp.Manager) (string, int, []string, error) {
248	var b strings.Builder
249	applied := 0
250	var modifiedPaths []string
251	var firstErr error
252	pendingEdits := make([]lsp.WorkspaceEdit, 0)
253	for _, response := range responses {
254		if response.Error != nil {
255			fmt.Fprintf(&b, "%s: error: %s\n", response.Server, response.Error)
256			if firstErr == nil {
257				firstErr = response.Error
258			}
259			continue
260		}
261		fmt.Fprintf(&b, "%s:\n%s\n", response.Server, prettyJSON(response.Result))
262		if apply && (action == "rename" || action == "code_actions") {
263			pendingEdits = append(pendingEdits, workspaceEdits(response.Result, action)...)
264		}
265	}
266	if len(pendingEdits) > 0 {
267		modifiedPaths = workspaceEditPaths(cwd, pendingEdits)
268		if err := manager.ApplyEdits(cwd, pendingEdits); err != nil {
269			if firstErr == nil {
270				firstErr = err
271			}
272			fmt.Fprintf(&b, "apply: error: %s\n", err)
273		} else {
274			applied = len(pendingEdits)
275		}
276	}
277	if b.Len() == 0 {
278		return "No results.", applied, modifiedPaths, firstErr
279	}
280	return b.String(), applied, modifiedPaths, firstErr
281}
282
283func workspaceEditPaths(cwd string, edits []lsp.WorkspaceEdit) []string {
284	seen := make(map[string]bool)
285	var paths []string
286	add := func(uri string) {
287		path, err := lsp.URIToPath(uri)
288		if err != nil {
289			return
290		}
291		if !filepath.IsAbs(path) {
292			path = filepath.Join(cwd, path)
293		}
294		path = filepath.Clean(path)
295		if !seen[path] {
296			seen[path] = true
297			paths = append(paths, path)
298		}
299	}
300	for _, edit := range edits {
301		for uri := range edit.Changes {
302			add(uri)
303		}
304		for _, raw := range edit.DocumentChanges {
305			var change struct {
306				TextDocument struct {
307					URI string `json:"uri"`
308				} `json:"textDocument"`
309			}
310			if json.Unmarshal(raw, &change) == nil {
311				add(change.TextDocument.URI)
312			}
313		}
314	}
315	return paths
316}
317
318func workspaceEdits(raw json.RawMessage, action string) []lsp.WorkspaceEdit {
319	if action == "rename" {
320		var edit lsp.WorkspaceEdit
321		if json.Unmarshal(raw, &edit) == nil && (len(edit.Changes) > 0 || len(edit.DocumentChanges) > 0) {
322			return []lsp.WorkspaceEdit{edit}
323		}
324		return nil
325	}
326	var actions []struct {
327		Edit lsp.WorkspaceEdit `json:"edit"`
328	}
329	if json.Unmarshal(raw, &actions) != nil {
330		return nil
331	}
332	out := make([]lsp.WorkspaceEdit, 0, len(actions))
333	for _, action := range actions {
334		if len(action.Edit.Changes) > 0 || len(action.Edit.DocumentChanges) > 0 {
335			out = append(out, action.Edit)
336		}
337	}
338	return out
339}
340
341func formatRawResponses(responses []lsp.Response) string {
342	var b strings.Builder
343	for _, response := range responses {
344		if response.Error != nil {
345			fmt.Fprintf(&b, "%s: error: %s\n", response.Server, response.Error)
346			continue
347		}
348		fmt.Fprintf(&b, "%s:\n%s\n", response.Server, prettyJSON(response.Result))
349	}
350	if b.Len() == 0 {
351		return "No results."
352	}
353	return b.String()
354}
355
356func firstResponseError(responses []lsp.Response) error {
357	for _, response := range responses {
358		if response.Error != nil {
359			return response.Error
360		}
361	}
362	return nil
363}
364func fileURI(path string) string { return lsp.URIForPath(path) }
365func prettyJSON(raw any) string {
366	var data []byte
367	switch value := raw.(type) {
368	case json.RawMessage:
369		data = value
370	default:
371		data, _ = json.Marshal(value)
372	}
373	var value any
374	if json.Unmarshal(data, &value) == nil {
375		if formatted, err := json.MarshalIndent(value, "", "  "); err == nil {
376			return string(formatted)
377		}
378	}
379	return strings.TrimSpace(string(data))
380}
381func hasGlobMeta(value string) bool {
382	return strings.ContainsAny(value, "*?[")
383}
384
385func lspToolTimeout(milliseconds int) time.Duration {
386	if milliseconds <= 0 {
387		return defaultLSPTimeout
388	}
389	if milliseconds > maxLSPTimeoutMillis {
390		return maxLSPTimeout
391	}
392	return time.Duration(milliseconds) * time.Millisecond
393}
394
395func boundedMax(value int) int {
396	if value <= 0 || value > lsp.DefaultDiagnosticCap {
397		return lsp.DefaultDiagnosticCap
398	}
399	return value
400}
401func boundLSPText(value string) string {
402	if len(value) <= maxLSPToolOutput {
403		return value
404	}
405	suffix := fmt.Sprintf("\n... [truncated at %d bytes]", maxLSPToolOutput)
406	available := maxLSPToolOutput - len(suffix)
407	cut := value[:available]
408	for len(cut) > 0 && !utf8.ValidString(cut) {
409		cut = cut[:len(cut)-1]
410	}
411	return cut + suffix
412}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close