summaryrefslogtreecommitdiffhomepage
path: root/internal/llm/tools/lsp_code_action.go
blob: 772e4007812a8cb7660188c30b2b0e45ec3406d7 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
package tools

import (
	"context"
	"encoding/json"
	"fmt"
	"strings"

	"github.com/sst/opencode/internal/lsp"
	"github.com/sst/opencode/internal/lsp/protocol"
	"github.com/sst/opencode/internal/lsp/util"
)

type CodeActionParams struct {
	FilePath   string `json:"file_path"`
	Line       int    `json:"line"`
	Column     int    `json:"column"`
	EndLine    int    `json:"end_line,omitempty"`
	EndColumn  int    `json:"end_column,omitempty"`
	ActionID   int    `json:"action_id,omitempty"`
	LspName    string `json:"lsp_name,omitempty"`
}

type codeActionTool struct {
	lspClients map[string]*lsp.Client
}

const (
	CodeActionToolName    = "codeAction"
	codeActionDescription = `Get available code actions at a specific position or range in a file.
WHEN TO USE THIS TOOL:
- Use when you need to find available fixes or refactorings for code issues
- Helpful for resolving errors, warnings, or improving code quality
- Great for discovering automated code transformations

HOW TO USE:
- Provide the path to the file containing the code
- Specify the line number (1-based) where the action should be applied
- Specify the column number (1-based) where the action should be applied
- Optionally specify end_line and end_column to define a range
- Results show available code actions with their titles and kinds

TO EXECUTE A CODE ACTION:
- After getting the list of available actions, call the tool again with the same parameters
- Add action_id parameter with the number of the action you want to execute (e.g., 1 for the first action)
- Add lsp_name parameter with the name of the LSP server that provided the action

FEATURES:
- Finds quick fixes for errors and warnings
- Discovers available refactorings
- Shows code organization actions
- Returns detailed information about each action
- Can execute selected code actions

LIMITATIONS:
- Requires a functioning LSP server for the file type
- May not work for all code issues depending on LSP capabilities
- Results depend on the accuracy of the LSP server

TIPS:
- Use in conjunction with Diagnostics tool to find issues that can be fixed
- First call without action_id to see available actions, then call again with action_id to execute
`
)

func NewCodeActionTool(lspClients map[string]*lsp.Client) BaseTool {
	return &codeActionTool{
		lspClients,
	}
}

func (b *codeActionTool) Info() ToolInfo {
	return ToolInfo{
		Name:        CodeActionToolName,
		Description: codeActionDescription,
		Parameters: map[string]any{
			"file_path": map[string]any{
				"type":        "string",
				"description": "The path to the file containing the code",
			},
			"line": map[string]any{
				"type":        "integer",
				"description": "The line number (1-based) where the action should be applied",
			},
			"column": map[string]any{
				"type":        "integer",
				"description": "The column number (1-based) where the action should be applied",
			},
			"end_line": map[string]any{
				"type":        "integer",
				"description": "The ending line number (1-based) for a range (optional)",
			},
			"end_column": map[string]any{
				"type":        "integer",
				"description": "The ending column number (1-based) for a range (optional)",
			},
			"action_id": map[string]any{
				"type":        "integer",
				"description": "The ID of the code action to execute (optional)",
			},
			"lsp_name": map[string]any{
				"type":        "string",
				"description": "The name of the LSP server that provided the action (optional)",
			},
		},
		Required: []string{"file_path", "line", "column"},
	}
}

func (b *codeActionTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
	var params CodeActionParams
	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
	}

	lsps := b.lspClients

	if len(lsps) == 0 {
		return NewTextResponse("\nLSP clients are still initializing. Code actions will be available once they're ready.\n"), nil
	}

	// Ensure file is open in LSP
	notifyLspOpenFile(ctx, params.FilePath, lsps)

	// Convert 1-based line/column to 0-based for LSP protocol
	line := max(0, params.Line-1)
	column := max(0, params.Column-1)
	
	// Handle optional end line/column
	endLine := line
	endColumn := column
	if params.EndLine > 0 {
		endLine = max(0, params.EndLine-1)
	}
	if params.EndColumn > 0 {
		endColumn = max(0, params.EndColumn-1)
	}

	// Check if we're executing a specific action
	if params.ActionID > 0 && params.LspName != "" {
		return executeCodeAction(ctx, params.FilePath, line, column, endLine, endColumn, params.ActionID, params.LspName, lsps)
	}

	// Otherwise, just list available actions
	output := getCodeActions(ctx, params.FilePath, line, column, endLine, endColumn, lsps)
	return NewTextResponse(output), nil
}

func getCodeActions(ctx context.Context, filePath string, line, column, endLine, endColumn int, lsps map[string]*lsp.Client) string {
	var results []string

	for lspName, client := range lsps {
		// Create code action params
		uri := fmt.Sprintf("file://%s", filePath)
		codeActionParams := protocol.CodeActionParams{
			TextDocument: protocol.TextDocumentIdentifier{
				URI: protocol.DocumentUri(uri),
			},
			Range: protocol.Range{
				Start: protocol.Position{
					Line:      uint32(line),
					Character: uint32(column),
				},
				End: protocol.Position{
					Line:      uint32(endLine),
					Character: uint32(endColumn),
				},
			},
			Context: protocol.CodeActionContext{
				// Request all kinds of code actions
				Only: []protocol.CodeActionKind{
					protocol.QuickFix,
					protocol.Refactor,
					protocol.RefactorExtract,
					protocol.RefactorInline,
					protocol.RefactorRewrite,
					protocol.Source,
					protocol.SourceOrganizeImports,
					protocol.SourceFixAll,
				},
			},
		}

		// Get code actions
		codeActions, err := client.CodeAction(ctx, codeActionParams)
		if err != nil {
			results = append(results, fmt.Sprintf("Error from %s: %s", lspName, err))
			continue
		}

		if len(codeActions) == 0 {
			results = append(results, fmt.Sprintf("No code actions found by %s", lspName))
			continue
		}

		// Format the code actions
		results = append(results, fmt.Sprintf("Code actions found by %s:", lspName))
		for i, action := range codeActions {
			actionInfo := formatCodeAction(action, i+1)
			results = append(results, actionInfo)
		}
	}

	if len(results) == 0 {
		return "No code actions found at the specified position."
	}

	return strings.Join(results, "\n")
}

func formatCodeAction(action protocol.Or_Result_textDocument_codeAction_Item0_Elem, index int) string {
	switch v := action.Value.(type) {
	case protocol.CodeAction:
		kind := "Unknown"
		if v.Kind != "" {
			kind = string(v.Kind)
		}
		
		var details []string
		
		// Add edit information if available
		if v.Edit != nil {
			numChanges := 0
			if v.Edit.Changes != nil {
				numChanges = len(v.Edit.Changes)
			}
			if v.Edit.DocumentChanges != nil {
				numChanges = len(v.Edit.DocumentChanges)
			}
			details = append(details, fmt.Sprintf("Edits: %d changes", numChanges))
		}
		
		// Add command information if available
		if v.Command != nil {
			details = append(details, fmt.Sprintf("Command: %s", v.Command.Title))
		}
		
		// Add diagnostics information if available
		if v.Diagnostics != nil && len(v.Diagnostics) > 0 {
			details = append(details, fmt.Sprintf("Fixes: %d diagnostics", len(v.Diagnostics)))
		}
		
		detailsStr := ""
		if len(details) > 0 {
			detailsStr = " (" + strings.Join(details, ", ") + ")"
		}
		
		return fmt.Sprintf("  %d. %s [%s]%s", index, v.Title, kind, detailsStr)
		
	case protocol.Command:
		return fmt.Sprintf("  %d. %s [Command]", index, v.Title)
	}
	
	return fmt.Sprintf("  %d. Unknown code action type", index)
}

func executeCodeAction(ctx context.Context, filePath string, line, column, endLine, endColumn, actionID int, lspName string, lsps map[string]*lsp.Client) (ToolResponse, error) {
	client, ok := lsps[lspName]
	if !ok {
		return NewTextErrorResponse(fmt.Sprintf("LSP server '%s' not found", lspName)), nil
	}

	// Create code action params
	uri := fmt.Sprintf("file://%s", filePath)
	codeActionParams := protocol.CodeActionParams{
		TextDocument: protocol.TextDocumentIdentifier{
			URI: protocol.DocumentUri(uri),
		},
		Range: protocol.Range{
			Start: protocol.Position{
				Line:      uint32(line),
				Character: uint32(column),
			},
			End: protocol.Position{
				Line:      uint32(endLine),
				Character: uint32(endColumn),
			},
		},
		Context: protocol.CodeActionContext{
			// Request all kinds of code actions
			Only: []protocol.CodeActionKind{
				protocol.QuickFix,
				protocol.Refactor,
				protocol.RefactorExtract,
				protocol.RefactorInline,
				protocol.RefactorRewrite,
				protocol.Source,
				protocol.SourceOrganizeImports,
				protocol.SourceFixAll,
			},
		},
	}

	// Get code actions
	codeActions, err := client.CodeAction(ctx, codeActionParams)
	if err != nil {
		return NewTextErrorResponse(fmt.Sprintf("Error getting code actions: %s", err)), nil
	}

	if len(codeActions) == 0 {
		return NewTextErrorResponse("No code actions found"), nil
	}

	// Check if the requested action ID is valid
	if actionID < 1 || actionID > len(codeActions) {
		return NewTextErrorResponse(fmt.Sprintf("Invalid action ID: %d. Available actions: 1-%d", actionID, len(codeActions))), nil
	}

	// Get the selected action (adjust for 0-based index)
	selectedAction := codeActions[actionID-1]

	// Execute the action based on its type
	switch v := selectedAction.Value.(type) {
	case protocol.CodeAction:
		// Apply workspace edit if available
		if v.Edit != nil {
			err := util.ApplyWorkspaceEdit(*v.Edit)
			if err != nil {
				return NewTextErrorResponse(fmt.Sprintf("Error applying edit: %s", err)), nil
			}
		}

		// Execute command if available
		if v.Command != nil {
			_, err := client.ExecuteCommand(ctx, protocol.ExecuteCommandParams{
				Command:   v.Command.Command,
				Arguments: v.Command.Arguments,
			})
			if err != nil {
				return NewTextErrorResponse(fmt.Sprintf("Error executing command: %s", err)), nil
			}
		}

		return NewTextResponse(fmt.Sprintf("Successfully executed code action: %s", v.Title)), nil

	case protocol.Command:
		// Execute the command
		_, err := client.ExecuteCommand(ctx, protocol.ExecuteCommandParams{
			Command:   v.Command,
			Arguments: v.Arguments,
		})
		if err != nil {
			return NewTextErrorResponse(fmt.Sprintf("Error executing command: %s", err)), nil
		}

		return NewTextResponse(fmt.Sprintf("Successfully executed command: %s", v.Title)), nil
	}

	return NewTextErrorResponse("Unknown code action type"), nil
}