返回 DeepSeek-Reasonix
glob.go
根目录 / internal / tool / builtin / glob.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "sort"
11 "strings"
12 "time"
13
14 "github.com/bmatcuk/doublestar/v4"
15
16 "reasonix/internal/fileutil"
17 "reasonix/internal/secrets"
18 "reasonix/internal/tool"
19 )
20
21 func init() { tool.RegisterBuiltin(globTool{}) }
22
23 // globTool matches files by pattern. workDir, when non-empty, is the directory
24 // a relative pattern resolves against (see resolveIn). paths resolves
25 // session-scoped read aliases for external folder refs. forbidRoots lists
26 // directories the tool may not search inside.
27 type globTool struct {
28 workDir string
29 paths *PathResolver
30 forbidRoots []string
31 }
32
33 func (globTool) Name() string { return "glob" }
34
35 func (globTool) Description() string {
36 return "Find files matching a glob pattern (e.g. \"*.go\", \"internal/*/*.go\", \"**/*.test.ts\"). Supports shell metacharacters * ? [] and the recursive ** pattern."
37 }
38
39 func (globTool) Schema() json.RawMessage {
40 return json.RawMessage(`{"type":"object","properties":{"pattern":{"type":"string","description":"Glob pattern (supports ** for recursive matching)"},"timeout_seconds":{"type":"integer","description":"Walk timeout in seconds (default 30, max 300); partial results are returned when it expires"}},"required":["pattern"]}`)
41 }
42
43 func (globTool) ReadOnly() bool { return true }
44
45 // SnipHint keeps a long head and short tail like grep: the first paths matter
46 // most, the tail confirms how many more there were.
47 func (globTool) SnipHint() tool.SnipHint {
48 return tool.SnipHint{Head: 80, Tail: 8, HeadChars: 10000, TailChars: 1000}
49 }
50
51 const (
52 globMaxResults = 1000
53 globDefaultTimeout = 30 * time.Second
54 globMaxTimeout = 300 * time.Second
55 )
56
57 // globTimeout clamps a caller-supplied second count to a sane bound; 0 (omitted)
58 // falls back to the default so a deep tree can't hold a turn open for minutes.
59 func globTimeout(sec int) time.Duration {
60 switch {
61 case sec <= 0:
62 return globDefaultTimeout
63 case time.Duration(sec)*time.Second > globMaxTimeout:
64 return globMaxTimeout
65 default:
66 return time.Duration(sec) * time.Second
67 }
68 }
69
70 func (g globTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
71 var p struct {
72 Pattern string `json:"pattern"`
73 TimeoutSeconds int `json:"timeout_seconds"`
74 }
75 if err := json.Unmarshal(args, &p); err != nil {
76 return "", fmt.Errorf("invalid args: %w", err)
77 }
78 if p.Pattern == "" {
79 return "", fmt.Errorf("pattern is required")
80 }
81 to := globTimeout(p.TimeoutSeconds)
82 ctx, cancel := context.WithTimeout(ctx, to)
83 defer cancel()
84 // Save the original pattern before resolveIn prepends workDir, so the
85 // simple-filename recursive-fallback check below works on the raw input
86 // — not the already-joined absolute path that always contains separators.
87 rawPattern := p.Pattern
88 rp := resolveReadablePath(g.workDir, p.Pattern, g.paths)
89 p.Pattern = rp.Path
90 p.Pattern = filepath.FromSlash(p.Pattern) // models emit "/" (see Description); WalkDir/Match compare OS-native paths
91 displayPattern := rp.DisplayPath
92
93 // If the pattern contains **, use recursive matching via doublestar semantics
94 // while retaining Reasonix's cancellation and read-forbid pruning.
95 if strings.Contains(p.Pattern, "**") {
96 return g.globRecursive(ctx, p.Pattern, displayPattern, rp, to)
97 }
98
99 // For patterns without **, try filepath.Glob first. If no matches are
100 // found and the pattern is a simple filename (no path separator), retry
101 // with a recursive walk (equivalent to "**/<pattern>") so the tool finds
102 // files anywhere in the tree — the common case where the model only knows
103 // a filename but not its exact location. Uses the raw pattern (before
104 // resolveIn) so a workspace root doesn't mask a simple "*.go".
105 matches, err := filepath.Glob(p.Pattern)
106 if err != nil {
107 if rp.External {
108 return "", fmt.Errorf("glob %q: %s", displayPattern, rp.ErrorText(err))
109 }
110 return "", fmt.Errorf("glob %q: %w", displayPattern, err)
111 }
112 matches = filterForbidMatches(matches, g.forbidRoots)
113 if len(matches) == 0 && !strings.ContainsAny(rawPattern, "/\\") {
114 fallback := filepath.Join(g.workDir, "**", rawPattern)
115 return g.globRecursive(ctx, fallback, fallback, ResolvedPath{}, to)
116 }
117 if len(matches) == 0 {
118 return "(no matches)", nil
119 }
120 matches = displayGlobMatches(matches, rp)
121 if len(matches) > globMaxResults {
122 matches = matches[:globMaxResults]
123 return strings.Join(matches, "\n") + fmt.Sprintf("\n... (truncated at %d results)", globMaxResults), nil
124 }
125 return strings.Join(matches, "\n"), nil
126 }
127
128 func filterForbidMatches(matches, forbidRoots []string) []string {
129 if len(matches) == 0 || (len(forbidRoots) == 0 && !secrets.ProtectSensitiveFiles()) {
130 return matches
131 }
132 out := matches[:0]
133 for _, match := range matches {
134 if !confineRead(forbidRoots, match) {
135 out = append(out, match)
136 }
137 }
138 return out
139 }
140
141 // globRecursive handles patterns containing ** by walking the stable non-meta
142 // prefix and matching relative paths with doublestar. Accepts a context so the
143 // walk can be interrupted on cancellation, and to so an expired deadline can be
144 // reported as incomplete results rather than as a failure.
145 func (g globTool) globRecursive(ctx context.Context, pattern, displayPattern string, rp ResolvedPath, to time.Duration) (string, error) {
146 rootSlash, relPattern := doublestar.SplitPattern(filepath.ToSlash(filepath.Clean(pattern)))
147 root := filepath.FromSlash(rootSlash)
148 if relPattern == "" {
149 relPattern = "**"
150 }
151
152 // Check root exists.
153 if info, err := os.Stat(root); err != nil {
154 if rp.External {
155 return "", fmt.Errorf("glob %q: %s", displayPattern, rp.ErrorText(err))
156 }
157 return "", fmt.Errorf("glob %q: %w", displayPattern, err)
158 } else if !info.IsDir() {
159 return "(no matches)", nil
160 }
161
162 var matches []string
163 truncated := false
164
165 err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
166 if ctx.Err() != nil {
167 return ctx.Err() // abort promptly on cancel/deadline — a huge tree is interruptible
168 }
169 if err != nil {
170 return nil // skip unreadable entries
171 }
172 if d.IsDir() {
173 if skipWalkDir(root, path, d.Name()) || skipForbidDir(path, g.forbidRoots) {
174 return filepath.SkipDir
175 }
176 return nil
177 }
178 if confineRead(g.forbidRoots, path) {
179 return nil
180 }
181 rel, rerr := filepath.Rel(root, path)
182 if rerr != nil {
183 return nil
184 }
185 if matchGlobPattern(filepath.ToSlash(rel), relPattern) {
186 matches = append(matches, path)
187 }
188 if len(matches) >= globMaxResults {
189 truncated = true
190 return filepath.SkipAll
191 }
192 return nil
193 })
194 timedOut := errors.Is(err, context.DeadlineExceeded)
195 if err != nil && !timedOut {
196 if rp.External {
197 return "", fmt.Errorf("glob %q: %s", displayPattern, rp.ErrorText(err))
198 }
199 return "", fmt.Errorf("glob %q: %w", displayPattern, err)
200 }
201
202 if len(matches) == 0 {
203 if timedOut {
204 return fmt.Sprintf("(no matches; timed out after %s — narrow the pattern or raise timeout_seconds)", to), nil
205 }
206 return "(no matches)", nil
207 }
208 sort.Strings(matches)
209 matches = displayGlobMatches(matches, rp)
210 result := strings.Join(matches, "\n")
211 switch {
212 case truncated:
213 result += fmt.Sprintf("\n... (truncated at %d results)", globMaxResults)
214 case timedOut:
215 result += fmt.Sprintf("\n... (timed out after %s; results incomplete — narrow the pattern or raise timeout_seconds)", to)
216 }
217 return result, nil
218 }
219
220 func displayGlobMatches(matches []string, rp ResolvedPath) []string {
221 if !rp.External {
222 return matches
223 }
224 out := make([]string, len(matches))
225 for i, m := range matches {
226 out[i] = rp.DisplayFor(m)
227 }
228 return out
229 }
230
231 func matchGlobPattern(path, pattern string) bool {
232 return fileutil.MatchSlashGlob(path, pattern)
233 }
234
234 lines GO