Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions internal/tools/glob_grep_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,13 @@ func TestGrepOutputModes(t *testing.T) {
want string
}{
{
name: "files_with_matches is the default and lists each file once",
name: "content with line numbers is the default",
in: GrepInput{Pattern: "hello", Glob: "*.go"},
want: b + ":2:// hello again\n" + b + ":3:func hello() {}",
},
{
name: "files_with_matches lists each file once",
in: GrepInput{Pattern: "hello", Glob: "*.go", OutputMode: "files_with_matches"},
want: b,
},
{
Expand Down Expand Up @@ -150,7 +155,7 @@ func TestGrepOutputModes(t *testing.T) {
func TestGrepMultilineMatchesAcrossLines(t *testing.T) {
dir := searchTree(t)
g, _ := NewGrep()
res := runTool(t, g, Context{WorkingDir: dir}, GrepInput{Pattern: `package b.*func hello`, Multiline: true})
res := runTool(t, g, Context{WorkingDir: dir}, GrepInput{Pattern: `package b.*func hello`, Multiline: true, OutputMode: "files_with_matches"})
if res.Content != "b.go" { // relative to the working dir
t.Errorf("content = %q, want b.go (the pattern spans its lines)", res.Content)
}
Expand Down
41 changes: 28 additions & 13 deletions internal/tools/grep.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@ type GrepInput struct {
Pattern string `json:"pattern" jsonschema:"description=The regular expression pattern to search for in file contents"`
Path string `json:"path,omitempty" jsonschema:"description=File or directory to search in (defaults to cwd)"`
Glob string `json:"glob,omitempty" jsonschema:"description=Glob pattern to filter files (e.g. *.go)"`
OutputMode string `json:"output_mode,omitempty" jsonschema:"description=files_with_matches (the default; or content when -A/-B/-C/-n is given), content, or count"`
OutputMode string `json:"output_mode,omitempty" jsonschema:"description=content (the default: matching lines with line numbers), files_with_matches (paths only), or count (per-file counts). -A/-B/-C also select content"`
IgnoreCase bool `json:"-i,omitempty" jsonschema:"description=Case-insensitive search"`
LineNum bool `json:"-n,omitempty" jsonschema:"description=Show line numbers (content mode)"`
LineNum bool `json:"-n,omitempty" jsonschema:"description=Show line numbers (content mode; on by default)"`
Multiline bool `json:"multiline,omitempty" jsonschema:"description=Allow patterns to span lines (dot matches newline); content mode then shows each match with the line it starts on"`
Type string `json:"type,omitempty" jsonschema:"description=Only search files of this type: go, py, js, ts, rust, java, c, cpp, rb, php, sh, md, json, yaml, toml, html, css, sql, swift, kotlin"`
After int `json:"-A,omitempty" jsonschema:"description=Lines of context after each match (content mode)"`
Before int `json:"-B,omitempty" jsonschema:"description=Lines of context before each match (content mode)"`
Context int `json:"-C,omitempty" jsonschema:"description=Lines of context before and after each match (content mode)"`
HeadLimit int `json:"head_limit,omitempty" jsonschema:"description=Return at most this many lines/files/counts; the rest are counted, not shown"`
HeadLimit int `json:"head_limit,omitempty" jsonschema:"description=Return at most this many lines/files/counts; the rest are counted, not shown. Defaults to 200 in content mode when omitted"`
Hidden bool `json:"hidden,omitempty" jsonschema:"description=Also search hidden (dot) files and directories such as .github"`
NoIgnore bool `json:"no_ignore,omitempty" jsonschema:"description=Also search paths excluded by .gitignore/.ignore and the default skip list (node_modules, vendor, __pycache__)"`
}
Expand Down Expand Up @@ -74,11 +74,12 @@ func (g *Grep) Description(context.Context) (string, error) {
"are never searched from above, even when named in glob; pass one as path to search it (that asks first). " +
"\"No matches found\" names what was skipped, so it means \"not found where I looked\", not \"not in the repo\"; " +
"with matches, a note names only skipped directories holding files the glob or type would have searched.\n" +
"Output: output_mode is \"files_with_matches\" by default (matching file paths only), " +
"\"content\" (matching lines) or \"count\" (per-file counts). Giving -A/-B/-C (context lines) or -n " +
"(line numbers) without output_mode selects content. Context lines use '-' where matches use ':'.\n" +
"Output: output_mode is \"content\" by default (matching lines with line numbers, capped at 200 " +
"with the remainder counted). Pass \"files_with_matches\" for paths only, or \"count\" for per-file counts. " +
"Giving -A/-B/-C (context lines) without output_mode also selects content. " +
"Context lines use '-' where matches use ':'.\n" +
"Filter files with glob or type (e.g. type=go); ignore case with -i. head_limit caps how many results " +
"are shown; the rest are counted. A relative path is taken from the working directory.", nil
"are shown (200 when omitted in content mode); the rest are counted. A relative path is taken from the working directory.", nil
}

func (g *Grep) InputSchema() json.RawMessage { return g.schema.Raw }
Expand Down Expand Up @@ -121,6 +122,11 @@ func (g *Grep) CheckPermissions(pctx permission.Context, _ permission.Permission
// maxSearchResults bounds what Grep and Glob collect before formatting.
const maxSearchResults = 20000

// defaultContentHeadLimit caps a content-mode search that names no head_limit,
// so a pattern-only call returns lines rather than an unbounded dump, and says
// how many it left out.
const defaultContentHeadLimit = 200

func (g *Grep) Execute(ctx context.Context, tctx Context, raw json.RawMessage) ([]Result, error) {
var in GrepInput
if err := json.Unmarshal(raw, &in); err != nil {
Expand All @@ -136,15 +142,24 @@ func (g *Grep) Execute(ctx context.Context, tctx Context, raw json.RawMessage) (
if in.Context > 0 {
before, after = max(before, in.Context), max(after, in.Context)
}
// Asking for context or line numbers is asking to see lines. Without this,
// -A with no mode returned bare file paths and the context was dropped in
// silence.
// Content is the default: a caller that names only a pattern wants the
// lines, with numbers, capped. Context flags select content too, so -A
// with no mode never returns bare paths and drops the context.
// files_with_matches and count stay explicit and uncapped.
mode := in.OutputMode
if mode == "" && (in.After > 0 || in.Before > 0 || in.Context > 0 || in.LineNum) {
lineNum := in.LineNum
if mode == "" {
mode = "content"
lineNum = true
}
if mode != "content" {
before, after = 0, 0 // context only means something where lines are shown
} else if before > 0 || after > 0 {
lineNum = true // context lines are unreadable without numbers
}
limit := in.HeadLimit
if mode == "content" && limit == 0 {
limit = defaultContentHeadLimit
}

hidden := 0
Expand Down Expand Up @@ -187,13 +202,13 @@ func (g *Grep) Execute(ctx context.Context, tctx Context, raw json.RawMessage) (
var out string
switch mode {
case "content":
out = formatContent(matches, in.LineNum, before > 0 || after > 0)
out = formatContent(matches, lineNum, before > 0 || after > 0)
case "count":
out = formatCount(matches)
default: // files_with_matches
out = formatFiles(matches)
}
return []Result{CapResult(Result{Content: headLimit(out, in.HeadLimit) + note})}, nil
return []Result{CapResult(Result{Content: headLimit(out, limit) + note})}, nil
}

// headLimit keeps the first n lines of out and says how many were left out,
Expand Down
31 changes: 16 additions & 15 deletions internal/tools/grep_skips_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,43 +57,43 @@ func TestGrepHiddenAndIgnoredRows(t *testing.T) {
},
{
name: "a glob naming .github finds it",
in: GrepInput{Pattern: "needle-ci", Glob: ".github/**/*.yml"},
in: GrepInput{Pattern: "needle-ci", Glob: ".github/**/*.yml", OutputMode: "files_with_matches"},
found: filepath.Join(".github", "workflows", "ci.yml"),
// The glob could never match in target/ or .vscode/, so leaving
// them out is not a gap worth reporting.
quiet: true,
},
{
name: "a path inside .github finds it",
in: GrepInput{Pattern: "needle-ci", Path: ".github"},
in: GrepInput{Pattern: "needle-ci", Path: ".github", OutputMode: "files_with_matches"},
found: filepath.Join(".github", "workflows", "ci.yml"),
quiet: true,
},
{
name: "hidden: true finds it from the root",
in: GrepInput{Pattern: "needle-ci", Hidden: true},
in: GrepInput{Pattern: "needle-ci", Hidden: true, OutputMode: "files_with_matches"},
found: filepath.Join(".github", "workflows", "ci.yml"),
quiet: true,
},
{
name: "an untracked file is searched",
in: GrepInput{Pattern: "needle-untracked"},
in: GrepInput{Pattern: "needle-untracked", OutputMode: "files_with_matches"},
found: "notes.txt",
// Matches, and no glob or type to tell which skipped directory
// could have held more: a note here would be on every search.
quiet: true,
},
{
name: "with matches, a glob names only the skipped dirs holding files it would visit",
in: GrepInput{Pattern: "needle", Glob: "**/*.yml"},
in: GrepInput{Pattern: "needle", Glob: "**/*.yml", OutputMode: "files_with_matches"},
found: filepath.Join("deploy", "app.yml"),
// .github holds a .yml; .vscode and target do not.
note: []string{"hidden: .github/", "hidden: true"},
absent: []string{".vscode", "target", "no_ignore", "hidden file"},
},
{
name: "with matches, a type filter works the same way",
in: GrepInput{Pattern: "needle", Type: "yaml"},
in: GrepInput{Pattern: "needle", Type: "yaml", OutputMode: "files_with_matches"},
found: filepath.Join("deploy", "app.yml"),
note: []string{"hidden: .github/"},
absent: []string{".vscode", "target"},
Expand All @@ -105,7 +105,7 @@ func TestGrepHiddenAndIgnoredRows(t *testing.T) {
},
{
name: "no_ignore: true searches it",
in: GrepInput{Pattern: "needle-built", NoIgnore: true},
in: GrepInput{Pattern: "needle-built", NoIgnore: true, OutputMode: "files_with_matches"},
found: filepath.Join("target", "out.txt"),
quiet: true,
},
Expand Down Expand Up @@ -148,7 +148,7 @@ func TestGrepHiddenAndIgnoredRows(t *testing.T) {
func TestGrepSkipNoteCountsFilesOnlyWhenEmpty(t *testing.T) {
dir := skipRepo(t)
g, _ := NewGrep()
hit := runTool(t, g, Context{WorkingDir: dir}, GrepInput{Pattern: "needle", Glob: "**/*.yml"})
hit := runTool(t, g, Context{WorkingDir: dir}, GrepInput{Pattern: "needle", Glob: "**/*.yml", OutputMode: "files_with_matches"})
if strings.Contains(hit.Content, "hidden file") {
t.Errorf("a result with matches counted skipped dotfiles:\n%s", hit.Content)
}
Expand All @@ -158,8 +158,9 @@ func TestGrepSkipNoteCountsFilesOnlyWhenEmpty(t *testing.T) {
}
}

// Asking for context or line numbers is asking to see lines. -A with no
// output_mode used to return bare paths and drop the context in silence.
// Asking for context is asking to see lines. -A with no output_mode used to
// return bare paths and drop the context in silence; content is now the
// default, so the flags only add context, and an explicit mode still wins.
func TestGrepContextFlagsSelectContentMode(t *testing.T) {
dir := skipRepo(t)
g, _ := NewGrep()
Expand All @@ -169,9 +170,9 @@ func TestGrepContextFlagsSelectContentMode(t *testing.T) {
in GrepInput
want string
}{
{"-A", GrepInput{Pattern: "test:", Hidden: true, After: 1}, ci + ": test:\n" + ci + "- run: make needle-ci"},
{"-B", GrepInput{Pattern: "test:", Hidden: true, Before: 1}, ci + "-jobs:\n" + ci + ": test:"},
{"-C", GrepInput{Pattern: "test:", Hidden: true, Context: 1}, ci + "-jobs:\n" + ci + ": test:\n" + ci + "- run: make needle-ci"},
{"-A", GrepInput{Pattern: "test:", Hidden: true, After: 1}, ci + ":2: test:\n" + ci + "-3- run: make needle-ci"},
{"-B", GrepInput{Pattern: "test:", Hidden: true, Before: 1}, ci + "-1-jobs:\n" + ci + ":2: test:"},
{"-C", GrepInput{Pattern: "test:", Hidden: true, Context: 1}, ci + "-1-jobs:\n" + ci + ":2: test:\n" + ci + "-3- run: make needle-ci"},
{"-n", GrepInput{Pattern: "test:", Hidden: true, LineNum: true}, ci + ":2: test:"},
// An explicit mode still wins: the caller asked for paths.
{"explicit mode", GrepInput{Pattern: "test:", Hidden: true, After: 1, OutputMode: "files_with_matches"}, ci},
Expand Down Expand Up @@ -215,7 +216,7 @@ func TestGlobHiddenAndIgnoredInputs(t *testing.T) {
func TestGrepDescriptionStatesDefaultsAndSkips(t *testing.T) {
g, _ := NewGrep()
desc, _ := g.Description(t.Context())
for _, want := range []string{"NOT searched", "hidden: true", "no_ignore: true", "files_with_matches\" by default", "selects content"} {
for _, want := range []string{"NOT searched", "hidden: true", "no_ignore: true", "content\" by default", "files_with_matches"} {
if !strings.Contains(desc, want) {
t.Errorf("description does not say %q:\n%s", want, desc)
}
Expand Down Expand Up @@ -268,7 +269,7 @@ func TestGrepNeverWalksIntoCredentials(t *testing.T) {

// Aimed at the location itself, the search runs — behind the prompt the
// root check raises.
res = runTool(t, g, tctx, GrepInput{Pattern: "needle-secret", Path: filepath.Join(home, ".aws")})
res = runTool(t, g, tctx, GrepInput{Pattern: "needle-secret", Path: filepath.Join(home, ".aws"), OutputMode: "files_with_matches"})
if first, _, _ := strings.Cut(res.Content, "\n"); first != filepath.Join(".aws", "credentials") {
t.Errorf("a search rooted in .aws should search it:\n%s", res.Content)
}
Expand Down
40 changes: 38 additions & 2 deletions internal/tools/grepopts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package tools
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -70,7 +71,7 @@ func TestGrepContextOverlapKeepsEachLineOnce(t *testing.T) {
func TestGrepTypeFilter(t *testing.T) {
dir := t.TempDir()
writeFiles(t, dir, map[string]string{"a.go": "needle\n", "b.py": "needle\n", "c.tsx": "needle\n"})
out, _ := runGrep(t, dir, map[string]any{"pattern": "needle", "type": "ts"})
out, _ := runGrep(t, dir, map[string]any{"pattern": "needle", "type": "ts", "output_mode": "files_with_matches"})
if !strings.Contains(out, "c.tsx") || strings.Contains(out, "a.go") || strings.Contains(out, "b.py") {
t.Errorf("type=ts should keep only the .tsx file:\n%s", out)
}
Expand All @@ -79,6 +80,41 @@ func TestGrepTypeFilter(t *testing.T) {
}
}

func TestGrepDefaultIsContentWithLineNumbersAndCap(t *testing.T) {
dir := t.TempDir()
var body strings.Builder
for i := 1; i <= defaultContentHeadLimit+5; i++ {
fmt.Fprintf(&body, "hit %d\n", i)
}
writeFiles(t, dir, map[string]string{"a.txt": body.String()})

out, err := runGrep(t, dir, map[string]any{"pattern": "hit"})
if err != nil {
t.Fatal(err)
}
lines := strings.Split(out, "\n")
// 200 matching lines plus the cap notice.
if len(lines) != defaultContentHeadLimit+1 {
t.Fatalf("got %d lines, want %d plus the cap notice", len(lines), defaultContentHeadLimit)
}
if lines[0] != "a.txt:1:hit 1" || lines[defaultContentHeadLimit-1] != fmt.Sprintf("a.txt:%d:hit %d", defaultContentHeadLimit, defaultContentHeadLimit) {
t.Errorf("default content should carry line numbers, got first %q last %q", lines[0], lines[defaultContentHeadLimit-1])
}
want := fmt.Sprintf("showing %d of %d lines", defaultContentHeadLimit, defaultContentHeadLimit+5)
if !strings.Contains(lines[defaultContentHeadLimit], want) {
t.Errorf("cap notice = %q, want it to contain %q", lines[defaultContentHeadLimit], want)
}

// An explicit mode is uncapped and returns no lines.
paths, err := runGrep(t, dir, map[string]any{"pattern": "hit", "output_mode": "files_with_matches"})
if err != nil {
t.Fatal(err)
}
if paths != "a.txt" {
t.Errorf("files_with_matches = %q, want the path only", paths)
}
}

func TestGrepHeadLimit(t *testing.T) {
dir := t.TempDir()
writeFiles(t, dir, map[string]string{"a.txt": "hit 1\nhit 2\nhit 3\nhit 4\n"})
Expand All @@ -101,7 +137,7 @@ func TestGrepMultilineShowsMatchAndLine(t *testing.T) {
func TestGrepAndGlobResolveRelativePath(t *testing.T) {
dir := t.TempDir()
writeFiles(t, dir, map[string]string{"sub/a.txt": "needle\n"})
out, _ := runGrep(t, dir, map[string]any{"pattern": "needle", "path": "sub"})
out, _ := runGrep(t, dir, map[string]any{"pattern": "needle", "path": "sub", "output_mode": "files_with_matches"})
if !strings.Contains(out, filepath.Join("sub", "a.txt")) { // relative to the working dir
t.Errorf("Grep path=sub should search the working dir's sub:\n%s", out)
}
Expand Down
Loading