import { createRequire } from 'node:module' import { spawn } from 'node:child_process' import { relative, sep } from 'node:path' import { getConfig } from './config' /** ripgrep is the single source of "what files are in the project": it powers * content search, the file-name list, AND the Explorer tree / content index * (via fs-service) — so gitignore + files.exclude are honored everywhere the * same way. Substring (fixed-string), smart-case search. */ const require = createRequire(import.meta.url) let rgPath: string | null = null try { rgPath = (require('@vscode/ripgrep') as { rgPath: string }).rgPath // When packaged the binary is unpacked from the asar; rgPath still points // inside app.asar, so redirect it. No-op in dev (path has no app.asar). if (rgPath) rgPath = rgPath.replace(/\bapp\.asar\b/, 'app.asar.unpacked') } catch (e) { console.error('[helder] @vscode/ripgrep unavailable:', (e as Error).message) } export interface ContentHit { no: number; ln: string; ix: number } export interface ContentGroup { path: string; hits: ContentHit[] } /** Always-excluded heavy/noise dirs, on top of gitignore + user excludes. */ const BASE_IGNORE = [ 'node_modules', '.git', 'out', 'dist', 'build', '.cache', 'vendor', 'coverage', '.helder', '.next', '.nuxt', '.turbo', '.idea', '.vscode', ] const MAX_FILES = 400 const MAX_LINE = 1000 export function rgAvailable(): boolean { return !!rgPath } /** Glob/ignore args derived from config (files.exclude, files.followGitignore). */ function ignoreArgs(): string[] { const cfg = getConfig() const args: string[] = [] for (const d of BASE_IGNORE) args.push('--glob', `!${d}`) for (const g of cfg.files.exclude) if (g) args.push('--glob', `!${g}`) if (!cfg.files.followGitignore) args.push('--no-ignore') return args } function toRel(root: string, p: string): string { return relative(root, p).split(sep).join('/') } export function searchContent(root: string, query: string): Promise { return new Promise((resolve) => { if (!rgPath || query.trim().length < 2) return resolve([]) const child = spawn(rgPath, [ '--json', '--fixed-strings', '--smart-case', '--hidden', '--max-count', '50', '--max-columns', '2000', ...ignoreArgs(), '-e', query, '--', root, ]) const order: string[] = [] const groups = new Map() let buf = '' let done = false const finish = (): void => { if (done) return; done = true; resolve(order.slice(0, MAX_FILES).map((p) => groups.get(p)!)) } child.stdout.on('data', (chunk: Buffer) => { buf += chunk.toString() let nl: number while ((nl = buf.indexOf('\n')) >= 0) { const line = buf.slice(0, nl); buf = buf.slice(nl + 1) if (!line) continue let msg: { type: string; data: { path?: { text?: string }; lines?: { text?: string }; line_number?: number; submatches?: { start: number }[] } } try { msg = JSON.parse(line) } catch { continue } if (msg.type !== 'match') continue const abs = msg.data.path?.text const text = msg.data.lines?.text if (!abs || text == null) continue const rel = toRel(root, abs) let g = groups.get(rel) if (!g) { if (groups.size >= MAX_FILES) continue; g = { path: rel, hits: [] }; groups.set(rel, g); order.push(rel) } const ln = text.replace(/\n$/, '').slice(0, MAX_LINE) const ix = msg.data.submatches && msg.data.submatches[0] ? msg.data.submatches[0].start : 0 g.hits.push({ no: msg.data.line_number || 0, ln, ix: Math.min(ix, ln.length) }) } }) child.on('close', finish) child.on('error', finish) }) } /** All project files (relative paths), honoring gitignore + excludes. Includes * dotfiles (--hidden) so .env etc. show up unless ignored. */ export function listFiles(root: string): Promise { return new Promise((resolve) => { if (!rgPath) return resolve([]) const child = spawn(rgPath, ['--files', '--hidden', ...ignoreArgs(), '--', root]) let buf = '' const out: string[] = [] let done = false const finish = (): void => { if (done) return; done = true; resolve(out) } child.stdout.on('data', (chunk: Buffer) => { buf += chunk.toString() let nl: number while ((nl = buf.indexOf('\n')) >= 0) { const line = buf.slice(0, nl); buf = buf.slice(nl + 1) if (line) out.push(toRel(root, line)) } }) child.on('close', () => { if (buf.trim()) out.push(toRel(root, buf.trim())); finish() }) child.on('error', finish) }) }