Skip to content

Commit 10cebba

Browse files
author
SqlRush
committed
Add Grep include-zero counts
1 parent 4d81152 commit 10cebba

4 files changed

Lines changed: 91 additions & 1 deletion

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ test/parity/ # golden tests against TS/official behavior
215215
- 本轮补充:Grep content 输出现在支持 ripgrep 风格 `vimgrep`/`--vimgrep`,匹配行按每个匹配重复输出 `path:line:column:text`,context 行保持单行输出,并兼容 `-N``only_matching` 和 quoted semantic boolean。
216216
- 本轮补充:Grep content/count 输出现在支持 ripgrep 风格 `with_filename`/`--with-filename`/`-H``no_filename`/`--no-filename`/`-I`,可控制匹配行和计数输出的文件名前缀;文件列表模式仍保留路径输出。
217217
- 本轮补充:Grep content 输出现在支持 ripgrep 风格 `replace`/`--replace`/`-r` 显示替换,匹配行按 replacement 展示、context 行保持原文,`only_matching``vimgrep` 输出也保留替换语义。
218+
- 本轮补充:Grep count 输出现在支持 ripgrep 风格 `include_zero`/`--include-zero`,普通 line count 和 `count_matches` occurrence count 都会输出零命中文件,并兼容 `--no-filename`
218219
- 本轮补充:Grep 常用布尔参数继续补齐 ripgrep 长参数 aliases,覆盖 `--line-number``--ignore-case``--fixed-strings``--word-regexp``--invert-match``--only-matching`,并兼容 quoted semantic boolean。
219220
- 本轮补充:Grep multiline 搜索现在支持 ripgrep 风格 `-U``--multiline``multiline-dotall``--multiline-dotall` aliases,统一映射到既有跨行 dotall 匹配逻辑并兼容 quoted semantic boolean。
220221
- 本轮补充:Grep 搜索现在支持 `no_ignore`/`noIgnore`/`no-ignore`/`--no-ignore`,可跳过 `.gitignore`/`.ignore` 规则,同时继续排除 VCS metadata 目录并保留 Read deny 额外 ignore 保护;`--no-ignore` 兼容 quoted boolean。

docs/first-second-parity-audit.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ Covered behavior:
140140
- `Grep` content output now accepts `vimgrep`/`--vimgrep`, repeating matching lines once per match with column metadata while preserving context-line output and `-N` formatting.
141141
- `Grep` content and count output now accept `with_filename`/`--with-filename`/`-H` plus `no_filename`/`--no-filename`/`-I`, while file-list modes continue to emit paths.
142142
- `Grep` content output now accepts `replace`/`--replace`/`-r`, applying display-only replacements to matching lines and only-matching/vimgrep output without changing context lines or count/list modes.
143+
- `Grep` count output now accepts `include_zero`/`--include-zero`, including zero-count files for both line counts and `count_matches` occurrence counts while preserving `--no-filename` output.
143144
- `Grep` content output now accepts `trim`/`--trim` plus `no_trim`/`--no-trim`, trimming leading ASCII whitespace from printed line text while preserving original match columns.
144145
- `Grep` long-line output now accepts `max_columns_preview`/`--max-columns-preview` plus `no_max_columns_preview`/`--no-max-columns-preview`, showing a ripgrep-style truncated preview when `max_columns` is exceeded.
145146
- `Glob`/`Grep` traversal now applies `Read(...)` deny rules from the permission context as extra search ignore rules, hiding denied basename, path, and directory patterns from search results.

internal/tools/file/search_tools.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ var allowedGrepInputKeys = map[string]struct{}{
5555
"files_without_match": {}, "filesWithoutMatch": {}, "files-without-match": {}, "--files-without-match": {}, "files_without_matches": {}, "filesWithoutMatches": {}, "files-without-matches": {}, "--files-without-matches": {}, "-L": {},
5656
"count": {}, "--count": {}, "-c": {},
5757
"count_matches": {}, "countMatches": {}, "count-matches": {}, "--count-matches": {},
58+
"include_zero": {}, "includeZero": {}, "include-zero": {}, "--include-zero": {},
5859
"no_ignore": {}, "noIgnore": {}, "no-ignore": {}, "--no-ignore": {},
5960
}
6061

@@ -86,6 +87,7 @@ var grepSemanticBooleanKeys = map[string]struct{}{
8687
"files_without_match": {}, "filesWithoutMatch": {}, "files-without-match": {}, "--files-without-match": {}, "files_without_matches": {}, "filesWithoutMatches": {}, "files-without-matches": {}, "--files-without-matches": {}, "-L": {},
8788
"count": {}, "--count": {}, "-c": {},
8889
"count_matches": {}, "countMatches": {}, "count-matches": {}, "--count-matches": {},
90+
"include_zero": {}, "includeZero": {}, "include-zero": {}, "--include-zero": {},
8991
"no_ignore": {}, "noIgnore": {}, "no-ignore": {}, "--no-ignore": {},
9092
}
9193

@@ -252,6 +254,10 @@ type grepInput struct {
252254
CountMatchesAlt bool `json:"countMatches,omitempty"`
253255
CountMatchesDash bool `json:"count-matches,omitempty"`
254256
LongCountMatches bool `json:"--count-matches,omitempty"`
257+
IncludeZero bool `json:"include_zero,omitempty"`
258+
IncludeZeroAlt bool `json:"includeZero,omitempty"`
259+
IncludeZeroDash bool `json:"include-zero,omitempty"`
260+
LongIncludeZero bool `json:"--include-zero,omitempty"`
255261
NoIgnore bool `json:"no_ignore,omitempty"`
256262
NoIgnoreAlt bool `json:"noIgnore,omitempty"`
257263
NoIgnoreDash bool `json:"no-ignore,omitempty"`
@@ -299,6 +305,7 @@ type grepOptions struct {
299305
Passthru bool
300306
Trim bool
301307
CountMatches bool
308+
IncludeZero bool
302309
ColumnNumbers bool
303310
Text bool
304311
SortMode string
@@ -544,6 +551,10 @@ func NewGrepTool() tool.Tool {
544551
"countMatches": map[string]any{"type": "boolean"},
545552
"count-matches": map[string]any{"type": "boolean"},
546553
"--count-matches": map[string]any{"type": "boolean"},
554+
"include_zero": map[string]any{"type": "boolean"},
555+
"includeZero": map[string]any{"type": "boolean"},
556+
"include-zero": map[string]any{"type": "boolean"},
557+
"--include-zero": map[string]any{"type": "boolean"},
547558
"no_ignore": map[string]any{"type": "boolean"},
548559
"noIgnore": map[string]any{"type": "boolean"},
549560
"no-ignore": map[string]any{"type": "boolean"},
@@ -561,7 +572,7 @@ func NewGrepTool() tool.Tool {
561572
},
562573
},
563574
PromptFunc: func(tool.PromptContext) (string, error) {
564-
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_with_matches, files_without_matches, content, or count; glob/-g/--glob and type/-t/--type optionally filter file paths. glob accepts whitespace/comma-separated patterns 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, -H/--with-filename and -I/--no-filename filename prefix 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, and trim/--trim leading-whitespace trimming. Use 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. Use sort/--sort or sortr/--sortr with path or modified to control result ordering. 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
575+
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_with_matches, files_without_matches, content, or count; glob/-g/--glob and type/-t/--type optionally filter file paths. glob accepts whitespace/comma-separated patterns 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, -H/--with-filename and -I/--no-filename filename prefix 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, and trim/--trim leading-whitespace trimming. Use 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. 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
565576
},
566577
NormalizeFunc: normalizeGrepRawInput,
567578
ValidateFunc: validateGrep,
@@ -727,6 +738,7 @@ func callGrep(ctx tool.Context, raw json.RawMessage, _ tool.ProgressSink) (contr
727738
}
728739
trim := grepTrim(input) && mode == "content"
729740
countMatches := grepCountMatches(input) && mode == "count" && !invertMatch
741+
includeZero := grepIncludeZero(input) && mode == "count"
730742
replace, hasReplace := grepReplacement(input)
731743
if mode != "content" {
732744
replace = ""
@@ -756,6 +768,7 @@ func callGrep(ctx tool.Context, raw json.RawMessage, _ tool.ProgressSink) (contr
756768
Passthru: passthru,
757769
Trim: trim,
758770
CountMatches: countMatches,
771+
IncludeZero: includeZero,
759772
ColumnNumbers: grepColumnNumbers(input),
760773
Text: grepText(input),
761774
SortMode: sortMode,
@@ -813,6 +826,7 @@ func callGrep(ctx tool.Context, raw json.RawMessage, _ tool.ProgressSink) (contr
813826
"files_without_match": mode == "files_without_matches",
814827
"count": mode == "count",
815828
"count_matches": countMatches,
829+
"include_zero": includeZero,
816830
"no_ignore": noIgnore,
817831
"multiline": grepMultiline(input),
818832
"sort": grepStructuredSortMode(options),
@@ -1007,6 +1021,9 @@ func collectGrepMatches(root string, displayRoot string, glob string, typeFilter
10071021
return nil
10081022
}
10091023
if len(lineMatches) == 0 {
1024+
if options.Mode == "count" && options.IncludeZero {
1025+
matches = append(matches, grepMatch{Path: displayRel, Count: 0, ModUnix: info.ModTime().UnixNano()})
1026+
}
10101027
return nil
10111028
}
10121029
switch options.Mode {
@@ -1850,6 +1867,10 @@ func grepCountMatches(input grepInput) bool {
18501867
return input.CountMatches || input.CountMatchesAlt || input.CountMatchesDash || input.LongCountMatches
18511868
}
18521869

1870+
func grepIncludeZero(input grepInput) bool {
1871+
return input.IncludeZero || input.IncludeZeroAlt || input.IncludeZeroDash || input.LongIncludeZero
1872+
}
1873+
18531874
func grepNoIgnore(input grepInput) bool {
18541875
return input.NoIgnore || input.NoIgnoreAlt || input.NoIgnoreDash || input.LongNoIgnore
18551876
}

internal/tools/file/tools_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2089,6 +2089,73 @@ func TestGrepToolFilenameControls(t *testing.T) {
20892089
}
20902090
}
20912091

2092+
func TestGrepToolIncludeZero(t *testing.T) {
2093+
dir := t.TempDir()
2094+
if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("Needle Needle\n"), 0o644); err != nil {
2095+
t.Fatal(err)
2096+
}
2097+
if err := os.WriteFile(filepath.Join(dir, "b.txt"), []byte("none\n"), 0o644); err != nil {
2098+
t.Fatal(err)
2099+
}
2100+
executor := fileExecutor(t)
2101+
ctx := fileToolContext(dir)
2102+
2103+
countResult, err := executor.Execute(ctx, contracts.ToolUse{
2104+
ID: "toolu_grep_include_zero_count",
2105+
Name: "Grep",
2106+
Input: json.RawMessage(`{"pattern":"Needle","output_mode":"count","include_zero":true,"sort":"path"}`),
2107+
}, nil)
2108+
if err != nil {
2109+
t.Fatal(err)
2110+
}
2111+
wantCount := "a.txt:1\nb.txt:0\n\nFound 1 total occurrence across 2 files."
2112+
if countResult.Content != wantCount || countResult.StructuredContent["include_zero"] != true {
2113+
t.Fatalf("include-zero count result = %#v", countResult)
2114+
}
2115+
2116+
countMatchesResult, err := executor.Execute(ctx, contracts.ToolUse{
2117+
ID: "toolu_grep_include_zero_count_matches",
2118+
Name: "Grep",
2119+
Input: json.RawMessage(`{"pattern":"Needle","output_mode":"count","count_matches":true,"--include-zero":"true","sort":"path"}`),
2120+
}, nil)
2121+
if err != nil {
2122+
t.Fatal(err)
2123+
}
2124+
wantCountMatches := "a.txt:2\nb.txt:0\n\nFound 2 total occurrences across 2 files."
2125+
if countMatchesResult.Content != wantCountMatches ||
2126+
countMatchesResult.StructuredContent["count_matches"] != true ||
2127+
countMatchesResult.StructuredContent["include_zero"] != true {
2128+
t.Fatalf("include-zero count-matches result = %#v", countMatchesResult)
2129+
}
2130+
2131+
noFilenameResult, err := executor.Execute(ctx, contracts.ToolUse{
2132+
ID: "toolu_grep_include_zero_no_filename",
2133+
Name: "Grep",
2134+
Input: json.RawMessage(`{"pattern":"Needle","output_mode":"count","include-zero":true,"--no-filename":true,"sort":"path"}`),
2135+
}, nil)
2136+
if err != nil {
2137+
t.Fatal(err)
2138+
}
2139+
wantNoFilename := "1\n0\n\nFound 1 total occurrence across 2 files."
2140+
if noFilenameResult.Content != wantNoFilename ||
2141+
noFilenameResult.StructuredContent["with_filename"] != false ||
2142+
noFilenameResult.StructuredContent["include_zero"] != true {
2143+
t.Fatalf("include-zero no-filename result = %#v", noFilenameResult)
2144+
}
2145+
2146+
contentResult, err := executor.Execute(ctx, contracts.ToolUse{
2147+
ID: "toolu_grep_include_zero_content_ignored",
2148+
Name: "Grep",
2149+
Input: json.RawMessage(`{"pattern":"Needle","output_mode":"content","--include-zero":true,"sort":"path"}`),
2150+
}, nil)
2151+
if err != nil {
2152+
t.Fatal(err)
2153+
}
2154+
if contentResult.Content != "a.txt:1:Needle Needle" || contentResult.StructuredContent["include_zero"] != false {
2155+
t.Fatalf("include-zero content result = %#v", contentResult)
2156+
}
2157+
}
2158+
20922159
func TestGrepToolTrim(t *testing.T) {
20932160
dir := t.TempDir()
20942161
content := strings.Join([]string{

0 commit comments

Comments
 (0)