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