Skip to content

Commit 1de46f8

Browse files
committed
feat(init): add grep and glob local-op handlers
Add two new local-op types that let the server search project files without reading them all: - grep: regex search across files with optional glob filter, batched (multiple patterns in one round-trip), capped at 100 matches per search with 2000-char line truncation - glob: find files by pattern, batched (multiple patterns in one round-trip), capped at 100 results Both use a Node.js fs-based implementation (no ripgrep dependency) that skips node_modules, .git, and other non-source directories. Counterpart server-side schemas will be added in cli-init-api. Made-with: Cursor
1 parent 74898e8 commit 1de46f8

File tree

4 files changed

+432
-0
lines changed

4 files changed

+432
-0
lines changed

src/lib/init/local-ops.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import type {
3232
DetectSentryPayload,
3333
DirEntry,
3434
FileExistsBatchPayload,
35+
GlobPayload,
36+
GrepPayload,
3537
ListDirPayload,
3638
LocalOpPayload,
3739
LocalOpResult,
@@ -313,6 +315,10 @@ export async function handleLocalOp(
313315
return await runCommands(payload, options.dryRun);
314316
case "apply-patchset":
315317
return await applyPatchset(payload, options.dryRun);
318+
case "grep":
319+
return await grep(payload);
320+
case "glob":
321+
return await glob(payload);
316322
case "create-sentry-project":
317323
return await createSentryProject(payload, options);
318324
case "detect-sentry":
@@ -846,6 +852,185 @@ async function detectSentry(
846852
};
847853
}
848854

855+
// ── Grep & Glob ─────────────────────────────────────────────────────
856+
857+
const MAX_GREP_RESULTS_PER_SEARCH = 100;
858+
const MAX_GREP_LINE_LENGTH = 2000;
859+
const MAX_GLOB_RESULTS = 100;
860+
const SKIP_DIRS = new Set([
861+
"node_modules",
862+
".git",
863+
"__pycache__",
864+
".venv",
865+
"venv",
866+
"dist",
867+
"build",
868+
]);
869+
870+
type GrepMatch = { path: string; lineNum: number; line: string };
871+
872+
/**
873+
* Recursively walk a directory, yielding relative file paths.
874+
* Skips common non-source directories and respects an optional glob filter.
875+
*/
876+
async function* walkFiles(
877+
root: string,
878+
base: string,
879+
globPattern: string | undefined
880+
): AsyncGenerator<string> {
881+
let entries: fs.Dirent[];
882+
try {
883+
entries = await fs.promises.readdir(base, { withFileTypes: true });
884+
} catch {
885+
return;
886+
}
887+
for (const entry of entries) {
888+
const full = path.join(base, entry.name);
889+
const rel = path.relative(root, full);
890+
if (
891+
entry.isDirectory() &&
892+
!SKIP_DIRS.has(entry.name) &&
893+
!entry.name.startsWith(".")
894+
) {
895+
yield* walkFiles(root, full, globPattern);
896+
} else if (
897+
entry.isFile() &&
898+
(!globPattern || matchGlob(entry.name, globPattern))
899+
) {
900+
yield rel;
901+
}
902+
}
903+
}
904+
905+
/** Minimal glob matcher — supports `*` and `?` wildcards. */
906+
function matchGlob(name: string, pattern: string): boolean {
907+
const re = pattern
908+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
909+
.replace(/\*/g, ".*")
910+
.replace(/\?/g, ".");
911+
return new RegExp(`^${re}$`).test(name);
912+
}
913+
914+
/**
915+
* Search files for a regex pattern. Reads files line-by-line and collects
916+
* matches up to `maxResults`.
917+
*/
918+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: file-walking search with early exits
919+
async function grepSearch(opts: {
920+
cwd: string;
921+
pattern: string;
922+
searchPath: string | undefined;
923+
include: string | undefined;
924+
maxResults: number;
925+
}): Promise<{ matches: GrepMatch[]; truncated: boolean }> {
926+
const { cwd, pattern, searchPath, include, maxResults } = opts;
927+
const target = searchPath ? safePath(cwd, searchPath) : cwd;
928+
const regex = new RegExp(pattern);
929+
const matches: GrepMatch[] = [];
930+
let truncated = false;
931+
932+
for await (const rel of walkFiles(cwd, target, include)) {
933+
if (matches.length >= maxResults) {
934+
truncated = true;
935+
break;
936+
}
937+
const absPath = path.join(cwd, rel);
938+
let content: string;
939+
try {
940+
content = await fs.promises.readFile(absPath, "utf-8");
941+
} catch {
942+
continue;
943+
}
944+
const lines = content.split("\n");
945+
for (let i = 0; i < lines.length; i += 1) {
946+
const line = lines[i] ?? "";
947+
if (regex.test(line)) {
948+
let text = line;
949+
if (text.length > MAX_GREP_LINE_LENGTH) {
950+
text = `${text.substring(0, MAX_GREP_LINE_LENGTH)}…`;
951+
}
952+
matches.push({ path: rel, lineNum: i + 1, line: text });
953+
if (matches.length >= maxResults) {
954+
truncated = true;
955+
break;
956+
}
957+
}
958+
}
959+
}
960+
961+
return { matches, truncated };
962+
}
963+
964+
async function grep(payload: GrepPayload): Promise<LocalOpResult> {
965+
const { cwd, params } = payload;
966+
const maxResults = params.maxResultsPerSearch ?? MAX_GREP_RESULTS_PER_SEARCH;
967+
968+
const results = await Promise.all(
969+
params.searches.map(async (search) => {
970+
const { matches, truncated } = await grepSearch({
971+
cwd,
972+
pattern: search.pattern,
973+
searchPath: search.path,
974+
include: search.include,
975+
maxResults,
976+
});
977+
return {
978+
pattern: search.pattern,
979+
matches,
980+
truncated,
981+
totalMatches: matches.length,
982+
};
983+
})
984+
);
985+
986+
return { ok: true, data: { results } };
987+
}
988+
989+
async function globSearch(
990+
cwd: string,
991+
pattern: string,
992+
searchPath: string | undefined,
993+
maxResults: number
994+
): Promise<{ files: string[]; truncated: boolean }> {
995+
const target = searchPath ? safePath(cwd, searchPath) : cwd;
996+
const files: string[] = [];
997+
let truncated = false;
998+
999+
for await (const rel of walkFiles(cwd, target, pattern)) {
1000+
files.push(rel);
1001+
if (files.length >= maxResults + 1) {
1002+
truncated = true;
1003+
break;
1004+
}
1005+
}
1006+
1007+
if (truncated) {
1008+
files.length = maxResults;
1009+
}
1010+
return { files, truncated };
1011+
}
1012+
1013+
async function glob(payload: GlobPayload): Promise<LocalOpResult> {
1014+
const { cwd, params } = payload;
1015+
const maxResults = params.maxResults ?? MAX_GLOB_RESULTS;
1016+
1017+
const results = await Promise.all(
1018+
params.patterns.map(async (pattern) => {
1019+
const { files, truncated } = await globSearch(
1020+
cwd,
1021+
pattern,
1022+
params.path,
1023+
maxResults
1024+
);
1025+
return { pattern, files, truncated };
1026+
})
1027+
);
1028+
1029+
return { ok: true, data: { results } };
1030+
}
1031+
1032+
// ── Sentry project + DSN ────────────────────────────────────────────
1033+
8491034
async function createSentryProject(
8501035
payload: CreateSentryProjectPayload,
8511036
options: WizardOptions

src/lib/init/types.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export type LocalOpPayload =
2525
| FileExistsBatchPayload
2626
| RunCommandsPayload
2727
| ApplyPatchsetPayload
28+
| GrepPayload
29+
| GlobPayload
2830
| CreateSentryProjectPayload
2931
| DetectSentryPayload;
3032

@@ -69,6 +71,33 @@ export type RunCommandsPayload = {
6971
};
7072
};
7173

74+
export type GrepSearch = {
75+
pattern: string;
76+
path?: string;
77+
include?: string;
78+
};
79+
80+
export type GrepPayload = {
81+
type: "local-op";
82+
operation: "grep";
83+
cwd: string;
84+
params: {
85+
searches: GrepSearch[];
86+
maxResultsPerSearch?: number;
87+
};
88+
};
89+
90+
export type GlobPayload = {
91+
type: "local-op";
92+
operation: "glob";
93+
cwd: string;
94+
params: {
95+
patterns: string[];
96+
path?: string;
97+
maxResults?: number;
98+
};
99+
};
100+
72101
export type ApplyPatchsetPayload = {
73102
type: "local-op";
74103
operation: "apply-patchset";

src/lib/init/wizard-runner.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,20 @@ export function describeLocalOp(payload: LocalOpPayload): string {
137137
}
138138
case "list-dir":
139139
return "Listing directory...";
140+
case "grep": {
141+
const searches = payload.params.searches;
142+
if (searches.length === 1 && searches[0]) {
143+
return `Searching for ${safeCodeSpan(searches[0].pattern)}...`;
144+
}
145+
return `Running ${searches.length} searches...`;
146+
}
147+
case "glob": {
148+
const patterns = payload.params.patterns;
149+
if (patterns.length === 1 && patterns[0]) {
150+
return `Finding files matching ${safeCodeSpan(patterns[0])}...`;
151+
}
152+
return `Finding files (${patterns.length} patterns)...`;
153+
}
140154
case "create-sentry-project":
141155
return `Creating project ${safeCodeSpan(payload.params.name)} (${payload.params.platform})...`;
142156
case "detect-sentry":

0 commit comments

Comments
 (0)