/* PHP symbol index + reference lookup. * * Two jobs, both on ripgrep: * - the index: every class/interface/trait/enum DECLARED in the project AND in * vendor (so a framework class is clickable too), as * name → {path, line}. The renderer needs the bare name list to decide which * tokens it may underline, and that decision is per visible token per frame, * so it cannot be a search. One rg pass answers it for every token at once. * - the lookup: on ⌘-click, the declarations of one name plus every reference * to it, found there and then. Nothing about usages is cached. * * The index is built lazily and never on the startup path: the first caller * starts it and later callers join the same promise. A file change drops it, so * the next caller pays for the rebuild (~100 ms) instead of the window opening. */ import { spawn } from 'node:child_process' import { relative, sep } from 'node:path' import { getConfig } from './config' import { log } from './logger' export interface SymbolDef { name: string; path: string; line: number; kind: string /** The file's `namespace`, so the popup can name the class in full. */ ns: string /** Parents named on the declaration line: `extends A`, `implements B, C`. */ parents: string[] interfaces: string[] /** Traits mixed in by an indented `use A, B;` inside this declaration's body. */ traits: string[] } export interface RefHit { no: number; ln: string; ix: number } export interface RefGroup { path: string; hits: RefHit[] } export interface SymbolLookup { name: string; defs: SymbolDef[]; refs: RefGroup[]; refCount: number } /** Same resolution dance as search-service: @vscode/ripgrep is ESM, and the * packaged binary lives outside the asar. */ const rgPathPromise: Promise = (async () => { try { const mod = await import('@vscode/ripgrep') let p = (mod as { rgPath: string }).rgPath if (p) p = p.replace(/\bapp\.asar\b/, 'app.asar.unpacked') return p || null } catch (e) { log('error', 'symbols', 'ripgrep unavailable', (e as Error).message) return null } })() const BASE_IGNORE = [ 'node_modules', '.git', 'out', 'dist', 'build', '.cache', 'vendor', 'coverage', '.helder', '.next', '.nuxt', '.turbo', '.idea', '.vscode', ] /** `vendor/` is out of the reference search — nobody wants to read how Symfony * uses Throwable — but it IS indexed for declarations, so a framework class is * still a name we can underline and open. Measured on a Laravel app: 48 project * classes in 30 ms, 7k more with vendor in 170 ms. */ const DECL_IGNORE = BASE_IGNORE.filter((d) => d !== 'vendor') /** Declarations only. `readonly`/`final`/`abstract` may precede the keyword, and * an enum may carry a backing type. Anchored at the line start (with optional * indent) so `$x instanceof class` shaped text cannot match. */ const DECL_RE = /^[ \t]*(?:(?:final|abstract|readonly)[ \t]+)*(class|interface|trait|enum)[ \t]+([A-Za-z_]\w*)(.*)$/ /** Declarations and the file's namespace in one pass: ripgrep emits a file's * matches together and in line order, and `namespace` always precedes the * declarations it covers, so the last one seen is the right one. */ const DECL_RG = '^\\s*(namespace\\s+[A-Za-z_\\\\][\\w\\\\]*\\s*;|(final\\s+|abstract\\s+|readonly\\s+)*(class|interface|trait|enum)\\s+\\w+)' const NS_RE = /^[ \t]*namespace[ \t]+([A-Za-z_\\][\w\\]*)[ \t]*;/ /** An indented `use` — a trait mixed into a class body, not a namespace import. */ const TRAIT_RE = /^[ \t]+use[ \t]+([A-Za-z_\\][\w\\ \t,]*?)[ \t]*[;{]/ const TRAIT_RG = '^\\s+use\\s+[A-Za-z_\\\\][\\w\\\\ \t,]*[;{]' const MAX_DEFS = 25 const MAX_REF_FILES = 300 const MAX_LINE = 1000 function ignoreArgs(dirs: string[] = BASE_IGNORE): string[] { const cfg = getConfig() const args: string[] = [] for (const d of dirs) 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('/') } /** Bare class names out of an `extends`/`implements` clause: split on commas and * drop the namespace, since the index is keyed on the short name. */ function clauseNames(clause: string): string[] { return clause.split(',') .map((part) => (part.trim().split('\\').pop() ?? '').trim()) .filter((n) => /^[A-Za-z_]\w*$/.test(n)) } /** The namespace a `namespace X;` line declares, or null. */ export function parseNamespace(text: string): string | null { const m = NS_RE.exec(text) return m ? m[1].replace(/^\\/, '') : null } /** The traits an indented `use` line mixes in, or [] for anything else. A * `use function …` or a closure's `use ($x)` is not a trait. */ export function parseTraitUse(text: string): string[] { const m = TRAIT_RE.exec(text) if (!m || /^\s*use\s+(function|const)\b/.test(text)) return [] return clauseNames(m[1]) } /** The declaration on one line, or null. Exported for the unit tests. * Only this line is read, so an `implements` list wrapped onto the next line is * not seen — the popup then shows fewer parents, never wrong ones. */ export function parseDeclaration(text: string): { kind: string; name: string; parents: string[]; interfaces: string[] } | null { const m = DECL_RE.exec(text) if (!m) return null const rest = m[3] ?? '' const ext = /\bextends\s+([^{]*?)(?:\s+implements\b|\s*\{|$)/.exec(rest) const impl = /\bimplements\s+([^{]*?)(?:\s*\{|$)/.exec(rest) return { kind: m[1], name: m[2], parents: ext ? clauseNames(ext[1]) : [], interfaces: impl ? clauseNames(impl[1]) : [], } } /** A namespace import — `use App\Models\Agent;` at column 0, with an optional * alias or a group body. A `use` INSIDE a class body is indented and mixes a * trait in, which is a real reference, so the indent is what separates them. */ export function isImportLine(text: string): boolean { return /^use[ \t]+[^;]*;?[ \t\r]*$/.test(text) } /** Run rg with --json and hand every match line to `onMatch`. */ function rgJson(args: string[], onMatch: (path: string, line: number, text: string, col: number) => void): Promise { return new Promise((resolve) => { rgPathPromise.then((rgPath) => { if (!rgPath) { resolve(); return } const child = spawn(rgPath, args) let buf = '' let done = false const finish = (): void => { if (done) return; done = true; resolve() } 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 onMatch(abs, msg.data.line_number || 0, text.replace(/\n$/, ''), msg.data.submatches?.[0]?.start ?? 0) } }) child.on('close', finish) child.on('error', (e) => { log('error', 'symbols', 'ripgrep failed', (e as Error).message); finish() }) }) }) } interface Index { root: string; byName: Map } let index: Index | null = null let building: Promise | null = null async function build(root: string): Promise { const t0 = Date.now() const byName = new Map() const byFile = new Map() const nsByFile = new Map() await rgJson( ['--json', '--hidden', '--glob', '*.php', ...ignoreArgs(DECL_IGNORE), '-e', DECL_RG, '--', root], (abs, line, text) => { const rel = toRel(root, abs) const ns = parseNamespace(text) if (ns != null) { nsByFile.set(rel, ns); return } const d = parseDeclaration(text) if (!d) return const def: SymbolDef = { name: d.name, path: rel, line, kind: d.kind, ns: nsByFile.get(rel) ?? '', parents: d.parents, interfaces: d.interfaces, traits: [], } const list = byName.get(d.name) if (list) list.push(def) else byName.set(d.name, [def]) const inFile = byFile.get(def.path) if (inFile) inFile.push(def) else byFile.set(def.path, [def]) }, ) // Traits live in the body, not on the declaration line, so they need their own // pass. A `use` belongs to the last declaration above it in the same file — // which is also why the declaration pass has to run first. await rgJson( ['--json', '--hidden', '--glob', '*.php', ...ignoreArgs(DECL_IGNORE), '-e', TRAIT_RG, '--', root], (abs, line, text) => { const names = parseTraitUse(text) if (!names.length) return const decls = byFile.get(toRel(root, abs)) if (!decls) return let owner: SymbolDef | null = null for (const d of decls) if (d.line < line && (!owner || d.line > owner.line)) owner = d if (owner) for (const n of names) if (!owner.traits.includes(n)) owner.traits.push(n) }, ) log('info', 'symbols', 'index built', { classes: byName.size, ms: Date.now() - t0 }) return { root, byName } } /** The index for `root`, built on first use. Concurrent callers share one build. */ function getIndex(root: string): Promise { if (index && index.root === root) return Promise.resolve(index) if (building) return building building = build(root).then((ix) => { index = ix; building = null; return ix }) .catch((e) => { building = null; throw e }) return building } /** Drop the index after a file change. The next caller rebuilds it. */ export function invalidateSymbols(): void { index = null } /** Every declared name. The renderer keeps this as a Set to mark tokens. */ export async function symbolNames(root: string): Promise { const ix = await getIndex(root) return [...ix.byName.keys()] } /** Declarations of `name` plus every reference to it, grouped by file. Namespace * imports are dropped (noise), and so are the declaration lines themselves — * they are already the first section of the popup. */ export async function lookupSymbol(root: string, name: string): Promise { if (!/^[A-Za-z_]\w*$/.test(name)) return { name, defs: [], refs: [], refCount: 0 } const ix = await getIndex(root) // The project's own declaration comes first: a common name like `Handler` is // declared a dozen times inside vendor, and none of those is what was clicked. const defs = (ix.byName.get(name) ?? []) .slice() .sort((a, b) => Number(a.path.startsWith('vendor/')) - Number(b.path.startsWith('vendor/')) || a.path.localeCompare(b.path)) .slice(0, MAX_DEFS) const declared = new Set(defs.map((d) => d.path + ':' + d.line)) const order: string[] = [] const groups = new Map() let refCount = 0 await rgJson( ['--json', '--hidden', '--word-regexp', '--glob', '*.php', ...ignoreArgs(), '-e', name, '--', root], (abs, line, text, col) => { const rel = toRel(root, abs) if (declared.has(rel + ':' + line) || isImportLine(text)) return let g = groups.get(rel) if (!g) { if (groups.size >= MAX_REF_FILES) return; g = { path: rel, hits: [] }; groups.set(rel, g); order.push(rel) } const ln = text.slice(0, MAX_LINE) g.hits.push({ no: line, ln, ix: Math.min(col, ln.length) }) refCount++ }, ) return { name, defs, refs: order.map((p) => groups.get(p) as RefGroup), refCount } }