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, ¶ms); 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}