SPB Git

Repository Public

fsx-search.ts 8.7 KB 220 lines typescript

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
Files main
Menu

Theme