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. /* PHP symbol index + reference lookup.
* *
* Two jobs, both on ripgrep: * 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 * 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, * 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. * 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', 'node_modules', '.git', 'out', 'dist', 'build', '.cache',
'vendor', 'coverage', '.helder', '.next', '.nuxt', '.turbo', '.idea', '.vscode', '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 /** Declarations only. `readonly`/`final`/`abstract` may precede the keyword, and
* an enum may carry a backing type. Anchored at the line start (with optional * 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_RE = /^[ \t]+use[ \t]+([A-Za-z_\\][\w\\ \t,]*?)[ \t]*[;{]/
const TRAIT_RG = '^\\s+use\\s+[A-Za-z_\\\\][\\w\\\\ \t,]*[;{]' const TRAIT_RG = '^\\s+use\\s+[A-Za-z_\\\\][\\w\\\\ \t,]*[;{]'
const MAX_DEFS = 25
const MAX_REF_FILES = 300 const MAX_REF_FILES = 300
const MAX_LINE = 1000 const MAX_LINE = 1000
function ignoreArgs(): string[] { function ignoreArgs(dirs: string[] = BASE_IGNORE): string[] {
const cfg = getConfig() const cfg = getConfig()
const args: string[] = [] 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}`) for (const g of cfg.files.exclude) if (g) args.push('--glob', `!${g}`)
if (!cfg.files.followGitignore) args.push('--no-ignore') if (!cfg.files.followGitignore) args.push('--no-ignore')
return args return args
@@ -165,7 +172,7 @@ async function build(root: string): Promise<Index> {
const byFile = new Map<string, SymbolDef[]>() const byFile = new Map<string, SymbolDef[]>()
const nsByFile = new Map<string, string>() const nsByFile = new Map<string, string>()
await rgJson( 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) => { (abs, line, text) => {
const rel = toRel(root, abs) const rel = toRel(root, abs)
const ns = parseNamespace(text) 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 — // 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. // which is also why the declaration pass has to run first.
await rgJson( 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) => { (abs, line, text) => {
const names = parseTraitUse(text) const names = parseTraitUse(text)
if (!names.length) return 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> { export async function lookupSymbol(root: string, name: string): Promise<SymbolLookup> {
if (!/^[A-Za-z_]\w*$/.test(name)) return { name, defs: [], refs: [], refCount: 0 } if (!/^[A-Za-z_]\w*$/.test(name)) return { name, defs: [], refs: [], refCount: 0 }
const ix = await getIndex(root) 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 declared = new Set(defs.map((d) => d.path + ':' + d.line))
const order: string[] = [] const order: string[] = []
const groups = new Map<string, RefGroup>() const groups = new Map<string, RefGroup>()

View File

@@ -94,7 +94,16 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext, onSymb
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[text, lang, wrap, symVer], [text, lang, wrap, symVer],
) )
const count = useMemo(() => text.split('\n').length, [text]) /* One text block, not one element per line: the font is monospace and every
* row is exactly 20px, so the numbers land on the same grid a list of divs
* gave — for one node instead of thousands on a long file. Nothing styles a
* single number today; the day something must, this goes back to spans. */
const gutter = useMemo(() => {
const n = text.split('\n').length
let out = '1'
for (let i = 2; i <= n; i++) out += '\n' + i
return out
}, [text])
// The band sits behind the text, so it needs the geometry of the line. Unwrapped // The band sits behind the text, so it needs the geometry of the line. Unwrapped
// that is arithmetic; wrapped, a line can be several rows tall, so measure it. // that is arithmetic; wrapped, a line can be several rows tall, so measure it.
const [flashBox, setFlashBox] = useState<{ top: number; height: number } | null>(null) const [flashBox, setFlashBox] = useState<{ top: number; height: number } | null>(null)
@@ -200,9 +209,7 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext, onSymb
<div className={'code-edit' + (wrap ? ' wrap' : '') + (metaDown ? ' sym-live' : '')}> <div className={'code-edit' + (wrap ? ' wrap' : '') + (metaDown ? ' sym-live' : '')}>
{!wrap && ( {!wrap && (
<div className="ce-gutterwrap"> <div className="ce-gutterwrap">
<div className="ce-gutter" ref={gutterRef}> <div className="ce-gutter" ref={gutterRef}>{gutter}</div>
{Array.from({ length: count }, (_, i) => <div key={i}>{i + 1}</div>)}
</div>
</div> </div>
)} )}
<div className="ce-scroll" ref={scrollRef} onScroll={onScroll} onClick={handleTokenClick}> <div className="ce-scroll" ref={scrollRef} onScroll={onScroll} onClick={handleTokenClick}>

View File

@@ -582,8 +582,11 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
/* editable buffer — transparent textarea over a highlighted <pre>, synced gutter */ /* editable buffer — transparent textarea over a highlighted <pre>, synced gutter */
.code-edit { flex:1; min-height:0; display:flex; overflow:hidden; } .code-edit { flex:1; min-height:0; display:flex; overflow:hidden; }
.ce-gutterwrap { flex:0 0 54px; overflow:hidden; position:relative; } .ce-gutterwrap { flex:0 0 54px; overflow:hidden; position:relative; }
.ce-gutter { padding-top:6px; will-change:transform; } .ce-gutter {
.ce-gutter div { height:20px; line-height:20px; text-align:right; padding-right:14px; color:var(--fg-3); font-family:var(--code-font); font-size:12px; user-select:none; } padding:6px 14px 0 0; will-change:transform;
white-space:pre; text-align:right; line-height:20px;
color:var(--fg-3); font-family:var(--code-font); font-size:12px; user-select:none;
}
.ce-scroll { flex:1; min-width:0; overflow:auto; position:relative; } .ce-scroll { flex:1; min-width:0; overflow:auto; position:relative; }
/* min-height keeps the inset:0 textarea filling the pane on short/empty files, /* min-height keeps the inset:0 textarea filling the pane on short/empty files,
so a click anywhere in the blank area below the last line still lands. */ so a click anywhere in the blank area below the last line still lands. */