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
88func globFiles(ctx context.Context, pattern, searchPath string, limit int) ([]string, bool, error) {
89 cmdRg := getRgCmd(ctx, pattern)
90 if cmdRg != nil {
91 cmdRg.Dir = searchPath
92 matches, err := runRipgrep(cmdRg, searchPath, limit)
93 if err == nil {
94 return matches, len(matches) >= limit && limit > 0, nil
95 }
96 slog.Warn("Ripgrep execution failed, falling back to doublestar", "error", err)
97 }
98
99 return fsext.GlobGitignoreAware(pattern, searchPath, limit)
100}
101
102func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) {
103 out, err := cmd.CombinedOutput()
104 if err != nil {
105 if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
106 return nil, nil
107 }
108 return nil, fmt.Errorf("ripgrep: %w\n%s", err, out)
109 }
110
111 var matches []string
112 for p := range bytes.SplitSeq(out, []byte{0}) {
113 if len(p) == 0 {
114 continue
115 }
116 absPath := filepathext.SmartJoin(searchRoot, string(p))
117 if fsext.SkipHidden(absPath) {
118 continue
119 }
120 matches = append(matches, absPath)
121 }
122
123 sort.SliceStable(matches, func(i, j int) bool {
124 return len(matches[i]) < len(matches[j])
125 })
126
127 if limit > 0 && len(matches) > limit {
128 matches = matches[:limit]
129 }
130 return matches, nil
131}
132
133func normalizeFilePaths(paths []string) {
134 for i, p := range paths {
135 paths[i] = filepath.ToSlash(p)
136 }
137}