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