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