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