v0.16.0 042538b

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

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