Skip to content

Commit 741173c

Browse files
author
SqlRush
committed
Add Grep max-depth traversal
1 parent 19690bb commit 741173c

4 files changed

Lines changed: 149 additions & 6 deletions

File tree

docs/claude-code-go-rewrite-plan.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ test/parity/ # golden tests against TS/official behavior
233233
- 本轮补充:Grep 的 `files_with_matches` 输出现在按官方行为使用文件修改时间倒序排序,mtime 相同再按路径排序;分页和 `head_limit` 会在排序后应用。
234234
- 本轮补充:Grep 结果排序现在支持 ripgrep 风格 `sort`/`--sort``sortr`/`--sortr` 参数,覆盖 `path`/`modified`/`none` 及常见别名;显式排序会作用于 files/content/count 输出,structured content 会回传实际 sort、reverse 和 explicit 状态。
235235
- 本轮补充:Grep 结果排序现在支持 ripgrep deprecated `sort_files`/`sortFiles`/`sort-files`/`--sort-files` aliases,统一映射到显式 `sort=path`,并兼容 quoted semantic boolean。
236+
- 本轮补充:Grep 遍历现在支持 ripgrep 风格 `max_depth`/`maxDepth`/`max-depth`/`--max-depth`/`-d`,按 `--max-depth 0` 目录 no-op、`1` 仅直属文件、`2` 下一层文件的语义限制递归,并兼容 quoted number。
236237
- 本轮补充:Glob/Grep 搜索遍历现在会读取 permission context 中的 `Read(...)` deny 规则,并把对应 basename/path/directory pattern 作为额外 ignore rule,避免被禁止读取的文件出现在搜索结果中。
237238
- 本轮补充:Bash `grep`/`rg` read-only 分类现在会把 pattern-file 参数 `-f FILE``-fFILE``--file=FILE` 当作路径读取处理,缺值、绝对路径和 `..` 路径不再进入 read-only fast path。
238239
- 本轮补充:Bash/PowerShell read-only 分类会先校验 tokenizer 视角的语法完整性,未闭合 quote 或末尾 escape/line-continuation 不再进入只读 fast path。

docs/first-second-parity-audit.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ Covered behavior:
135135
- `Grep` files-with-matches output now mirrors the official modified-time sort: newest files first, path tie-breaker, and pagination applied after sorting.
136136
- `Grep` now accepts ripgrep-style file listing through `files`/`--files` and `output_mode:"files"`, not requiring a pattern and listing paths after traversal, ignore, glob/iglob, type/type-not, hidden, and binary/text filters without reading file contents.
137137
- `Grep` now accepts ripgrep-style result ordering through `sort`/`--sort`, `sortr`/`--sortr`, and deprecated `sort_files`/`--sort-files`, covering path, modified-time, and none ordering for files/content/count modes with structured sort metadata.
138+
- `Grep` now accepts ripgrep-style traversal depth limiting through `max_depth`/`maxDepth`/`max-depth`/`--max-depth`/`-d`, including quoted numeric input and `--max-depth 0` directory no-op behavior.
138139
- `Grep` now accepts ripgrep-style negative type filtering through `type_not`/`typeNot`/`type-not`/`--type-not`/`-T`, applying it after any positive `type` filter and preserving structured filter metadata.
139140
- `Grep` now accepts ripgrep-style case-insensitive glob filtering through `iglob`/`--iglob`, sharing positive and `!` negative glob rule semantics with `glob` while preserving structured filter metadata.
140141
- `Grep` glob filtering now accepts ripgrep-style negated `!pattern` rules, including combinations with positive patterns, comma/whitespace splitting, and brace-expanded globs.

internal/tools/file/search_tools.go

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ var allowedGrepInputKeys = map[string]struct{}{
3232
"pattern": {}, "regex": {}, "regexp": {}, "--regexp": {}, "-e": {}, "path": {}, "glob": {}, "--glob": {}, "-g": {}, "iglob": {}, "--iglob": {}, "type": {}, "--type": {}, "-t": {}, "type_not": {}, "typeNot": {}, "type-not": {}, "--type-not": {}, "-T": {}, "output_mode": {}, "outputMode": {}, "limit": {},
3333
"head_limit": {}, "headLimit": {}, "offset": {}, "max_count": {}, "maxCount": {}, "-m": {},
3434
"max_columns": {}, "maxColumns": {}, "max-columns": {}, "--max-columns": {},
35+
"max_depth": {}, "maxDepth": {}, "max-depth": {}, "--max-depth": {}, "-d": {},
3536
"max_columns_preview": {}, "maxColumnsPreview": {}, "max-columns-preview": {}, "--max-columns-preview": {}, "no_max_columns_preview": {}, "noMaxColumnsPreview": {}, "no-max-columns-preview": {}, "--no-max-columns-preview": {},
3637
"replace": {}, "--replace": {}, "-r": {},
3738
"with_filename": {}, "withFilename": {}, "with-filename": {}, "--with-filename": {}, "-H": {}, "no_filename": {}, "noFilename": {}, "no-filename": {}, "--no-filename": {}, "-I": {},
@@ -72,6 +73,7 @@ var allowedGrepInputKeys = map[string]struct{}{
7273
var grepSemanticNumberKeys = map[string]struct{}{
7374
"limit": {}, "head_limit": {}, "headLimit": {}, "offset": {}, "max_count": {}, "maxCount": {}, "-m": {},
7475
"max_columns": {}, "maxColumns": {}, "max-columns": {}, "--max-columns": {},
76+
"max_depth": {}, "maxDepth": {}, "max-depth": {}, "--max-depth": {}, "-d": {},
7577
"context": {}, "-C": {}, "before_context": {}, "beforeContext": {}, "-B": {}, "after_context": {}, "afterContext": {}, "-A": {},
7678
}
7779

@@ -146,6 +148,11 @@ type grepInput struct {
146148
MaxColumnsAlt *int `json:"maxColumns,omitempty"`
147149
MaxColumnsDash *int `json:"max-columns,omitempty"`
148150
LongMaxColumns *int `json:"--max-columns,omitempty"`
151+
MaxDepth *int `json:"max_depth,omitempty"`
152+
MaxDepthAlt *int `json:"maxDepth,omitempty"`
153+
MaxDepthDash *int `json:"max-depth,omitempty"`
154+
LongMaxDepth *int `json:"--max-depth,omitempty"`
155+
ShortMaxDepth *int `json:"-d,omitempty"`
149156
MaxColumnsPreview bool `json:"max_columns_preview,omitempty"`
150157
MaxColumnsPreviewAlt bool `json:"maxColumnsPreview,omitempty"`
151158
MaxColumnsPreviewDash bool `json:"max-columns-preview,omitempty"`
@@ -396,6 +403,7 @@ type searchWalkOptions struct {
396403
UseIgnoreFiles bool
397404
IncludeHidden bool
398405
ExcludeVCSDirs bool
406+
MaxDepth int
399407
ExtraIgnores searchIgnoreRules
400408
}
401409

@@ -473,6 +481,13 @@ func NewGrepTool() tool.Tool {
473481
"--max-columns": map[string]any{
474482
"type": "integer",
475483
},
484+
"max_depth": map[string]any{"type": "integer"},
485+
"maxDepth": map[string]any{"type": "integer"},
486+
"max-depth": map[string]any{"type": "integer"},
487+
"--max-depth": map[string]any{
488+
"type": "integer",
489+
},
490+
"-d": map[string]any{"type": "integer"},
476491
"max_columns_preview": map[string]any{"type": "boolean"},
477492
"maxColumnsPreview": map[string]any{"type": "boolean"},
478493
"max-columns-preview": map[string]any{"type": "boolean"},
@@ -706,7 +721,7 @@ func NewGrepTool() tool.Tool {
706721
},
707722
},
708723
PromptFunc: func(tool.PromptContext) (string, error) {
709-
return "Searches text files under path using a regular expression or fixed string. pattern is the canonical search expression; regex/regexp/--regexp/-e are accepted aliases. output_mode may be files, files_with_matches, files_without_matches, content, or count; glob/-g/--glob, iglob/--iglob, type/-t/--type, and type_not/-T/--type-not optionally filter file paths. glob and iglob accept whitespace/comma-separated patterns, negation, and brace alternation. content mode supports context, before_context, after_context, -C, -B, -A, -n/--line-number and -N/--no-line-number line-number control, --column column-number output, byte_offset/--byte-offset/-b byte offset output, -H/--with-filename and -I/--no-filename filename prefix control, heading/--heading grouped file headings, path_separator/--path-separator display path separator control, null/--null NUL path terminators/separators, field_match_separator/--field-match-separator and field_context_separator/--field-context-separator output field separators, context_separator/--context-separator and no_context_separator/--no-context-separator context group separator control, offset, head_limit pagination, max_count/-m per-file match limiting, max_columns/--max-columns long-line omission, --max-columns-preview long-line previews, replace/--replace/-r display-only replacement, only_matching/-o/--only-matching matched-text output, vimgrep/--vimgrep per-match line output, passthru/--passthru/--passthrough all-line output, trim/--trim leading-whitespace trimming, and hidden/--hidden or no_hidden/--no-hidden hidden file traversal control. Use files/--files to list files that would be searched without requiring pattern, files_with_matches or -l to list files with matches, files_without_match or -L to list files without matches, and count/--count/-c for count mode. Count mode supports count_matches/--count-matches for occurrence counts and include_zero/--include-zero to include zero-count files. Use sort/--sort or sortr/--sortr with path or modified to control result ordering; --sort-files is accepted as a path-sort alias. Use fixed_strings/-F/--fixed-strings for literal matching, text/-a/--text to search binary-extension files as text, word_regexp/-w/--word-regexp for whole-word matches, line_regexp/-x/--line-regexp for whole-line matches, ignore_case/-i/--ignore-case for case-insensitive search, case_sensitive/-s/--case-sensitive to force case-sensitive matching, smart_case/-S/--smart-case for lowercase-only patterns, and invert_match/-v/--invert-match to select non-matching lines. Set no_ignore/--no-ignore to skip .gitignore/.ignore files while still excluding VCS metadata and read-denied paths. Set multiline to allow patterns to span lines with dot matching newlines.", nil
724+
return "Searches text files under path using a regular expression or fixed string. pattern is the canonical search expression; regex/regexp/--regexp/-e are accepted aliases. output_mode may be files, files_with_matches, files_without_matches, content, or count; glob/-g/--glob, iglob/--iglob, type/-t/--type, and type_not/-T/--type-not optionally filter file paths. glob and iglob accept whitespace/comma-separated patterns, negation, and brace alternation. content mode supports context, before_context, after_context, -C, -B, -A, -n/--line-number and -N/--no-line-number line-number control, --column column-number output, byte_offset/--byte-offset/-b byte offset output, -H/--with-filename and -I/--no-filename filename prefix control, heading/--heading grouped file headings, path_separator/--path-separator display path separator control, null/--null NUL path terminators/separators, field_match_separator/--field-match-separator and field_context_separator/--field-context-separator output field separators, context_separator/--context-separator and no_context_separator/--no-context-separator context group separator control, offset, head_limit pagination, max_count/-m per-file match limiting, max_columns/--max-columns long-line omission, --max-columns-preview long-line previews, replace/--replace/-r display-only replacement, only_matching/-o/--only-matching matched-text output, vimgrep/--vimgrep per-match line output, passthru/--passthru/--passthrough all-line output, trim/--trim leading-whitespace trimming, and hidden/--hidden or no_hidden/--no-hidden hidden file traversal control. Use files/--files to list files that would be searched without requiring pattern, files_with_matches or -l to list files with matches, files_without_match or -L to list files without matches, and count/--count/-c for count mode. Count mode supports count_matches/--count-matches for occurrence counts and include_zero/--include-zero to include zero-count files. Use max_depth/--max-depth/-d to limit directory descent, and sort/--sort or sortr/--sortr with path or modified to control result ordering; --sort-files is accepted as a path-sort alias. Use fixed_strings/-F/--fixed-strings for literal matching, text/-a/--text to search binary-extension files as text, word_regexp/-w/--word-regexp for whole-word matches, line_regexp/-x/--line-regexp for whole-line matches, ignore_case/-i/--ignore-case for case-insensitive search, case_sensitive/-s/--case-sensitive to force case-sensitive matching, smart_case/-S/--smart-case for lowercase-only patterns, and invert_match/-v/--invert-match to select non-matching lines. Set no_ignore/--no-ignore to skip .gitignore/.ignore files while still excluding VCS metadata and read-denied paths. Set multiline to allow patterns to span lines with dot matching newlines.", nil
710725
},
711726
NormalizeFunc: normalizeGrepRawInput,
712727
ValidateFunc: validateGrep,
@@ -823,6 +838,9 @@ func validateGrep(ctx tool.Context, raw json.RawMessage) error {
823838
if input.LongMaxColumns != nil && *input.LongMaxColumns < 0 {
824839
return fmt.Errorf("max_columns must be non-negative")
825840
}
841+
if err := validateGrepMaxDepth(input); err != nil {
842+
return err
843+
}
826844
if _, _, _, err := grepSort(input); err != nil {
827845
return err
828846
}
@@ -942,7 +960,8 @@ func callGrep(ctx tool.Context, raw json.RawMessage, _ tool.ProgressSink) (contr
942960
iglobFilter := grepIGlobFilter(input)
943961
typeFilter := grepTypeFilter(input)
944962
typeNotFilter := grepTypeNotFilter(input)
945-
matches, totalMatches, truncated, err := collectGrepMatches(root, displayRoot, globFilter, iglobFilter, typeFilter, typeNotFilter, expr, options, grepWalkOptions(ctx, root, noIgnore, includeHidden))
963+
maxDepth := grepMaxDepth(input)
964+
matches, totalMatches, truncated, err := collectGrepMatches(root, displayRoot, globFilter, iglobFilter, typeFilter, typeNotFilter, expr, options, grepWalkOptions(ctx, root, noIgnore, includeHidden, maxDepth))
946965
if err != nil {
947966
return contracts.ToolResult{}, err
948967
}
@@ -965,6 +984,7 @@ func callGrep(ctx tool.Context, raw json.RawMessage, _ tool.ProgressSink) (contr
965984
"total_matches": totalMatches,
966985
"offset": options.Offset,
967986
"limit": options.Limit,
987+
"max_depth": structuredOptionalInt(maxDepth),
968988
"max_count": options.MaxCount,
969989
"max_columns": options.MaxColumns,
970990
"max_columns_preview": options.MaxPreview,
@@ -2514,6 +2534,41 @@ func grepMaxColumns(input grepInput) int {
25142534
return defaultGrepMaxColumns
25152535
}
25162536

2537+
func validateGrepMaxDepth(input grepInput) error {
2538+
for _, value := range []*int{input.MaxDepth, input.MaxDepthAlt, input.MaxDepthDash, input.LongMaxDepth, input.ShortMaxDepth} {
2539+
if value != nil && *value < 0 {
2540+
return fmt.Errorf("max_depth must be non-negative")
2541+
}
2542+
}
2543+
return nil
2544+
}
2545+
2546+
func grepMaxDepth(input grepInput) int {
2547+
if input.MaxDepth != nil {
2548+
return *input.MaxDepth
2549+
}
2550+
if input.MaxDepthAlt != nil {
2551+
return *input.MaxDepthAlt
2552+
}
2553+
if input.MaxDepthDash != nil {
2554+
return *input.MaxDepthDash
2555+
}
2556+
if input.LongMaxDepth != nil {
2557+
return *input.LongMaxDepth
2558+
}
2559+
if input.ShortMaxDepth != nil {
2560+
return *input.ShortMaxDepth
2561+
}
2562+
return -1
2563+
}
2564+
2565+
func structuredOptionalInt(value int) any {
2566+
if value < 0 {
2567+
return nil
2568+
}
2569+
return value
2570+
}
2571+
25172572
func grepMaxColumnsPreview(input grepInput) bool {
25182573
if input.NoMaxColumnsPreview ||
25192574
input.NoMaxColumnsPreviewAlt ||
@@ -2586,15 +2641,17 @@ func globWalkOptions(ctx tool.Context, root string) searchWalkOptions {
25862641
return searchWalkOptions{
25872642
UseIgnoreFiles: !envTruthyDefault("CLAUDE_CODE_GLOB_NO_IGNORE", true),
25882643
IncludeHidden: envTruthyDefault("CLAUDE_CODE_GLOB_HIDDEN", true),
2644+
MaxDepth: -1,
25892645
ExtraIgnores: readDenySearchIgnoreRules(ctx, root),
25902646
}
25912647
}
25922648

2593-
func grepWalkOptions(ctx tool.Context, root string, noIgnore bool, includeHidden bool) searchWalkOptions {
2649+
func grepWalkOptions(ctx tool.Context, root string, noIgnore bool, includeHidden bool, maxDepth int) searchWalkOptions {
25942650
return searchWalkOptions{
25952651
UseIgnoreFiles: !noIgnore,
25962652
IncludeHidden: includeHidden,
25972653
ExcludeVCSDirs: true,
2654+
MaxDepth: maxDepth,
25982655
ExtraIgnores: readDenySearchIgnoreRules(ctx, root),
25992656
}
26002657
}
@@ -2706,17 +2763,20 @@ func walkSearchFiles(root string, options searchWalkOptions, visit func(path str
27062763
if !info.IsDir() {
27072764
return visit(root, filepath.Base(root), info)
27082765
}
2766+
if options.MaxDepth == 0 {
2767+
return nil
2768+
}
27092769
var ignoreRules searchIgnoreRules
27102770
if options.UseIgnoreFiles {
27112771
ignoreRules = loadSearchIgnoreRules(root, "")
27122772
}
27132773
if len(options.ExtraIgnores) > 0 {
27142774
ignoreRules = append(ignoreRules, options.ExtraIgnores...)
27152775
}
2716-
return walkSearchDir(root, root, options, ignoreRules, visit)
2776+
return walkSearchDir(root, root, 0, options, ignoreRules, visit)
27172777
}
27182778

2719-
func walkSearchDir(root string, dir string, options searchWalkOptions, ignoreRules searchIgnoreRules, visit func(path string, rel string, info os.FileInfo) error) error {
2779+
func walkSearchDir(root string, dir string, depth int, options searchWalkOptions, ignoreRules searchIgnoreRules, visit func(path string, rel string, info os.FileInfo) error) error {
27202780
entries, err := os.ReadDir(dir)
27212781
if err != nil {
27222782
return err
@@ -2735,11 +2795,14 @@ func walkSearchDir(root string, dir string, options searchWalkOptions, ignoreRul
27352795
if (options.ExcludeVCSDirs && ignoredVCSDir(entry.Name())) || (len(ignoreRules) > 0 && ignoreRules.Ignored(rel, true)) {
27362796
continue
27372797
}
2798+
if options.MaxDepth >= 0 && depth+1 >= options.MaxDepth {
2799+
continue
2800+
}
27382801
dirRules := append(searchIgnoreRules(nil), ignoreRules...)
27392802
if options.UseIgnoreFiles {
27402803
dirRules = append(dirRules, loadSearchIgnoreRules(path, rel)...)
27412804
}
2742-
if err := walkSearchDir(root, path, options, dirRules, visit); err != nil {
2805+
if err := walkSearchDir(root, path, depth+1, options, dirRules, visit); err != nil {
27432806
return err
27442807
}
27452808
continue

internal/tools/file/tools_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1611,6 +1611,75 @@ func TestGrepToolFilesMode(t *testing.T) {
16111611
}
16121612
}
16131613

1614+
func TestGrepToolMaxDepth(t *testing.T) {
1615+
dir := t.TempDir()
1616+
for _, subdir := range []string{
1617+
filepath.Join(dir, "one", "two"),
1618+
filepath.Join(dir, "skip"),
1619+
} {
1620+
if err := os.MkdirAll(subdir, 0o755); err != nil {
1621+
t.Fatal(err)
1622+
}
1623+
}
1624+
files := map[string]string{
1625+
"root.txt": "Needle root\n",
1626+
"one/one.txt": "Needle one\n",
1627+
"one/two/two.txt": "Needle two\n",
1628+
"skip/ignored.txt": "no match\n",
1629+
"skip/matched.txt": "Needle skip\n",
1630+
"skip/nested.txt": "Needle nested\n",
1631+
"skip/another.json": "Needle json\n",
1632+
}
1633+
for name, content := range files {
1634+
if err := os.WriteFile(filepath.Join(dir, filepath.FromSlash(name)), []byte(content), 0o644); err != nil {
1635+
t.Fatal(err)
1636+
}
1637+
}
1638+
executor := fileExecutor(t)
1639+
ctx := fileToolContext(dir)
1640+
1641+
filesResult, err := executor.Execute(ctx, contracts.ToolUse{
1642+
ID: "toolu_grep_max_depth_files",
1643+
Name: "Grep",
1644+
Input: json.RawMessage(`{"--files":true,"--max-depth":"2","sort":"path"}`),
1645+
}, nil)
1646+
if err != nil {
1647+
t.Fatal(err)
1648+
}
1649+
wantFiles := "Found 6 files\none/one.txt\nroot.txt\nskip/another.json\nskip/ignored.txt\nskip/matched.txt\nskip/nested.txt"
1650+
if filesResult.Content != wantFiles ||
1651+
filesResult.StructuredContent["max_depth"] != 2 {
1652+
t.Fatalf("max-depth files result = %#v", filesResult)
1653+
}
1654+
1655+
matchResult, err := executor.Execute(ctx, contracts.ToolUse{
1656+
ID: "toolu_grep_short_max_depth",
1657+
Name: "Grep",
1658+
Input: json.RawMessage(`{"pattern":"Needle","-d":1,"sort":"path"}`),
1659+
}, nil)
1660+
if err != nil {
1661+
t.Fatal(err)
1662+
}
1663+
if matchResult.Content != "Found 1 file\nroot.txt" ||
1664+
matchResult.StructuredContent["max_depth"] != 1 {
1665+
t.Fatalf("short max-depth result = %#v", matchResult)
1666+
}
1667+
1668+
zeroResult, err := executor.Execute(ctx, contracts.ToolUse{
1669+
ID: "toolu_grep_zero_max_depth",
1670+
Name: "Grep",
1671+
Input: json.RawMessage(`{"--files":true,"max_depth":0}`),
1672+
}, nil)
1673+
if err != nil {
1674+
t.Fatal(err)
1675+
}
1676+
if zeroResult.Content != "No files found" ||
1677+
zeroResult.StructuredContent["max_depth"] != 0 ||
1678+
zeroResult.StructuredContent["total_matches"] != 0 {
1679+
t.Fatalf("zero max-depth result = %#v", zeroResult)
1680+
}
1681+
}
1682+
16141683
func TestGrepToolFilesWithMatchesSortsByModifiedTime(t *testing.T) {
16151684
dir := t.TempDir()
16161685
base := time.Now().Add(-4 * time.Hour)
@@ -3136,6 +3205,15 @@ func TestGrepToolCaseInsensitiveAndValidation(t *testing.T) {
31363205
if err == nil || !strings.Contains(err.Error(), "max_columns must be non-negative") {
31373206
t.Fatalf("max_columns validation err = %v", err)
31383207
}
3208+
3209+
_, err = executor.Execute(ctx, contracts.ToolUse{
3210+
ID: "toolu_grep_bad_max_depth",
3211+
Name: "Grep",
3212+
Input: json.RawMessage(`{"pattern":"Alpha","--max-depth":"-1"}`),
3213+
}, nil)
3214+
if err == nil || !strings.Contains(err.Error(), "max_depth must be non-negative") {
3215+
t.Fatalf("max_depth validation err = %v", err)
3216+
}
31393217
}
31403218

31413219
func TestGrepToolFixedStrings(t *testing.T) {

0 commit comments

Comments
 (0)