| 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 |