src / workspace / search.ts
src / workspace / search.ts
import { readFile, stat } from "node:fs/promises";
import { AgenticError } from "../core/errors";
import type { WorkspaceBoundary } from "./boundary";
import { appearsBinary } from "./text";
import { walkWorkspace } from "./walk";
export interface SearchOptions {
query: string;
regex?: boolean;
caseSensitive?: boolean;
path?: string;
maxResults: number;
maxFiles: number;
maxFileBytes: number;
contextLines?: number;
}
export interface SearchMatch {
path: string;
line: number;
column: number;
text: string;
before?: string[];
after?: string[];
}
function suspiciousRegex(pattern: string): boolean {
if (pattern.length > 200) return true;
// JavaScript RegExp has no execution timeout. Keep model-supplied regexes to
// a deliberately conservative subset rather than risking event-loop stalls.
if (/\\[1-9]/.test(pattern)) return true; // backreferences
if (/(^|[^\\])\((?:\\.|[^)])*\)[+*{]/.test(pattern)) return true; // quantified groups
if (/\.[+*](?:\\.|.)*\.[+*]/.test(pattern)) return true; // repeated wildcards
for (const match of pattern.matchAll(/\{(\d+)(?:,(\d*)?)?\}/g)) {
const minimum = Number(match[1]);
const maximum = match[2] === undefined || match[2] === "" ? minimum : Number(match[2]);
if (minimum > 1000 || maximum > 1000) return true;
}
return false;
}
function createMatcher(options: SearchOptions): RegExp {
const flags = options.caseSensitive ? "g" : "gi";
if (options.regex) {
if (suspiciousRegex(options.query)) {
throw new AgenticError(
"INVALID_INPUT",
"Regex rejected as too complex. Use a literal search or simplify the expression.",
);
}
try {
return new RegExp(options.query, flags);
} catch (error) {
throw new AgenticError("INVALID_INPUT", `Invalid regular expression: ${String(error)}`);
}
}
const escaped = options.query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(escaped, flags);
}
export async function searchWorkspace(
boundary: WorkspaceBoundary,
options: SearchOptions,
): Promise<{
matches: SearchMatch[];
filesScanned: number;
filesSkipped: number;
truncated: boolean;
}> {
if (!options.query) {
throw new AgenticError("INVALID_INPUT", "Search query may not be empty.");
}
const matcher = createMatcher(options);
const listing = await walkWorkspace(boundary, options.path ?? ".", {
maxDepth: 30,
maxEntries: options.maxFiles * 4,
includeHidden: false,
});
const files = listing.entries.filter((entry) => entry.type === "file");
const matches: SearchMatch[] = [];
let filesScanned = 0;
let filesSkipped = 0;
const context = Math.max(0, Math.min(options.contextLines ?? 0, 3));
for (const file of files) {
if (filesScanned >= options.maxFiles || matches.length >= options.maxResults) break;
const absolute = await boundary.resolveRead(file.path);
const info = await stat(absolute);
if (info.size > options.maxFileBytes) {
filesSkipped++;
continue;
}
const buffer = await readFile(absolute);
if (appearsBinary(buffer)) {
filesSkipped++;
continue;
}
const content = buffer.toString("utf8");
if (!Buffer.from(content, "utf8").equals(buffer)) {
filesSkipped++;
continue;
}
filesScanned++;
const lines = content.split(/\r?\n/);
for (let index = 0; index < lines.length; index++) {
matcher.lastIndex = 0;
while (matches.length < options.maxResults) {
const found = matcher.exec(lines[index]);
if (!found) break;
matches.push({
path: file.path,
line: index + 1,
column: found.index + 1,
text: lines[index].slice(0, 500),
...(context > 0
? {
before: lines.slice(Math.max(0, index - context), index),
after: lines.slice(index + 1, index + 1 + context),
}
: {}),
});
// JavaScript global regexes do not advance after an empty match.
if (found[0].length === 0) matcher.lastIndex = found.index + 1;
}
if (matches.length >= options.maxResults) break;
}
}
return {
matches,
filesScanned,
filesSkipped,
truncated:
listing.truncated ||
filesScanned >= options.maxFiles ||
matches.length >= options.maxResults,
};
}
import { readFile, stat } from "node:fs/promises";
import { AgenticError } from "../core/errors";
import type { WorkspaceBoundary } from "./boundary";
import { appearsBinary } from "./text";
import { walkWorkspace } from "./walk";
export interface SearchOptions {
query: string;
regex?: boolean;
caseSensitive?: boolean;
path?: string;
maxResults: number;
maxFiles: number;
maxFileBytes: number;
contextLines?: number;
}
export interface SearchMatch {
path: string;
line: number;
column: number;
text: string;
before?: string[];
after?: string[];
}
function suspiciousRegex(pattern: string): boolean {
if (pattern.length > 200) return true;
// JavaScript RegExp has no execution timeout. Keep model-supplied regexes to
// a deliberately conservative subset rather than risking event-loop stalls.
if (/\\[1-9]/.test(pattern)) return true; // backreferences
if (/(^|[^\\])\((?:\\.|[^)])*\)[+*{]/.test(pattern)) return true; // quantified groups
if (/\.[+*](?:\\.|.)*\.[+*]/.test(pattern)) return true; // repeated wildcards
for (const match of pattern.matchAll(/\{(\d+)(?:,(\d*)?)?\}/g)) {
const minimum = Number(match[1]);
const maximum = match[2] === undefined || match[2] === "" ? minimum : Number(match[2]);
if (minimum > 1000 || maximum > 1000) return true;
}
return false;
}
function createMatcher(options: SearchOptions): RegExp {
const flags = options.caseSensitive ? "g" : "gi";
if (options.regex) {
if (suspiciousRegex(options.query)) {
throw new AgenticError(
"INVALID_INPUT",
"Regex rejected as too complex. Use a literal search or simplify the expression.",
);
}
try {
return new RegExp(options.query, flags);
} catch (error) {
throw new AgenticError("INVALID_INPUT", `Invalid regular expression: ${String(error)}`);
}
}
const escaped = options.query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(escaped, flags);
}
export async function searchWorkspace(
boundary: WorkspaceBoundary,
options: SearchOptions,
): Promise<{
matches: SearchMatch[];
filesScanned: number;
filesSkipped: number;
truncated: boolean;
}> {
if (!options.query) {
throw new AgenticError("INVALID_INPUT", "Search query may not be empty.");
}
const matcher = createMatcher(options);
const listing = await walkWorkspace(boundary, options.path ?? ".", {
maxDepth: 30,
maxEntries: options.maxFiles * 4,
includeHidden: false,
});
const files = listing.entries.filter((entry) => entry.type === "file");
const matches: SearchMatch[] = [];
let filesScanned = 0;
let filesSkipped = 0;
const context = Math.max(0, Math.min(options.contextLines ?? 0, 3));
for (const file of files) {
if (filesScanned >= options.maxFiles || matches.length >= options.maxResults) break;
const absolute = await boundary.resolveRead(file.path);
const info = await stat(absolute);
if (info.size > options.maxFileBytes) {
filesSkipped++;
continue;
}
const buffer = await readFile(absolute);
if (appearsBinary(buffer)) {
filesSkipped++;
continue;
}
const content = buffer.toString("utf8");
if (!Buffer.from(content, "utf8").equals(buffer)) {
filesSkipped++;
continue;
}
filesScanned++;
const lines = content.split(/\r?\n/);
for (let index = 0; index < lines.length; index++) {
matcher.lastIndex = 0;
while (matches.length < options.maxResults) {
const found = matcher.exec(lines[index]);
if (!found) break;
matches.push({
path: file.path,
line: index + 1,
column: found.index + 1,
text: lines[index].slice(0, 500),
...(context > 0
? {
before: lines.slice(Math.max(0, index - context), index),
after: lines.slice(index + 1, index + 1 + context),
}
: {}),
});
// JavaScript global regexes do not advance after an empty match.
if (found[0].length === 0) matcher.lastIndex = found.index + 1;
}
if (matches.length >= options.maxResults) break;
}
}
return {
matches,
filesScanned,
filesSkipped,
truncated:
listing.truncated ||
filesScanned >= options.maxFiles ||
matches.length >= options.maxResults,
};
}