This commit is contained in:
2026-09-03 09:21:43 +02:00
parent 03e1f90515
commit 79c7a45807
3 changed files with 34 additions and 12 deletions

View File

@@ -1,7 +1,8 @@
/* PHP symbol index + reference lookup.
*
* Two jobs, both on ripgrep:
* - the index: every class/interface/trait/enum DECLARED in the project, as
* - 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.
@@ -49,6 +50,11 @@ 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
@@ -63,13 +69,14 @@ const NS_RE = /^[ \t]*namespace[ \t]+([A-Za-z_\\][\w\\]*)[ \t]*;/
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(): string[] {
function ignoreArgs(dirs: string[] = BASE_IGNORE): string[] {
const cfg = getConfig()
const args: string[] = []
for (const d of BASE_IGNORE) args.push('--glob', `!${d}`)
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
@@ -165,7 +172,7 @@ async function build(root: string): Promise<Index> {
const byFile = new Map<string, SymbolDef[]>()
const nsByFile = new Map<string, string>()
await rgJson(
['--json', '--hidden', '--glob', '*.php', ...ignoreArgs(), '-e', DECL_RG, '--', root],
['--json', '--hidden', '--glob', '*.php', ...ignoreArgs(DECL_IGNORE), '-e', DECL_RG, '--', root],
(abs, line, text) => {
const rel = toRel(root, abs)
const ns = parseNamespace(text)
@@ -188,7 +195,7 @@ async function build(root: string): Promise<Index> {
// 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(), '-e', TRAIT_RG, '--', root],
['--json', '--hidden', '--glob', '*.php', ...ignoreArgs(DECL_IGNORE), '-e', TRAIT_RG, '--', root],
(abs, line, text) => {
const names = parseTraitUse(text)
if (!names.length) return
@@ -229,7 +236,12 @@ export async function symbolNames(root: string): Promise<string[]> {
export async function lookupSymbol(root: string, name: string): Promise<SymbolLookup> {
if (!/^[A-Za-z_]\w*$/.test(name)) return { name, defs: [], refs: [], refCount: 0 }
const ix = await getIndex(root)
const defs = ix.byName.get(name) ?? []
// 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<string, RefGroup>()