diagnostics.go 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. package tools
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "maps"
  7. "sort"
  8. "strings"
  9. "time"
  10. "github.com/opencode-ai/opencode/internal/lsp"
  11. "github.com/opencode-ai/opencode/internal/lsp/protocol"
  12. )
  13. type DiagnosticsParams struct {
  14. FilePath string `json:"file_path"`
  15. }
  16. type diagnosticsTool struct {
  17. lspClients map[string]*lsp.Client
  18. }
  19. const (
  20. DiagnosticsToolName = "diagnostics"
  21. diagnosticsDescription = `Get diagnostics for a file and/or project.
  22. WHEN TO USE THIS TOOL:
  23. - Use when you need to check for errors or warnings in your code
  24. - Helpful for debugging and ensuring code quality
  25. - Good for getting a quick overview of issues in a file or project
  26. HOW TO USE:
  27. - Provide a path to a file to get diagnostics for that file
  28. - Leave the path empty to get diagnostics for the entire project
  29. - Results are displayed in a structured format with severity levels
  30. FEATURES:
  31. - Displays errors, warnings, and hints
  32. - Groups diagnostics by severity
  33. - Provides detailed information about each diagnostic
  34. LIMITATIONS:
  35. - Results are limited to the diagnostics provided by the LSP clients
  36. - May not cover all possible issues in the code
  37. - Does not provide suggestions for fixing issues
  38. TIPS:
  39. - Use in conjunction with other tools for a comprehensive code review
  40. - Combine with the LSP client for real-time diagnostics
  41. `
  42. )
  43. func NewDiagnosticsTool(lspClients map[string]*lsp.Client) BaseTool {
  44. return &diagnosticsTool{
  45. lspClients,
  46. }
  47. }
  48. func (b *diagnosticsTool) Info() ToolInfo {
  49. return ToolInfo{
  50. Name: DiagnosticsToolName,
  51. Description: diagnosticsDescription,
  52. Parameters: map[string]any{
  53. "file_path": map[string]any{
  54. "type": "string",
  55. "description": "The path to the file to get diagnostics for (leave w empty for project diagnostics)",
  56. },
  57. },
  58. Required: []string{},
  59. }
  60. }
  61. func (b *diagnosticsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
  62. var params DiagnosticsParams
  63. if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
  64. return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
  65. }
  66. lsps := b.lspClients
  67. if len(lsps) == 0 {
  68. return NewTextErrorResponse("no LSP clients available"), nil
  69. }
  70. if params.FilePath != "" {
  71. notifyLspOpenFile(ctx, params.FilePath, lsps)
  72. waitForLspDiagnostics(ctx, params.FilePath, lsps)
  73. }
  74. output := getDiagnostics(params.FilePath, lsps)
  75. return NewTextResponse(output), nil
  76. }
  77. func notifyLspOpenFile(ctx context.Context, filePath string, lsps map[string]*lsp.Client) {
  78. for _, client := range lsps {
  79. err := client.OpenFile(ctx, filePath)
  80. if err != nil {
  81. continue
  82. }
  83. }
  84. }
  85. func waitForLspDiagnostics(ctx context.Context, filePath string, lsps map[string]*lsp.Client) {
  86. if len(lsps) == 0 {
  87. return
  88. }
  89. diagChan := make(chan struct{}, 1)
  90. for _, client := range lsps {
  91. originalDiags := make(map[protocol.DocumentUri][]protocol.Diagnostic)
  92. maps.Copy(originalDiags, client.GetDiagnostics())
  93. handler := func(params json.RawMessage) {
  94. lsp.HandleDiagnostics(client, params)
  95. var diagParams protocol.PublishDiagnosticsParams
  96. if err := json.Unmarshal(params, &diagParams); err != nil {
  97. return
  98. }
  99. if diagParams.URI.Path() == filePath || hasDiagnosticsChanged(client.GetDiagnostics(), originalDiags) {
  100. select {
  101. case diagChan <- struct{}{}:
  102. default:
  103. }
  104. }
  105. }
  106. client.RegisterNotificationHandler("textDocument/publishDiagnostics", handler)
  107. if client.IsFileOpen(filePath) {
  108. err := client.NotifyChange(ctx, filePath)
  109. if err != nil {
  110. continue
  111. }
  112. } else {
  113. err := client.OpenFile(ctx, filePath)
  114. if err != nil {
  115. continue
  116. }
  117. }
  118. }
  119. select {
  120. case <-diagChan:
  121. case <-time.After(5 * time.Second):
  122. case <-ctx.Done():
  123. }
  124. }
  125. func hasDiagnosticsChanged(current, original map[protocol.DocumentUri][]protocol.Diagnostic) bool {
  126. for uri, diags := range current {
  127. origDiags, exists := original[uri]
  128. if !exists || len(diags) != len(origDiags) {
  129. return true
  130. }
  131. }
  132. return false
  133. }
  134. func getDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
  135. fileDiagnostics := []string{}
  136. projectDiagnostics := []string{}
  137. formatDiagnostic := func(pth string, diagnostic protocol.Diagnostic, source string) string {
  138. severity := "Info"
  139. switch diagnostic.Severity {
  140. case protocol.SeverityError:
  141. severity = "Error"
  142. case protocol.SeverityWarning:
  143. severity = "Warn"
  144. case protocol.SeverityHint:
  145. severity = "Hint"
  146. }
  147. location := fmt.Sprintf("%s:%d:%d", pth, diagnostic.Range.Start.Line+1, diagnostic.Range.Start.Character+1)
  148. sourceInfo := ""
  149. if diagnostic.Source != "" {
  150. sourceInfo = diagnostic.Source
  151. } else if source != "" {
  152. sourceInfo = source
  153. }
  154. codeInfo := ""
  155. if diagnostic.Code != nil {
  156. codeInfo = fmt.Sprintf("[%v]", diagnostic.Code)
  157. }
  158. tagsInfo := ""
  159. if len(diagnostic.Tags) > 0 {
  160. tags := []string{}
  161. for _, tag := range diagnostic.Tags {
  162. switch tag {
  163. case protocol.Unnecessary:
  164. tags = append(tags, "unnecessary")
  165. case protocol.Deprecated:
  166. tags = append(tags, "deprecated")
  167. }
  168. }
  169. if len(tags) > 0 {
  170. tagsInfo = fmt.Sprintf(" (%s)", strings.Join(tags, ", "))
  171. }
  172. }
  173. return fmt.Sprintf("%s: %s [%s]%s%s %s",
  174. severity,
  175. location,
  176. sourceInfo,
  177. codeInfo,
  178. tagsInfo,
  179. diagnostic.Message)
  180. }
  181. for lspName, client := range lsps {
  182. diagnostics := client.GetDiagnostics()
  183. if len(diagnostics) > 0 {
  184. for location, diags := range diagnostics {
  185. isCurrentFile := location.Path() == filePath
  186. for _, diag := range diags {
  187. formattedDiag := formatDiagnostic(location.Path(), diag, lspName)
  188. if isCurrentFile {
  189. fileDiagnostics = append(fileDiagnostics, formattedDiag)
  190. } else {
  191. projectDiagnostics = append(projectDiagnostics, formattedDiag)
  192. }
  193. }
  194. }
  195. }
  196. }
  197. sort.Slice(fileDiagnostics, func(i, j int) bool {
  198. iIsError := strings.HasPrefix(fileDiagnostics[i], "Error")
  199. jIsError := strings.HasPrefix(fileDiagnostics[j], "Error")
  200. if iIsError != jIsError {
  201. return iIsError // Errors come first
  202. }
  203. return fileDiagnostics[i] < fileDiagnostics[j] // Then alphabetically
  204. })
  205. sort.Slice(projectDiagnostics, func(i, j int) bool {
  206. iIsError := strings.HasPrefix(projectDiagnostics[i], "Error")
  207. jIsError := strings.HasPrefix(projectDiagnostics[j], "Error")
  208. if iIsError != jIsError {
  209. return iIsError
  210. }
  211. return projectDiagnostics[i] < projectDiagnostics[j]
  212. })
  213. output := ""
  214. if len(fileDiagnostics) > 0 {
  215. output += "\n<file_diagnostics>\n"
  216. if len(fileDiagnostics) > 10 {
  217. output += strings.Join(fileDiagnostics[:10], "\n")
  218. output += fmt.Sprintf("\n... and %d more diagnostics", len(fileDiagnostics)-10)
  219. } else {
  220. output += strings.Join(fileDiagnostics, "\n")
  221. }
  222. output += "\n</file_diagnostics>\n"
  223. }
  224. if len(projectDiagnostics) > 0 {
  225. output += "\n<project_diagnostics>\n"
  226. if len(projectDiagnostics) > 10 {
  227. output += strings.Join(projectDiagnostics[:10], "\n")
  228. output += fmt.Sprintf("\n... and %d more diagnostics", len(projectDiagnostics)-10)
  229. } else {
  230. output += strings.Join(projectDiagnostics, "\n")
  231. }
  232. output += "\n</project_diagnostics>\n"
  233. }
  234. if len(fileDiagnostics) > 0 || len(projectDiagnostics) > 0 {
  235. fileErrors := countSeverity(fileDiagnostics, "Error")
  236. fileWarnings := countSeverity(fileDiagnostics, "Warn")
  237. projectErrors := countSeverity(projectDiagnostics, "Error")
  238. projectWarnings := countSeverity(projectDiagnostics, "Warn")
  239. output += "\n<diagnostic_summary>\n"
  240. output += fmt.Sprintf("Current file: %d errors, %d warnings\n", fileErrors, fileWarnings)
  241. output += fmt.Sprintf("Project: %d errors, %d warnings\n", projectErrors, projectWarnings)
  242. output += "</diagnostic_summary>\n"
  243. }
  244. return output
  245. }
  246. func countSeverity(diagnostics []string, severity string) int {
  247. count := 0
  248. for _, diag := range diagnostics {
  249. if strings.HasPrefix(diag, severity) {
  250. count++
  251. }
  252. }
  253. return count
  254. }