lsp_diagnostics.go 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. package tools
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "maps"
  7. "sort"
  8. "strings"
  9. "time"
  10. "github.com/sst/opencode/internal/lsp"
  11. "github.com/sst/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 a more helpful message when LSP clients aren't ready yet
  69. return NewTextResponse("\n<diagnostic_summary>\nLSP clients are still initializing. Diagnostics will be available once they're ready.\n</diagnostic_summary>\n"), nil
  70. }
  71. if params.FilePath != "" {
  72. notifyLspOpenFile(ctx, params.FilePath, lsps)
  73. waitForLspDiagnostics(ctx, params.FilePath, lsps)
  74. }
  75. output := getDiagnostics(params.FilePath, lsps)
  76. return NewTextResponse(output), nil
  77. }
  78. func notifyLspOpenFile(ctx context.Context, filePath string, lsps map[string]*lsp.Client) {
  79. for _, client := range lsps {
  80. err := client.OpenFile(ctx, filePath)
  81. if err != nil {
  82. continue
  83. }
  84. }
  85. }
  86. func waitForLspDiagnostics(ctx context.Context, filePath string, lsps map[string]*lsp.Client) {
  87. if len(lsps) == 0 {
  88. return
  89. }
  90. diagChan := make(chan struct{}, 1)
  91. for _, client := range lsps {
  92. originalDiags := make(map[protocol.DocumentUri][]protocol.Diagnostic)
  93. maps.Copy(originalDiags, client.GetDiagnostics())
  94. handler := func(params json.RawMessage) {
  95. lsp.HandleDiagnostics(client, params)
  96. var diagParams protocol.PublishDiagnosticsParams
  97. if err := json.Unmarshal(params, &diagParams); err != nil {
  98. return
  99. }
  100. if diagParams.URI.Path() == filePath || hasDiagnosticsChanged(client.GetDiagnostics(), originalDiags) {
  101. select {
  102. case diagChan <- struct{}{}:
  103. default:
  104. }
  105. }
  106. }
  107. client.RegisterNotificationHandler("textDocument/publishDiagnostics", handler)
  108. if client.IsFileOpen(filePath) {
  109. err := client.NotifyChange(ctx, filePath)
  110. if err != nil {
  111. continue
  112. }
  113. } else {
  114. err := client.OpenFile(ctx, filePath)
  115. if err != nil {
  116. continue
  117. }
  118. }
  119. }
  120. select {
  121. case <-diagChan:
  122. case <-time.After(5 * time.Second):
  123. case <-ctx.Done():
  124. }
  125. }
  126. func hasDiagnosticsChanged(current, original map[protocol.DocumentUri][]protocol.Diagnostic) bool {
  127. for uri, diags := range current {
  128. origDiags, exists := original[uri]
  129. if !exists || len(diags) != len(origDiags) {
  130. return true
  131. }
  132. }
  133. return false
  134. }
  135. func getDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
  136. fileDiagnostics := []string{}
  137. projectDiagnostics := []string{}
  138. formatDiagnostic := func(pth string, diagnostic protocol.Diagnostic, source string) string {
  139. severity := "Info"
  140. switch diagnostic.Severity {
  141. case protocol.SeverityError:
  142. severity = "Error"
  143. case protocol.SeverityWarning:
  144. severity = "Warn"
  145. case protocol.SeverityHint:
  146. severity = "Hint"
  147. }
  148. location := fmt.Sprintf("%s:%d:%d", pth, diagnostic.Range.Start.Line+1, diagnostic.Range.Start.Character+1)
  149. sourceInfo := ""
  150. if diagnostic.Source != "" {
  151. sourceInfo = diagnostic.Source
  152. } else if source != "" {
  153. sourceInfo = source
  154. }
  155. codeInfo := ""
  156. if diagnostic.Code != nil {
  157. codeInfo = fmt.Sprintf("[%v]", diagnostic.Code)
  158. }
  159. tagsInfo := ""
  160. if len(diagnostic.Tags) > 0 {
  161. tags := []string{}
  162. for _, tag := range diagnostic.Tags {
  163. switch tag {
  164. case protocol.Unnecessary:
  165. tags = append(tags, "unnecessary")
  166. case protocol.Deprecated:
  167. tags = append(tags, "deprecated")
  168. }
  169. }
  170. if len(tags) > 0 {
  171. tagsInfo = fmt.Sprintf(" (%s)", strings.Join(tags, ", "))
  172. }
  173. }
  174. return fmt.Sprintf("%s: %s [%s]%s%s %s",
  175. severity,
  176. location,
  177. sourceInfo,
  178. codeInfo,
  179. tagsInfo,
  180. diagnostic.Message)
  181. }
  182. for lspName, client := range lsps {
  183. diagnostics := client.GetDiagnostics()
  184. if len(diagnostics) > 0 {
  185. for location, diags := range diagnostics {
  186. isCurrentFile := location.Path() == filePath
  187. for _, diag := range diags {
  188. formattedDiag := formatDiagnostic(location.Path(), diag, lspName)
  189. if isCurrentFile {
  190. fileDiagnostics = append(fileDiagnostics, formattedDiag)
  191. } else {
  192. projectDiagnostics = append(projectDiagnostics, formattedDiag)
  193. }
  194. }
  195. }
  196. }
  197. }
  198. sort.Slice(fileDiagnostics, func(i, j int) bool {
  199. iIsError := strings.HasPrefix(fileDiagnostics[i], "Error")
  200. jIsError := strings.HasPrefix(fileDiagnostics[j], "Error")
  201. if iIsError != jIsError {
  202. return iIsError // Errors come first
  203. }
  204. return fileDiagnostics[i] < fileDiagnostics[j] // Then alphabetically
  205. })
  206. sort.Slice(projectDiagnostics, func(i, j int) bool {
  207. iIsError := strings.HasPrefix(projectDiagnostics[i], "Error")
  208. jIsError := strings.HasPrefix(projectDiagnostics[j], "Error")
  209. if iIsError != jIsError {
  210. return iIsError
  211. }
  212. return projectDiagnostics[i] < projectDiagnostics[j]
  213. })
  214. output := ""
  215. if len(fileDiagnostics) > 0 {
  216. output += "\n<file_diagnostics>\n"
  217. if len(fileDiagnostics) > 10 {
  218. output += strings.Join(fileDiagnostics[:10], "\n")
  219. output += fmt.Sprintf("\n... and %d more diagnostics", len(fileDiagnostics)-10)
  220. } else {
  221. output += strings.Join(fileDiagnostics, "\n")
  222. }
  223. output += "\n</file_diagnostics>\n"
  224. }
  225. if len(projectDiagnostics) > 0 {
  226. output += "\n<project_diagnostics>\n"
  227. if len(projectDiagnostics) > 10 {
  228. output += strings.Join(projectDiagnostics[:10], "\n")
  229. output += fmt.Sprintf("\n... and %d more diagnostics", len(projectDiagnostics)-10)
  230. } else {
  231. output += strings.Join(projectDiagnostics, "\n")
  232. }
  233. output += "\n</project_diagnostics>\n"
  234. }
  235. if len(fileDiagnostics) > 0 || len(projectDiagnostics) > 0 {
  236. fileErrors := countSeverity(fileDiagnostics, "Error")
  237. fileWarnings := countSeverity(fileDiagnostics, "Warn")
  238. projectErrors := countSeverity(projectDiagnostics, "Error")
  239. projectWarnings := countSeverity(projectDiagnostics, "Warn")
  240. output += "\n<diagnostic_summary>\n"
  241. output += fmt.Sprintf("Current file: %d errors, %d warnings\n", fileErrors, fileWarnings)
  242. output += fmt.Sprintf("Project: %d errors, %d warnings\n", projectErrors, projectWarnings)
  243. output += "</diagnostic_summary>\n"
  244. }
  245. return output
  246. }
  247. func countSeverity(diagnostics []string, severity string) int {
  248. count := 0
  249. for _, diag := range diagnostics {
  250. if strings.HasPrefix(diag, severity) {
  251. count++
  252. }
  253. }
  254. return count
  255. }