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