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