diff options
| author | adamdottv <[email protected]> | 2025-05-14 14:40:45 -0500 |
|---|---|---|
| committer | adamdottv <[email protected]> | 2025-05-14 14:40:45 -0500 |
| commit | 5ea989fb74ab1c286fbdeadc9d5a2860361d4f95 (patch) | |
| tree | d4de81020fb74ae740112b49cc07d63647f37bf5 /internal/llm/tools/lsp_diagnostics.go | |
| parent | 45c778b90d2e13cbdb54b07372fede413e430055 (diff) | |
| download | opencode-5ea989fb74ab1c286fbdeadc9d5a2860361d4f95.tar.gz opencode-5ea989fb74ab1c286fbdeadc9d5a2860361d4f95.zip | |
feat: docSymbols and workspaceSymbols tools
Diffstat (limited to 'internal/llm/tools/lsp_diagnostics.go')
| -rw-r--r-- | internal/llm/tools/lsp_diagnostics.go | 296 |
1 files changed, 296 insertions, 0 deletions
diff --git a/internal/llm/tools/lsp_diagnostics.go b/internal/llm/tools/lsp_diagnostics.go new file mode 100644 index 000000000..a1ed33b6a --- /dev/null +++ b/internal/llm/tools/lsp_diagnostics.go @@ -0,0 +1,296 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "sort" + "strings" + "time" + + "github.com/sst/opencode/internal/lsp" + "github.com/sst/opencode/internal/lsp/protocol" +) + +type DiagnosticsParams struct { + FilePath string `json:"file_path"` +} +type diagnosticsTool struct { + lspClients map[string]*lsp.Client +} + +const ( + DiagnosticsToolName = "diagnostics" + diagnosticsDescription = `Get diagnostics for a file and/or project. +WHEN TO USE THIS TOOL: +- Use when you need to check for errors or warnings in your code +- Helpful for debugging and ensuring code quality +- Good for getting a quick overview of issues in a file or project +HOW TO USE: +- Provide a path to a file to get diagnostics for that file +- Leave the path empty to get diagnostics for the entire project +- Results are displayed in a structured format with severity levels +FEATURES: +- Displays errors, warnings, and hints +- Groups diagnostics by severity +- Provides detailed information about each diagnostic +LIMITATIONS: +- Results are limited to the diagnostics provided by the LSP clients +- May not cover all possible issues in the code +- Does not provide suggestions for fixing issues +TIPS: +- Use in conjunction with other tools for a comprehensive code review +- Combine with the LSP client for real-time diagnostics +` +) + +func NewDiagnosticsTool(lspClients map[string]*lsp.Client) BaseTool { + return &diagnosticsTool{ + lspClients, + } +} + +func (b *diagnosticsTool) Info() ToolInfo { + return ToolInfo{ + Name: DiagnosticsToolName, + Description: diagnosticsDescription, + Parameters: map[string]any{ + "file_path": map[string]any{ + "type": "string", + "description": "The path to the file to get diagnostics for (leave w empty for project diagnostics)", + }, + }, + Required: []string{}, + } +} + +func (b *diagnosticsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) { + var params DiagnosticsParams + if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil { + return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil + } + + lsps := b.lspClients + + if len(lsps) == 0 { + // Return a more helpful message when LSP clients aren't ready yet + return NewTextResponse("\n<diagnostic_summary>\nLSP clients are still initializing. Diagnostics will be available once they're ready.\n</diagnostic_summary>\n"), nil + } + + if params.FilePath != "" { + notifyLspOpenFile(ctx, params.FilePath, lsps) + waitForLspDiagnostics(ctx, params.FilePath, lsps) + } + + output := getDiagnostics(params.FilePath, lsps) + + return NewTextResponse(output), nil +} + +func notifyLspOpenFile(ctx context.Context, filePath string, lsps map[string]*lsp.Client) { + for _, client := range lsps { + err := client.OpenFile(ctx, filePath) + if err != nil { + continue + } + } +} + +func waitForLspDiagnostics(ctx context.Context, filePath string, lsps map[string]*lsp.Client) { + if len(lsps) == 0 { + return + } + + diagChan := make(chan struct{}, 1) + + for _, client := range lsps { + originalDiags := make(map[protocol.DocumentUri][]protocol.Diagnostic) + maps.Copy(originalDiags, client.GetDiagnostics()) + + handler := func(params json.RawMessage) { + lsp.HandleDiagnostics(client, params) + var diagParams protocol.PublishDiagnosticsParams + if err := json.Unmarshal(params, &diagParams); err != nil { + return + } + + if diagParams.URI.Path() == filePath || hasDiagnosticsChanged(client.GetDiagnostics(), originalDiags) { + select { + case diagChan <- struct{}{}: + default: + } + } + } + + client.RegisterNotificationHandler("textDocument/publishDiagnostics", handler) + + if client.IsFileOpen(filePath) { + err := client.NotifyChange(ctx, filePath) + if err != nil { + continue + } + } else { + err := client.OpenFile(ctx, filePath) + if err != nil { + continue + } + } + } + + select { + case <-diagChan: + case <-time.After(5 * time.Second): + case <-ctx.Done(): + } +} + +func hasDiagnosticsChanged(current, original map[protocol.DocumentUri][]protocol.Diagnostic) bool { + for uri, diags := range current { + origDiags, exists := original[uri] + if !exists || len(diags) != len(origDiags) { + return true + } + } + return false +} + +func getDiagnostics(filePath string, lsps map[string]*lsp.Client) string { + fileDiagnostics := []string{} + projectDiagnostics := []string{} + + formatDiagnostic := func(pth string, diagnostic protocol.Diagnostic, source string) string { + severity := "Info" + switch diagnostic.Severity { + case protocol.SeverityError: + severity = "Error" + case protocol.SeverityWarning: + severity = "Warn" + case protocol.SeverityHint: + severity = "Hint" + } + + location := fmt.Sprintf("%s:%d:%d", pth, diagnostic.Range.Start.Line+1, diagnostic.Range.Start.Character+1) + + sourceInfo := "" + if diagnostic.Source != "" { + sourceInfo = diagnostic.Source + } else if source != "" { + sourceInfo = source + } + + codeInfo := "" + if diagnostic.Code != nil { + codeInfo = fmt.Sprintf("[%v]", diagnostic.Code) + } + + tagsInfo := "" + if len(diagnostic.Tags) > 0 { + tags := []string{} + for _, tag := range diagnostic.Tags { + switch tag { + case protocol.Unnecessary: + tags = append(tags, "unnecessary") + case protocol.Deprecated: + tags = append(tags, "deprecated") + } + } + if len(tags) > 0 { + tagsInfo = fmt.Sprintf(" (%s)", strings.Join(tags, ", ")) + } + } + + return fmt.Sprintf("%s: %s [%s]%s%s %s", + severity, + location, + sourceInfo, + codeInfo, + tagsInfo, + diagnostic.Message) + } + + for lspName, client := range lsps { + diagnostics := client.GetDiagnostics() + if len(diagnostics) > 0 { + for location, diags := range diagnostics { + isCurrentFile := location.Path() == filePath + + for _, diag := range diags { + formattedDiag := formatDiagnostic(location.Path(), diag, lspName) + + if isCurrentFile { + fileDiagnostics = append(fileDiagnostics, formattedDiag) + } else { + projectDiagnostics = append(projectDiagnostics, formattedDiag) + } + } + } + } + } + + sort.Slice(fileDiagnostics, func(i, j int) bool { + iIsError := strings.HasPrefix(fileDiagnostics[i], "Error") + jIsError := strings.HasPrefix(fileDiagnostics[j], "Error") + if iIsError != jIsError { + return iIsError // Errors come first + } + return fileDiagnostics[i] < fileDiagnostics[j] // Then alphabetically + }) + + sort.Slice(projectDiagnostics, func(i, j int) bool { + iIsError := strings.HasPrefix(projectDiagnostics[i], "Error") + jIsError := strings.HasPrefix(projectDiagnostics[j], "Error") + if iIsError != jIsError { + return iIsError + } + return projectDiagnostics[i] < projectDiagnostics[j] + }) + + output := "" + + if len(fileDiagnostics) > 0 { + output += "\n<file_diagnostics>\n" + if len(fileDiagnostics) > 10 { + output += strings.Join(fileDiagnostics[:10], "\n") + output += fmt.Sprintf("\n... and %d more diagnostics", len(fileDiagnostics)-10) + } else { + output += strings.Join(fileDiagnostics, "\n") + } + output += "\n</file_diagnostics>\n" + } + + if len(projectDiagnostics) > 0 { + output += "\n<project_diagnostics>\n" + if len(projectDiagnostics) > 10 { + output += strings.Join(projectDiagnostics[:10], "\n") + output += fmt.Sprintf("\n... and %d more diagnostics", len(projectDiagnostics)-10) + } else { + output += strings.Join(projectDiagnostics, "\n") + } + output += "\n</project_diagnostics>\n" + } + + if len(fileDiagnostics) > 0 || len(projectDiagnostics) > 0 { + fileErrors := countSeverity(fileDiagnostics, "Error") + fileWarnings := countSeverity(fileDiagnostics, "Warn") + projectErrors := countSeverity(projectDiagnostics, "Error") + projectWarnings := countSeverity(projectDiagnostics, "Warn") + + output += "\n<diagnostic_summary>\n" + output += fmt.Sprintf("Current file: %d errors, %d warnings\n", fileErrors, fileWarnings) + output += fmt.Sprintf("Project: %d errors, %d warnings\n", projectErrors, projectWarnings) + output += "</diagnostic_summary>\n" + } + + return output +} + +func countSeverity(diagnostics []string, severity string) int { + count := 0 + for _, diag := range diagnostics { + if strings.HasPrefix(diag, severity) { + count++ + } + } + return count +} |
