diff --git a/internal/tools/glob_grep_test.go b/internal/tools/glob_grep_test.go index 528acfd..2034a32 100644 --- a/internal/tools/glob_grep_test.go +++ b/internal/tools/glob_grep_test.go @@ -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, }, { @@ -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) } diff --git a/internal/tools/grep.go b/internal/tools/grep.go index bdb2b35..f5aa4b7 100644 --- a/internal/tools/grep.go +++ b/internal/tools/grep.go @@ -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__)"` } @@ -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 } @@ -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 { @@ -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 @@ -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, diff --git a/internal/tools/grep_skips_test.go b/internal/tools/grep_skips_test.go index 0aebe81..e228d90 100644 --- a/internal/tools/grep_skips_test.go +++ b/internal/tools/grep_skips_test.go @@ -57,7 +57,7 @@ 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. @@ -65,19 +65,19 @@ func TestGrepHiddenAndIgnoredRows(t *testing.T) { }, { 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. @@ -85,7 +85,7 @@ func TestGrepHiddenAndIgnoredRows(t *testing.T) { }, { 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"}, @@ -93,7 +93,7 @@ func TestGrepHiddenAndIgnoredRows(t *testing.T) { }, { 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"}, @@ -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, }, @@ -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) } @@ -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() @@ -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}, @@ -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) } @@ -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) } diff --git a/internal/tools/grepopts_test.go b/internal/tools/grepopts_test.go index c998bbd..8dddeac 100644 --- a/internal/tools/grepopts_test.go +++ b/internal/tools/grepopts_test.go @@ -3,6 +3,7 @@ package tools import ( "context" "encoding/json" + "fmt" "os" "path/filepath" "strings" @@ -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) } @@ -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"}) @@ -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) }