Skip to content
Open
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
15 changes: 14 additions & 1 deletion src/hooks/grep-direct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,22 @@ export function parseBashGrep(cmd: string): GrepParams | null {
ti++;
}

const pattern = explicitPatterns.length > 0 ? explicitPatterns[0] : tokens[ti];
let pattern = explicitPatterns.length > 0 ? explicitPatterns[0] : tokens[ti];
if (!pattern) return null;

// grep treats repeated -e/--regexp options as alternative patterns. Keep
// them together for the downstream line matcher, which accepts one regex.
// Fixed-string patterns must be escaped before building the alternation.
if (explicitPatterns.length > 1) {
const alternatives = explicitPatterns.map((p) =>
fixedString ? p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : p,
);
// A bare alternation preserves grep's per-pattern OR semantics and keeps
// the alternatives visible to grep-core's SQL prefilter extractor.
pattern = alternatives.join("|");
fixedString = false;
}

let target = explicitPatterns.length > 0 ? (tokens[ti] ?? "/") : (tokens[ti + 1] ?? "/");
if (target === "." || target === "./") target = "/";

Expand Down
11 changes: 6 additions & 5 deletions src/shell/grep-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -614,11 +614,12 @@ export function extractRegexAlternationPrefilters(pattern: string): string[] | n
if (escaped || !current) return null;
parts.push(current);

const literals = [...new Set(
parts
.map((part) => extractRegexLiteralPrefilter(part))
.filter((part): part is string => typeof part === "string" && part.length >= 2),
)];
const branchLiterals = parts.map((part) => extractRegexLiteralPrefilter(part));
// An OR prefilter must retain at least one safe anchor from every branch.
// Dropping an unanchored branch would turn it into a false-negative filter.
if (branchLiterals.some((literal) => literal === null)) return null;

const literals = [...new Set(branchLiterals.filter((part): part is string => part !== null))];
return literals.length > 0 ? literals : null;
}

Expand Down
4 changes: 4 additions & 0 deletions tests/claude-code/grep-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1010,6 +1010,10 @@ describe("regex literal prefilter", () => {
expect(extractRegexAlternationPrefilters(".|.|.")).toBeNull();
});

it("returns null when any branch has no safe literal anchor", () => {
expect(extractRegexAlternationPrefilters("apple|\\d+")).toBeNull();
});

it("returns null when input has no alternation pipe", () => {
expect(extractRegexAlternationPrefilters("foobar")).toBeNull();
});
Expand Down
27 changes: 27 additions & 0 deletions tests/claude-code/grep-direct.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,33 @@ describe("parseBashGrep", () => {
});
});

describe("parseBashGrep: repeated patterns", () => {
it("matches lines for every -e expression", async () => {
const parsed = parseBashGrep("grep -e apple -e banana /notes");
expect(parsed).not.toBeNull();
const api = {
query: vi.fn().mockResolvedValueOnce([{ path: "/notes/a.md", content: "apple pie\nbanana bread" }]),
} as any;
const result = await handleGrepDirect(api, "memory", "sessions", parsed!);
expect(result).toBe("apple pie\nbanana bread");
const sql = api.query.mock.calls[0][0] as string;
expect(sql).toContain("summary::text ILIKE '%apple%'");
expect(sql).toContain("summary::text ILIKE '%banana%'");
expect(sql).toContain("message::text ILIKE '%apple%'");
expect(sql).toContain("message::text ILIKE '%banana%'");
});

it("keeps repeated -F expressions literal", async () => {
const parsed = parseBashGrep("grep -F -e a.b -e banana /notes");
expect(parsed).not.toBeNull();
const api = {
query: vi.fn().mockResolvedValueOnce([{ path: "/notes/a.md", content: "a.b\naxb\nbanana" }]),
} as any;
const result = await handleGrepDirect(api, "memory", "sessions", parsed!);
expect(result).toBe("a.b\nbanana");
});
});

// ─── rg (ripgrep) ──────────────────────────────────────────────────────────
//
// Modern coding agents reach for `rg` by default for directory searches.
Expand Down