Skip to content

Commit 39c84af

Browse files
author
SqlRush
committed
Add Grep vimgrep output mode
1 parent a93365f commit 39c84af

4 files changed

Lines changed: 106 additions & 2 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ test/parity/ # golden tests against TS/official behavior
212212
- 本轮补充:Grep content 输出现在支持 ripgrep 风格 `trim`/`--trim``no_trim`/`--no-trim`,会删除每条已打印文本行开头的 ASCII 空白,同时保留原始匹配列号;quoted semantic boolean 同样兼容。
213213
- 本轮补充:Grep 长行输出现在支持 ripgrep 风格 `max_columns_preview`/`--max-columns-preview``no_max_columns_preview`/`--no-max-columns-preview`,会在 `max_columns` 触发时输出截断预览加官方 omitted-end 后缀;quoted semantic boolean 同样兼容。
214214
- 本轮补充:Grep 搜索现在支持 ripgrep 风格 `line_regexp`/`line-regexp`/`--line-regexp`/`-x`,将 pattern 限定为整行匹配,并按官方语义优先于 `word_regexp`;fixed-string、multiline 和 quoted semantic boolean 组合均兼容。
215+
- 本轮补充:Grep content 输出现在支持 ripgrep 风格 `vimgrep`/`--vimgrep`,匹配行按每个匹配重复输出 `path:line:column:text`,context 行保持单行输出,并兼容 `-N``only_matching` 和 quoted semantic boolean。
215216
- 本轮补充:Grep 常用布尔参数继续补齐 ripgrep 长参数 aliases,覆盖 `--line-number``--ignore-case``--fixed-strings``--word-regexp``--invert-match``--only-matching`,并兼容 quoted semantic boolean。
216217
- 本轮补充:Grep multiline 搜索现在支持 ripgrep 风格 `-U``--multiline``multiline-dotall``--multiline-dotall` aliases,统一映射到既有跨行 dotall 匹配逻辑并兼容 quoted semantic boolean。
217218
- 本轮补充: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
@@ -137,6 +137,7 @@ Covered behavior:
137137
- `Grep` now accepts `text`/`--text`/`-a` to search binary-extension files as text while preserving the default binary-extension skip behavior.
138138
- `Grep` now accepts `line_regexp`/`line-regexp`/`--line-regexp`/`-x` to require whole-line matches, with fixed-string and word-regexp precedence covered.
139139
- `Grep` content output now accepts `passthru`/`passthrough`/`--passthru`/`--passthrough`, printing all searched lines while preserving matched-line markers and overriding context counts.
140+
- `Grep` content output now accepts `vimgrep`/`--vimgrep`, repeating matching lines once per match with column metadata while preserving context-line output and `-N` formatting.
140141
- `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.
141142
- `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.
142143
- `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: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ var allowedGrepInputKeys = map[string]struct{}{
4646
"line_regexp": {}, "lineRegexp": {}, "line-regexp": {}, "--line-regexp": {}, "-x": {},
4747
"invert_match": {}, "invertMatch": {}, "invert-match": {}, "--invert-match": {}, "-v": {},
4848
"only_matching": {}, "onlyMatching": {}, "only-matching": {}, "--only-matching": {}, "-o": {},
49+
"vimgrep": {}, "--vimgrep": {},
4950
"passthru": {}, "passthrough": {}, "--passthru": {}, "--passthrough": {},
5051
"trim": {}, "--trim": {}, "no_trim": {}, "noTrim": {}, "no-trim": {}, "--no-trim": {},
5152
"files_with_match": {}, "filesWithMatch": {}, "files-with-match": {}, "--files-with-match": {}, "files_with_matches": {}, "filesWithMatches": {}, "files-with-matches": {}, "--files-with-matches": {}, "-l": {},
@@ -75,6 +76,7 @@ var grepSemanticBooleanKeys = map[string]struct{}{
7576
"line_regexp": {}, "lineRegexp": {}, "line-regexp": {}, "--line-regexp": {}, "-x": {},
7677
"invert_match": {}, "invertMatch": {}, "invert-match": {}, "--invert-match": {}, "-v": {},
7778
"only_matching": {}, "onlyMatching": {}, "only-matching": {}, "--only-matching": {}, "-o": {},
79+
"vimgrep": {}, "--vimgrep": {},
7880
"passthru": {}, "passthrough": {}, "--passthru": {}, "--passthrough": {},
7981
"trim": {}, "--trim": {}, "no_trim": {}, "noTrim": {}, "no-trim": {}, "--no-trim": {},
8082
"files_with_match": {}, "filesWithMatch": {}, "files-with-match": {}, "--files-with-match": {}, "files_with_matches": {}, "filesWithMatches": {}, "files-with-matches": {}, "--files-with-matches": {}, "-l": {},
@@ -197,6 +199,8 @@ type grepInput struct {
197199
OnlyMatchingDash bool `json:"only-matching,omitempty"`
198200
LongOnlyMatching bool `json:"--only-matching,omitempty"`
199201
ShortOnlyMatching bool `json:"-o,omitempty"`
202+
Vimgrep bool `json:"vimgrep,omitempty"`
203+
LongVimgrep bool `json:"--vimgrep,omitempty"`
200204
Passthru bool `json:"passthru,omitempty"`
201205
Passthrough bool `json:"passthrough,omitempty"`
202206
LongPassthru bool `json:"--passthru,omitempty"`
@@ -272,6 +276,7 @@ type grepOptions struct {
272276
Multiline bool
273277
InvertMatch bool
274278
OnlyMatching bool
279+
Vimgrep bool
275280
Passthru bool
276281
Trim bool
277282
CountMatches bool
@@ -446,6 +451,8 @@ func NewGrepTool() tool.Tool {
446451
"only-matching": map[string]any{"type": "boolean"},
447452
"--only-matching": map[string]any{"type": "boolean"},
448453
"-o": map[string]any{"type": "boolean"},
454+
"vimgrep": map[string]any{"type": "boolean"},
455+
"--vimgrep": map[string]any{"type": "boolean"},
449456
"passthru": map[string]any{"type": "boolean"},
450457
"passthrough": map[string]any{"type": "boolean"},
451458
"--passthru": map[string]any{"type": "boolean"},
@@ -522,7 +529,7 @@ func NewGrepTool() tool.Tool {
522529
},
523530
},
524531
PromptFunc: func(tool.PromptContext) (string, error) {
525-
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, offset, head_limit pagination, max_count/-m per-file match limiting, max_columns/--max-columns long-line omission, --max-columns-preview long-line previews, only_matching/-o/--only-matching matched-text 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
532+
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, offset, head_limit pagination, max_count/-m per-file match limiting, max_columns/--max-columns long-line omission, --max-columns-preview long-line previews, 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
526533
},
527534
NormalizeFunc: normalizeGrepRawInput,
528535
ValidateFunc: validateGrep,
@@ -680,6 +687,7 @@ func callGrep(ctx tool.Context, raw json.RawMessage, _ tool.ProgressSink) (contr
680687
before = 0
681688
after = 0
682689
}
690+
vimgrep := grepVimgrep(input) && mode == "content"
683691
passthru := grepPassthru(input) && mode == "content" && !onlyMatching
684692
if passthru {
685693
before = 0
@@ -704,6 +712,7 @@ func callGrep(ctx tool.Context, raw json.RawMessage, _ tool.ProgressSink) (contr
704712
Multiline: grepMultiline(input),
705713
InvertMatch: invertMatch,
706714
OnlyMatching: onlyMatching,
715+
Vimgrep: vimgrep,
707716
Passthru: passthru,
708717
Trim: trim,
709718
CountMatches: countMatches,
@@ -753,6 +762,7 @@ func callGrep(ctx tool.Context, raw json.RawMessage, _ tool.ProgressSink) (contr
753762
"line_regexp": grepLineRegexp(input),
754763
"invert_match": invertMatch,
755764
"only_matching": onlyMatching,
765+
"vimgrep": vimgrep,
756766
"passthru": passthru,
757767
"trim": trim,
758768
"files_with_matches": mode == "files_with_matches",
@@ -1049,6 +1059,9 @@ func grepFileMatches(path string, content string, expr *regexp.Regexp, options g
10491059
included[i] = true
10501060
}
10511061
}
1062+
if options.Vimgrep && !options.InvertMatch {
1063+
return grepVimgrepMatches(path, lines, expr, matched, included, options)
1064+
}
10521065
matches := make([]grepMatch, 0, len(included))
10531066
for i := range lines {
10541067
if !included[i] {
@@ -1065,6 +1078,29 @@ func grepFileMatches(path string, content string, expr *regexp.Regexp, options g
10651078
return matches
10661079
}
10671080

1081+
func grepVimgrepMatches(path string, lines []string, expr *regexp.Regexp, matched map[int]bool, included map[int]bool, options grepOptions) []grepMatch {
1082+
matches := make([]grepMatch, 0, len(included))
1083+
for i := range lines {
1084+
if !included[i] {
1085+
continue
1086+
}
1087+
text := grepDisplayLine(lines[i], matched[i], options.MaxColumns, options.MaxPreview, options.Trim)
1088+
if !matched[i] {
1089+
matches = append(matches, grepMatch{Path: path, Line: i + 1, Text: text, Matched: false})
1090+
continue
1091+
}
1092+
spans := expr.FindAllStringIndex(lines[i], -1)
1093+
if len(spans) == 0 {
1094+
matches = append(matches, grepMatch{Path: path, Line: i + 1, Column: 1, Text: text, Matched: true})
1095+
continue
1096+
}
1097+
for _, span := range spans {
1098+
matches = append(matches, grepMatch{Path: path, Line: i + 1, Column: span[0] + 1, Text: text, Matched: true})
1099+
}
1100+
}
1101+
return matches
1102+
}
1103+
10681104
func grepLineOnlyMatches(path string, lines []string, expr *regexp.Regexp, maxCount int, maxColumns int, maxPreview bool, trim bool) []grepMatch {
10691105
var matches []grepMatch
10701106
matchedLines := 0
@@ -1287,12 +1323,16 @@ func formatGrepMatches(matches []grepMatch, options grepOptions) string {
12871323
separator = "-"
12881324
}
12891325
if options.LineNumbers {
1290-
if options.ColumnNumbers && match.Matched && match.Column > 0 {
1326+
if (options.ColumnNumbers || options.Vimgrep) && match.Matched && match.Column > 0 {
12911327
lines = append(lines, fmt.Sprintf("%s%s%d%s%d%s%s", match.Path, separator, match.Line, separator, match.Column, separator, match.Text))
12921328
continue
12931329
}
12941330
lines = append(lines, fmt.Sprintf("%s%s%d%s%s", match.Path, separator, match.Line, separator, match.Text))
12951331
} else {
1332+
if options.Vimgrep && match.Matched && match.Column > 0 {
1333+
lines = append(lines, fmt.Sprintf("%s%s%d%s%s", match.Path, separator, match.Column, separator, match.Text))
1334+
continue
1335+
}
12961336
lines = append(lines, fmt.Sprintf("%s%s%s", match.Path, separator, match.Text))
12971337
}
12981338
}
@@ -1665,6 +1705,10 @@ func grepOnlyMatching(input grepInput) bool {
16651705
input.ShortOnlyMatching
16661706
}
16671707

1708+
func grepVimgrep(input grepInput) bool {
1709+
return input.Vimgrep || input.LongVimgrep
1710+
}
1711+
16681712
func grepPassthru(input grepInput) bool {
16691713
return input.Passthru ||
16701714
input.Passthrough ||

internal/tools/file/tools_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1951,6 +1951,64 @@ func TestGrepToolColumnNumbers(t *testing.T) {
19511951
}
19521952
}
19531953

1954+
func TestGrepToolVimgrep(t *testing.T) {
1955+
dir := t.TempDir()
1956+
content := strings.Join([]string{
1957+
"before",
1958+
"Needle Needle",
1959+
"after",
1960+
"Needle",
1961+
}, "\n")
1962+
if err := os.WriteFile(filepath.Join(dir, "hits.txt"), []byte(content), 0o644); err != nil {
1963+
t.Fatal(err)
1964+
}
1965+
executor := fileExecutor(t)
1966+
ctx := fileToolContext(dir)
1967+
1968+
result, err := executor.Execute(ctx, contracts.ToolUse{
1969+
ID: "toolu_grep_vimgrep",
1970+
Name: "Grep",
1971+
Input: json.RawMessage(`{"pattern":"Needle","output_mode":"content","--vimgrep":"true","context":1}`),
1972+
}, nil)
1973+
if err != nil {
1974+
t.Fatal(err)
1975+
}
1976+
want := "hits.txt-1-before\nhits.txt:2:1:Needle Needle\nhits.txt:2:8:Needle Needle\nhits.txt-3-after\nhits.txt:4:1:Needle"
1977+
if result.Content != want || result.StructuredContent["vimgrep"] != true {
1978+
t.Fatalf("vimgrep result = %#v", result)
1979+
}
1980+
matches := result.StructuredContent["matches"].([]map[string]any)
1981+
if len(matches) != 5 || matches[1]["column"] != 1 || matches[2]["column"] != 8 || matches[3]["matched"] != false {
1982+
t.Fatalf("vimgrep structured matches = %#v", matches)
1983+
}
1984+
1985+
noLineResult, err := executor.Execute(ctx, contracts.ToolUse{
1986+
ID: "toolu_grep_vimgrep_no_line",
1987+
Name: "Grep",
1988+
Input: json.RawMessage(`{"pattern":"Needle","output_mode":"content","vimgrep":true,"-N":true,"head_limit":2}`),
1989+
}, nil)
1990+
if err != nil {
1991+
t.Fatal(err)
1992+
}
1993+
wantNoLine := "hits.txt:1:Needle Needle\nhits.txt:8:Needle Needle\n\n[Showing results with pagination = limit: 2]"
1994+
if noLineResult.Content != wantNoLine || noLineResult.StructuredContent["line_numbers"] != false {
1995+
t.Fatalf("vimgrep no-line result = %#v", noLineResult)
1996+
}
1997+
1998+
onlyResult, err := executor.Execute(ctx, contracts.ToolUse{
1999+
ID: "toolu_grep_vimgrep_only_matching",
2000+
Name: "Grep",
2001+
Input: json.RawMessage(`{"pattern":"Needle","output_mode":"content","--vimgrep":true,"only_matching":true,"head_limit":2}`),
2002+
}, nil)
2003+
if err != nil {
2004+
t.Fatal(err)
2005+
}
2006+
wantOnly := "hits.txt:2:1:Needle\nhits.txt:2:8:Needle\n\n[Showing results with pagination = limit: 2]"
2007+
if onlyResult.Content != wantOnly || onlyResult.StructuredContent["vimgrep"] != true || onlyResult.StructuredContent["only_matching"] != true {
2008+
t.Fatalf("vimgrep only-matching result = %#v", onlyResult)
2009+
}
2010+
}
2011+
19542012
func TestGrepToolTrim(t *testing.T) {
19552013
dir := t.TempDir()
19562014
content := strings.Join([]string{

0 commit comments

Comments
 (0)