glob.go 3.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. package tools
  2. import (
  3. "bytes"
  4. "context"
  5. _ "embed"
  6. "fmt"
  7. "log/slog"
  8. "os/exec"
  9. "path/filepath"
  10. "sort"
  11. "strings"
  12. "charm.land/fantasy"
  13. "github.com/charmbracelet/crush/internal/fsext"
  14. )
  15. const GlobToolName = "glob"
  16. //go:embed glob.md
  17. var globDescription []byte
  18. type GlobParams struct {
  19. Pattern string `json:"pattern" description:"The glob pattern to match files against"`
  20. Path string `json:"path,omitempty" description:"The directory to search in. Defaults to the current working directory."`
  21. }
  22. type GlobResponseMetadata struct {
  23. NumberOfFiles int `json:"number_of_files"`
  24. Truncated bool `json:"truncated"`
  25. }
  26. func NewGlobTool(workingDir string) fantasy.AgentTool {
  27. return fantasy.NewAgentTool(
  28. GlobToolName,
  29. string(globDescription),
  30. func(ctx context.Context, params GlobParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
  31. if params.Pattern == "" {
  32. return fantasy.NewTextErrorResponse("pattern is required"), nil
  33. }
  34. searchPath := params.Path
  35. if searchPath == "" {
  36. searchPath = workingDir
  37. }
  38. files, truncated, err := globFiles(ctx, params.Pattern, searchPath, 100)
  39. if err != nil {
  40. return fantasy.ToolResponse{}, fmt.Errorf("error finding files: %w", err)
  41. }
  42. var output string
  43. if len(files) == 0 {
  44. output = "No files found"
  45. } else {
  46. normalizeFilePaths(files)
  47. output = strings.Join(files, "\n")
  48. if truncated {
  49. output += "\n\n(Results are truncated. Consider using a more specific path or pattern.)"
  50. }
  51. }
  52. return fantasy.WithResponseMetadata(
  53. fantasy.NewTextResponse(output),
  54. GlobResponseMetadata{
  55. NumberOfFiles: len(files),
  56. Truncated: truncated,
  57. },
  58. ), nil
  59. })
  60. }
  61. func globFiles(ctx context.Context, pattern, searchPath string, limit int) ([]string, bool, error) {
  62. cmdRg := getRgCmd(ctx, pattern)
  63. if cmdRg != nil {
  64. cmdRg.Dir = searchPath
  65. matches, err := runRipgrep(cmdRg, searchPath, limit)
  66. if err == nil {
  67. return matches, len(matches) >= limit && limit > 0, nil
  68. }
  69. slog.Warn("Ripgrep execution failed, falling back to doublestar", "error", err)
  70. }
  71. return fsext.GlobWithDoubleStar(pattern, searchPath, limit)
  72. }
  73. func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) {
  74. out, err := cmd.CombinedOutput()
  75. if err != nil {
  76. if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
  77. return nil, nil
  78. }
  79. return nil, fmt.Errorf("ripgrep: %w\n%s", err, out)
  80. }
  81. var matches []string
  82. for p := range bytes.SplitSeq(out, []byte{0}) {
  83. if len(p) == 0 {
  84. continue
  85. }
  86. absPath := string(p)
  87. if !filepath.IsAbs(absPath) {
  88. absPath = filepath.Join(searchRoot, absPath)
  89. }
  90. if fsext.SkipHidden(absPath) {
  91. continue
  92. }
  93. matches = append(matches, absPath)
  94. }
  95. sort.SliceStable(matches, func(i, j int) bool {
  96. return len(matches[i]) < len(matches[j])
  97. })
  98. if limit > 0 && len(matches) > limit {
  99. matches = matches[:limit]
  100. }
  101. return matches, nil
  102. }
  103. func normalizeFilePaths(paths []string) {
  104. for i, p := range paths {
  105. paths[i] = filepath.ToSlash(p)
  106. }
  107. }