Skip to content

Commit e7a46b0

Browse files
author
SqlRush
committed
Add Grep text binary override
1 parent 9006b7d commit e7a46b0

5 files changed

Lines changed: 82 additions & 3 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ test/parity/ # golden tests against TS/official behavior
207207
- 本轮补充:Grep 文件列表输出现在显式支持 `files_with_match(es)`/`filesWithMatch(es)`/`files-with-match(es)`/`--files-with-match(es)`/`-l`,并接受 `output_mode``files_with_match` alias,统一归一为 `files_with_matches`
208208
- 本轮补充:Grep 路径过滤现在支持 ripgrep 风格 `--glob`/`-g``--type`/`-t` aliases,执行和 structured content 都统一使用归一化后的 glob/type 过滤值。
209209
- 本轮补充:Grep `glob`/`--glob`/`-g` 路径过滤现在支持 ripgrep 风格 `!pattern` 排除规则,可与正向 glob、逗号/空白多 pattern 和 brace alternation 组合;只有排除规则时默认包含未被排除的路径。
210+
- 本轮补充:Grep 搜索现在支持 ripgrep 风格 `text`/`--text`/`-a`,可显式把二进制扩展名文件按文本读取参与匹配,structured content 会回传 `text` 状态;`-a` 同样兼容 quoted semantic boolean。
210211
- 本轮补充:Grep 常用布尔参数继续补齐 ripgrep 长参数 aliases,覆盖 `--line-number``--ignore-case``--fixed-strings``--word-regexp``--invert-match``--only-matching`,并兼容 quoted semantic boolean。
211212
- 本轮补充:Grep multiline 搜索现在支持 ripgrep 风格 `-U``--multiline``multiline-dotall``--multiline-dotall` aliases,统一映射到既有跨行 dotall 匹配逻辑并兼容 quoted semantic boolean。
212213
- 本轮补充: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
@@ -134,6 +134,7 @@ Covered behavior:
134134
- `Grep` files-with-matches output now mirrors the official modified-time sort: newest files first, path tie-breaker, and pagination applied after sorting.
135135
- `Grep` now accepts ripgrep-style result ordering through `sort`/`--sort` and `sortr`/`--sortr`, covering path, modified-time, and none ordering for files/content/count modes with structured sort metadata.
136136
- `Grep` glob filtering now accepts ripgrep-style negated `!pattern` rules, including combinations with positive patterns, comma/whitespace splitting, and brace-expanded globs.
137+
- `Grep` now accepts `text`/`--text`/`-a` to search binary-extension files as text while preserving the default binary-extension skip behavior.
137138
- `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.
138139
- `Read`/`Edit` now coerce quoted semantic strings for `offset`/`limit` and `replace_all`, including whole-decimal numeric strings such as `"2.0"` for integer fields while keeping fractional values rejected.
139140
- `Bash`/`BashOutput` now coerce quoted semantic strings for `timeout`, `run_in_background`/`runInBackground`, and `tail_lines`/`tailLines`, matching official SDK-style number/boolean inputs without relaxing unknown-field validation.

internal/tools/file/helpers.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,15 @@ func readText(path string) (string, error) {
8181
return string(data), nil
8282
}
8383

84+
func readTextAllowBinary(path string) (string, error) {
85+
data, err := os.ReadFile(path)
86+
if err != nil {
87+
return "", err
88+
}
89+
data = bytes.TrimPrefix(data, []byte{0xef, 0xbb, 0xbf})
90+
return string(data), nil
91+
}
92+
8493
func readTextForEdit(path string) (content string, existed bool, crlf bool, mode os.FileMode, err error) {
8594
info, statErr := os.Stat(path)
8695
if statErr != nil {

internal/tools/file/search_tools.go

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ var allowedGrepInputKeys = map[string]struct{}{
3939
"case_sensitive": {}, "caseSensitive": {}, "case-sensitive": {}, "--case-sensitive": {}, "-s": {},
4040
"smart_case": {}, "smartCase": {}, "smart-case": {}, "--smart-case": {}, "-S": {},
4141
"fixed_strings": {}, "fixedStrings": {}, "fixed-strings": {}, "--fixed-strings": {}, "-F": {}, "multiline": {}, "--multiline": {}, "multiline-dotall": {}, "--multiline-dotall": {}, "-U": {},
42+
"text": {}, "--text": {}, "-a": {},
4243
"word_regexp": {}, "wordRegexp": {}, "word-regexp": {}, "--word-regexp": {}, "-w": {},
4344
"invert_match": {}, "invertMatch": {}, "invert-match": {}, "--invert-match": {}, "-v": {},
4445
"only_matching": {}, "onlyMatching": {}, "only-matching": {}, "--only-matching": {}, "-o": {},
@@ -63,6 +64,7 @@ var grepSemanticBooleanKeys = map[string]struct{}{
6364
"case_sensitive": {}, "caseSensitive": {}, "case-sensitive": {}, "--case-sensitive": {}, "-s": {},
6465
"smart_case": {}, "smartCase": {}, "smart-case": {}, "--smart-case": {}, "-S": {},
6566
"fixed_strings": {}, "fixedStrings": {}, "fixed-strings": {}, "--fixed-strings": {}, "-F": {}, "multiline": {}, "--multiline": {}, "multiline-dotall": {}, "--multiline-dotall": {}, "-U": {},
67+
"text": {}, "--text": {}, "-a": {},
6668
"word_regexp": {}, "wordRegexp": {}, "word-regexp": {}, "--word-regexp": {}, "-w": {},
6769
"invert_match": {}, "invertMatch": {}, "invert-match": {}, "--invert-match": {}, "-v": {},
6870
"only_matching": {}, "onlyMatching": {}, "only-matching": {}, "--only-matching": {}, "-o": {},
@@ -155,6 +157,9 @@ type grepInput struct {
155157
FixedStringsDash bool `json:"fixed-strings,omitempty"`
156158
LongFixedStrings bool `json:"--fixed-strings,omitempty"`
157159
ShortFixedStrings bool `json:"-F,omitempty"`
160+
Text bool `json:"text,omitempty"`
161+
LongText bool `json:"--text,omitempty"`
162+
ShortText bool `json:"-a,omitempty"`
158163
WordRegexp bool `json:"word_regexp,omitempty"`
159164
WordRegexpAlt bool `json:"wordRegexp,omitempty"`
160165
WordRegexpDash bool `json:"word-regexp,omitempty"`
@@ -236,6 +241,7 @@ type grepOptions struct {
236241
OnlyMatching bool
237242
CountMatches bool
238243
ColumnNumbers bool
244+
Text bool
239245
SortMode string
240246
SortReverse bool
241247
SortExplicit bool
@@ -372,6 +378,9 @@ func NewGrepTool() tool.Tool {
372378
"fixed-strings": map[string]any{"type": "boolean"},
373379
"--fixed-strings": map[string]any{"type": "boolean"},
374380
"-F": map[string]any{"type": "boolean"},
381+
"text": map[string]any{"type": "boolean"},
382+
"--text": map[string]any{"type": "boolean"},
383+
"-a": map[string]any{"type": "boolean"},
375384
"word_regexp": map[string]any{"type": "boolean"},
376385
"wordRegexp": map[string]any{"type": "boolean"},
377386
"word-regexp": map[string]any{"type": "boolean"},
@@ -453,7 +462,7 @@ func NewGrepTool() tool.Tool {
453462
},
454463
},
455464
PromptFunc: func(tool.PromptContext) (string, error) {
456-
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, and only_matching/-o/--only-matching matched-text output. 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, word_regexp/-w/--word-regexp for whole-word 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
465+
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, and only_matching/-o/--only-matching matched-text output. 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, 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
457466
},
458467
NormalizeFunc: normalizeGrepRawInput,
459468
ValidateFunc: validateGrep,
@@ -629,6 +638,7 @@ func callGrep(ctx tool.Context, raw json.RawMessage, _ tool.ProgressSink) (contr
629638
OnlyMatching: onlyMatching,
630639
CountMatches: countMatches,
631640
ColumnNumbers: grepColumnNumbers(input),
641+
Text: grepText(input),
632642
SortMode: sortMode,
633643
SortReverse: sortReverse,
634644
SortExplicit: sortExplicit,
@@ -667,6 +677,7 @@ func callGrep(ctx tool.Context, raw json.RawMessage, _ tool.ProgressSink) (contr
667677
"case_sensitive": grepCaseSensitive(input),
668678
"smart_case": grepSmartCase(input),
669679
"fixed_strings": grepFixedStrings(input),
680+
"text": options.Text,
670681
"word_regexp": grepWordRegexp(input),
671682
"invert_match": grepInvertMatch(input),
672683
"only_matching": onlyMatching,
@@ -843,10 +854,10 @@ func collectGrepMatches(root string, displayRoot string, glob string, typeFilter
843854
if len(typeExtensions) > 0 && !grepTypeMatches(path, typeExtensions) {
844855
return nil
845856
}
846-
if hasBinaryExtension(path) {
857+
if hasBinaryExtension(path) && !options.Text {
847858
return nil
848859
}
849-
content, err := readText(path)
860+
content, err := readGrepText(path, options.Text)
850861
if err != nil {
851862
return nil
852863
}
@@ -936,6 +947,13 @@ func globBaseDirectory(pattern string) (string, string) {
936947
return filepath.Clean(base), pattern[lastSep+1:]
937948
}
938949

950+
func readGrepText(path string, allowBinary bool) (string, error) {
951+
if allowBinary {
952+
return readTextAllowBinary(path)
953+
}
954+
return readText(path)
955+
}
956+
939957
func grepFileMatches(path string, content string, expr *regexp.Regexp, options grepOptions) []grepMatch {
940958
content = normalizeGrepContent(content)
941959
lines := strings.Split(content, "\n")
@@ -1510,6 +1528,10 @@ func grepFixedStrings(input grepInput) bool {
15101528
input.ShortFixedStrings
15111529
}
15121530

1531+
func grepText(input grepInput) bool {
1532+
return input.Text || input.LongText || input.ShortText
1533+
}
1534+
15131535
func grepWordRegexp(input grepInput) bool {
15141536
return input.WordRegexp ||
15151537
input.WordRegexpAlt ||

internal/tools/file/tools_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1919,6 +1919,52 @@ func TestGrepToolColumnNumbers(t *testing.T) {
19191919
}
19201920
}
19211921

1922+
func TestGrepToolTextSearchesBinaryExtensionFiles(t *testing.T) {
1923+
dir := t.TempDir()
1924+
if err := os.WriteFile(filepath.Join(dir, "payload.bin"), []byte("Needle\x00inside\n"), 0o644); err != nil {
1925+
t.Fatal(err)
1926+
}
1927+
executor := fileExecutor(t)
1928+
ctx := fileToolContext(dir)
1929+
1930+
defaultResult, err := executor.Execute(ctx, contracts.ToolUse{
1931+
ID: "toolu_grep_binary_default",
1932+
Name: "Grep",
1933+
Input: json.RawMessage(`{"pattern":"Needle"}`),
1934+
}, nil)
1935+
if err != nil {
1936+
t.Fatal(err)
1937+
}
1938+
if defaultResult.Content != "No files found" || defaultResult.StructuredContent["text"] != false {
1939+
t.Fatalf("default binary result = %#v", defaultResult)
1940+
}
1941+
1942+
textResult, err := executor.Execute(ctx, contracts.ToolUse{
1943+
ID: "toolu_grep_binary_text",
1944+
Name: "Grep",
1945+
Input: json.RawMessage(`{"pattern":"Needle","--text":true}`),
1946+
}, nil)
1947+
if err != nil {
1948+
t.Fatal(err)
1949+
}
1950+
if textResult.Content != "Found 1 file\npayload.bin" || textResult.StructuredContent["text"] != true {
1951+
t.Fatalf("text binary result = %#v", textResult)
1952+
}
1953+
1954+
shortTextResult, err := executor.Execute(ctx, contracts.ToolUse{
1955+
ID: "toolu_grep_binary_short_text",
1956+
Name: "Grep",
1957+
Input: json.RawMessage(`{"pattern":"Needle","output_mode":"count","-a":"true"}`),
1958+
}, nil)
1959+
if err != nil {
1960+
t.Fatal(err)
1961+
}
1962+
wantShortText := "payload.bin:1\n\nFound 1 total occurrence across 1 file."
1963+
if shortTextResult.Content != wantShortText || shortTextResult.StructuredContent["text"] != true {
1964+
t.Fatalf("short text binary result = %#v", shortTextResult)
1965+
}
1966+
}
1967+
19221968
func TestGrepToolMaxColumnsOmission(t *testing.T) {
19231969
dir := t.TempDir()
19241970
longMatch := strings.Repeat("x", defaultGrepMaxColumns-len("Needle")) + "Needle"

0 commit comments

Comments
 (0)