Repository Public
1/**2 * /fs/search — `name` mode: bounded directory walk (never follows symlinks);3 * `content` mode: ripgrep when available (argv array, fixed-string, cwd = root), otherwise a4 * bounded pure-Node scan of small text files. Hard limits: 200 hits, 10 s, 20000 visited entries.5 */6import { spawn } from 'node:child_process';7import { promises as fsp } from 'node:fs';8import path from 'node:path';9import type { FastifyInstance } from 'fastify';10import { z } from 'zod';11import type { SearchHit } from '@cr/shared';12import type { RuntimeConfig } from './config.js';13import { RtError } from './plugins.js';14import { resolveInsideEx } from './paths.js';1516export const SKIP_DIRS = new Set(['.git', 'node_modules', '.next', 'dist', '.venv', '__pycache__']);17export const MAX_VISITED = 20000;18export const MAX_HITS = 200;19const SEARCH_TIMEOUT_MS = 10_000;20const MAX_SCAN_FILE = 512 * 1024;21const MAX_PER_FILE = 5;22const PREVIEW_CHARS = 300;2324export interface SearchOptions {25 /** Absolute ripgrep path; `null` forces the pure-Node fallback; undefined = auto-detect. */26 rgPath?: string | null;27 timeoutMs?: number;28}2930const SearchBody = z.object({31 root: z.string().min(1),32 rel: z.string().default(''),33 query: z.string().min(1).max(200),34 mode: z.enum(['name', 'content']).default('name'),35 limit: z.number().int().min(1).max(MAX_HITS).default(50),36});3738let rgDetected: Promise<string | null> | null = null;39export function detectRipgrep(): Promise<string | null> {40 rgDetected ??= (async () => {41 const dirs = [...(process.env.PATH ?? '').split(':'), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin'];42 for (const dir of dirs) {43 if (!path.isAbsolute(dir)) continue;44 const candidate = path.join(dir, 'rg');45 try {46 await fsp.access(candidate, fsp.constants.X_OK);47 return candidate;48 } catch {49 /* next */50 }51 }52 return null;53 })();54 return rgDetected;55}5657interface WalkFile {58 abs: string;59 rel: string;60 name: string;61 isFile: boolean;62}63/** Breadth-first walk below `start`; lstat-typed via Dirent, symlinks are reported, not followed. */64async function* walk(start: string, startRel: string, deadline: number): AsyncGenerator<WalkFile> {65 const queue: Array<{ abs: string; rel: string }> = [{ abs: start, rel: startRel }];66 let visited = 0;67 while (queue.length > 0) {68 const dir = queue.shift() as { abs: string; rel: string };69 let dirents;70 try {71 dirents = await fsp.readdir(dir.abs, { withFileTypes: true });72 } catch {73 continue; // unreadable directory74 }75 dirents.sort((a, b) => (a.name < b.name ? -1 : 1));76 for (const d of dirents) {77 if (++visited > MAX_VISITED || Date.now() > deadline) return;78 const rel = dir.rel ? `${dir.rel}/${d.name}` : d.name;79 const abs = path.join(dir.abs, d.name);80 if (d.isDirectory()) {81 if (!SKIP_DIRS.has(d.name)) queue.push({ abs, rel });82 yield { abs, rel, name: d.name, isFile: false };83 } else {84 yield { abs, rel, name: d.name, isFile: d.isFile() };85 }86 }87 }88}8990/** 0 = no match; higher is better (basename substring > path substring > fuzzy subsequence). */91export function nameScore(query: string, rel: string): number {92 const q = query.toLowerCase();93 const p = rel.toLowerCase();94 const base = p.slice(p.lastIndexOf('/') + 1);95 if (base === q) return 1000;96 if (base.startsWith(q)) return 900 - Math.min(base.length, 200);97 if (base.includes(q)) return 700 - Math.min(base.length, 200);98 if (p.includes(q)) return 500 - Math.min(p.length, 300);99 let i = 0;100 for (const ch of p) {101 if (ch === q[i]) i++;102 if (i === q.length) break;103 }104 return i === q.length && q.length >= 2 ? 100 - Math.min(p.length, 99) : 0;105}106107async function searchNames(start: string, startRel: string, query: string, limit: number, deadline: number): Promise<SearchHit[]> {108 const scored: Array<{ rel: string; score: number }> = [];109 for await (const f of walk(start, startRel, deadline)) {110 const score = nameScore(query, f.rel);111 if (score > 0) scored.push({ rel: f.rel, score });112 }113 scored.sort((a, b) => b.score - a.score || (a.rel < b.rel ? -1 : 1));114 return scored.slice(0, limit).map((s) => ({ path: s.rel, line: null, preview: null }));115}116117function preview(line: string): string {118 return line.replace(/[\r\n]+$/, '').trim().slice(0, PREVIEW_CHARS);119}120121async function searchContentNode(start: string, startRel: string, query: string, limit: number, deadline: number): Promise<SearchHit[]> {122 const hits: SearchHit[] = [];123 const needle = query.toLowerCase();124 for await (const f of walk(start, startRel, deadline)) {125 if (!f.isFile) continue;126 let buf: Buffer;127 try {128 const st = await fsp.lstat(f.abs);129 if (!st.isFile() || st.size > MAX_SCAN_FILE || st.size === 0) continue;130 buf = await fsp.readFile(f.abs);131 } catch {132 continue;133 }134 if (buf.subarray(0, 8192).includes(0)) continue; // binary135 const text = buf.toString('utf8');136 if (!text.toLowerCase().includes(needle)) continue;137 let inFile = 0;138 const lines = text.split('\n');139 for (let i = 0; i < lines.length && inFile < MAX_PER_FILE; i++) {140 const line = lines[i] as string;141 if (line.toLowerCase().includes(needle)) {142 hits.push({ path: f.rel, line: i + 1, preview: preview(line) });143 inFile++;144 if (hits.length >= limit) return hits;145 }146 }147 }148 return hits;149}150151interface RgEvent {152 type?: string;153 data?: { path?: { text?: string }; line_number?: number; lines?: { text?: string } };154}155156function searchContentRg(rg: string, cwd: string, relPrefix: string, query: string, limit: number, timeoutMs: number): Promise<SearchHit[]> {157 return new Promise((resolve, reject) => {158 // Same skip list as the walker (rg only honours .gitignore on its own).159 const skips = [...SKIP_DIRS].flatMap((d) => ['-g', `!${d}`]);160 const args = ['--no-config', '--json', '--max-count', String(MAX_PER_FILE), '--max-filesize', '1M', ...skips, '-F', '-i', '--', query, '.'];161 const child = spawn(rg, args, { cwd, shell: false, stdio: ['ignore', 'pipe', 'ignore'], env: { PATH: process.env.PATH ?? '', LC_ALL: 'en_US.UTF-8' } });162 const hits: SearchHit[] = [];163 let pending = '';164 let finished = false;165 const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);166 const finish = (err?: RtError): void => {167 if (finished) return;168 finished = true;169 clearTimeout(timer);170 if (err && hits.length === 0) reject(err);171 else resolve(hits);172 };173 child.stdout.setEncoding('utf8');174 child.stdout.on('data', (chunk: string) => {175 pending += chunk;176 let nl: number;177 while ((nl = pending.indexOf('\n')) >= 0) {178 const line = pending.slice(0, nl);179 pending = pending.slice(nl + 1);180 if (hits.length >= limit) continue;181 let ev: RgEvent;182 try {183 ev = JSON.parse(line) as RgEvent;184 } catch {185 continue;186 }187 const p = ev.data?.path?.text;188 if (ev.type !== 'match' || typeof p !== 'string') continue; // non-UTF-8 paths are skipped189 const rel = p.replace(/^\.\//, '');190 hits.push({191 path: relPrefix ? `${relPrefix}/${rel}` : rel,192 line: typeof ev.data?.line_number === 'number' ? ev.data.line_number : null,193 preview: typeof ev.data?.lines?.text === 'string' ? preview(ev.data.lines.text) : null,194 });195 if (hits.length >= limit) child.kill('SIGKILL');196 }197 });198 child.on('error', () => finish(new RtError(500, 'search_failed', 'could not start ripgrep')));199 // rg exits 1 when nothing matched; 2 on errors (partial results are still returned).200 child.on('close', (code) => finish(code === 2 ? new RtError(500, 'search_failed', 'ripgrep reported an error') : undefined));201 });202}203204export function registerSearch(app: FastifyInstance, config: RuntimeConfig, opts: SearchOptions = {}): void {205 app.post('/fs/search', async (req): Promise<SearchHit[]> => {206 const parsed = SearchBody.safeParse(req.body);207 if (!parsed.success) throw new RtError(400, 'bad_request', 'invalid search request');208 const { root, rel, query, mode, limit } = parsed.data;209 if (query.includes('\0')) throw new RtError(400, 'bad_request', 'invalid query');210 const dir = await resolveInsideEx(config, root, rel);211 if (!dir.exists || !dir.stats?.isDirectory()) throw new RtError(404, 'not_found', 'search directory does not exist');212 const timeoutMs = opts.timeoutMs ?? SEARCH_TIMEOUT_MS;213 const deadline = Date.now() + timeoutMs;214 if (mode === 'name') return searchNames(dir.abs, dir.rel, query, limit, deadline);215 const rg = opts.rgPath === undefined ? await detectRipgrep() : opts.rgPath;216 if (rg) return searchContentRg(rg, dir.abs, dir.rel, query, limit, timeoutMs);217 return searchContentNode(dir.abs, dir.rel, query, limit, deadline);218 });219}220