/** Scope-aware name resolution over parsed Pascal units. * * Covers hover/definition/completion/references/rename: same-file * locals (innermost routine scope first, like `visibleAt`), record * fields (`var.field`, `Type.field`, inline records), enumeration * literals, and unit-qualified names (`Unit.Name`) via same-directory * interface lookup. Best-effort: unknowable names yield null/empty, * never diagnostics. Pascal is case-insensitive throughout. */ import * as fs from 'fs'; import * as path from 'path'; import { isIdent, lex, mask, parsePascal, parseUses, PasSymbol, PasUse, Tok } from './pascalSymbols'; export interface PasResolved { sym: PasSymbol; filePath: string; } const eq = (a: string, b: string) => a.toUpperCase() === b.toUpperCase(); /** Top-level symbols (unwrapping the program/unit module wrapper). */ function topLevel(symbols: PasSymbol[]): PasSymbol[] { const top: PasSymbol[] = []; for (const s of symbols) { if (s.kind === 'module') top.push(...s.children); else top.push(s); } return top; } /** True when (line, ch) lies within the symbol's scope, if known. */ function inScope(s: PasSymbol, line: number, ch: number): boolean { if (s.scopeEndLine === undefined) return true; if (line < s.line) return false; if (line > s.scopeEndLine) return false; if (line === s.scopeEndLine && s.scopeEndCh !== undefined && ch > s.scopeEndCh) return false; return true; } /** A name among scope children, including enumeration literals * (constants declared inside a TYPE's `(A, B, ...)` list). Record * fields never match bare: they need a qualifier. */ function findInScope(children: PasSymbol[], name: string): PasSymbol | null { const direct = children.find(c => c.name !== undefined && eq(c.name, name) && c.kind !== 'field'); if (direct) return direct; for (const c of children) { if (c.kind === 'type') { const lit = c.children.find(g => g.kind === 'constant' && eq(g.name, name)); if (lit) return lit; } } return null; } /** Resolve a same-file visible name to its declaration. */ export function resolveLocal(symbols: PasSymbol[], line: number, ch: number, name: string): PasSymbol | null { const top = topLevel(symbols); const routines = top .filter(s => (s.kind === 'procedure' || s.kind === 'function') && inScope(s, line, ch)) .sort((a, b) => (b.line - a.line) || (b.ch - a.ch)); for (const r of routines) { const found = findInScope(r.children, name); if (found) return found; } const globals = top.filter(s => s.kind === 'constant' || s.kind === 'type' || s.kind === 'variable'); const g = findInScope(globals, name); if (g) return g; return top.find(s => (s.kind === 'procedure' || s.kind === 'function') && eq(s.name, name)) ?? null; } // ---- Cross-file unit cache ---- interface UnitEntry { mtimeMs: number; symbols: PasSymbol[]; uses: PasUse[]; } const unitCache = new Map(); const dirCache = new Map(); /** Case-insensitive same-directory unit lookup (`Name.pas`/`.pp`/`.p`). */ function findUnitFile(dir: string, unitName: string): string | null { const exts = ['.pas', '.pp', '.p']; for (const ext of exts) { for (const cand of [`${unitName}${ext}`, `${unitName.toLowerCase()}${ext}`, `${unitName.toUpperCase()}${ext}`]) { try { if (fs.statSync(path.join(dir, cand)).isFile()) return path.join(dir, cand); } catch { // Try the next candidate. } } } try { const st = fs.statSync(dir); const hit = dirCache.get(dir); let names = hit?.names; if (!hit || hit.mtimeMs !== st.mtimeMs) { names = fs.readdirSync(dir); dirCache.set(dir, { mtimeMs: st.mtimeMs, names }); } const want = unitName.toUpperCase(); for (const ext of exts) { const hitName = names!.find(n => n.toUpperCase() === want + ext.toUpperCase()); if (hitName) return path.join(dir, hitName); } } catch { // Unreadable directory: no unit found. } return null; } /** Parse a used unit's interface (whole file; callers only read top level). */ function loadUnit(dir: string, unitName: string): { symbols: PasSymbol[]; uses: PasUse[]; filePath: string } | null { const filePath = findUnitFile(dir, unitName); if (!filePath) return null; try { const st = fs.statSync(filePath); const hit = unitCache.get(filePath); if (hit && hit.mtimeMs === st.mtimeMs) return { ...hit, filePath }; const text = fs.readFileSync(filePath, 'utf8'); const entry: UnitEntry = { mtimeMs: st.mtimeMs, symbols: parsePascal(text), uses: parseUses(text) }; unitCache.set(filePath, entry); return { ...entry, filePath }; } catch { return null; } } /** Record fields of a named type: same file first, then used units. */ export function recordFieldsOf( symbols: PasSymbol[], uses: PasUse[], docDir: string, typeName: string, ): { fields: PasSymbol[]; filePath: string } | null { for (const s of topLevel(symbols)) { if (s.kind === 'type' && eq(s.name, typeName) && s.children.some(c => c.kind === 'field')) { return { fields: s.children.filter(c => c.kind === 'field'), filePath: '' }; } } for (const u of uses) { const loaded = loadUnit(docDir, u.name); if (!loaded) continue; for (const s of topLevel(loaded.symbols)) { if (s.kind === 'type' && eq(s.name, typeName) && s.children.some(c => c.kind === 'field')) { return { fields: s.children.filter(c => c.kind === 'field'), filePath: loaded.filePath }; } } } return null; } /** Exported top-level names of a unit (for `Unit.` completion). */ export function moduleExports(docDir: string, unitName: string): PasSymbol[] { const loaded = loadUnit(docDir, unitName); if (!loaded) return []; return topLevel(loaded.symbols).filter(s => s.kind !== 'module'); } /** Resolve `name` or `Qualifier.name` at a document position. */ export function resolveAt( symbols: PasSymbol[], uses: PasUse[], filePath: string, docDir: string, line: number, ch: number, qualifier: string | null, name: string, ): PasResolved | null { if (qualifier) { // Record field on a local (`r.f`) or on a named type (`T.f`). const head = resolveLocal(symbols, line, ch, qualifier); if (head) { if (head.kind === 'type' && head.children.length > 0) { const f = head.children.find(c => c.kind === 'field' && eq(c.name, name)); if (f) return { sym: f, filePath }; } if (head.kind === 'variable' || head.kind === 'parameter' || head.kind === 'constant') { if (head.children.length > 0) { const f = head.children.find(c => c.kind === 'field' && eq(c.name, name)); if (f) return { sym: f, filePath }; } const typeMatch = /:\s*([A-Za-z_][A-Za-z0-9_]*)/.exec(head.detail); if (typeMatch) { const rec = recordFieldsOf(symbols, uses, docDir, typeMatch[1]); if (rec) { const f = rec.fields.find(c => eq(c.name, name)); if (f) return { sym: f, filePath: rec.filePath || filePath }; } } } } // Unit-qualified name: qualifier is a used unit (or any same-dir unit). const used = uses.find(u => eq(u.name, qualifier)); const loaded = loadUnit(docDir, used ? used.name : qualifier); if (loaded) { const sym = findInScope( topLevel(loaded.symbols).filter(s => s.kind !== 'module'), name, ) ?? topLevel(loaded.symbols).find(s => (s.kind === 'procedure' || s.kind === 'function') && eq(s.name, name)); if (sym) return { sym, filePath: loaded.filePath }; return null; } return null; } const local = resolveLocal(symbols, line, ch, name); if (local) return { sym: local, filePath }; // Bare name from a used unit's interface. for (const u of uses) { const loaded = loadUnit(docDir, u.name); if (!loaded) continue; const sym = findInScope( topLevel(loaded.symbols).filter(s => s.kind !== 'module'), name, ) ?? topLevel(loaded.symbols).find(s => (s.kind === 'procedure' || s.kind === 'function') && eq(s.name, name)); if (sym) return { sym, filePath: loaded.filePath }; } return null; } /** The unit name at a USES-clause position, for go-to-unit. */ export function unitAt(uses: PasUse[], docDir: string, line: number, ch: number): PasResolved | null { const hit = uses.find(u => u.line === line && ch >= u.ch && ch <= u.endCh); if (!hit) return null; const filePath = findUnitFile(docDir, hit.name); if (!filePath) return null; return { sym: { name: hit.name, kind: 'module', detail: `UNIT ${hit.name}`, line, ch: hit.ch, endLine: line, endCh: hit.endCh, children: [], }, filePath, }; } // ---- References & rename ---- export interface PasOccurrence { qualifier: string | null; name: string; line: number; ch: number; endLine: number; endCh: number; } export interface PasLocatedOccurrence extends PasOccurrence { filePath: string; } /** Every identifier token in masked-lexed text with its qualifier (`A` in `A.B`). */ export function identifierOccurrences(text: string): PasOccurrence[] { const toks = lex(mask(text)); const out: PasOccurrence[] = []; for (let k = 0; k < toks.length; k++) { const t = toks[k]; if (!isIdent(t)) continue; const qualifier = toks[k - 1]?.text === '.' && toks[k - 2] && isIdent(toks[k - 2]) ? toks[k - 2].text : null; out.push({ qualifier, name: t.text, line: t.line, ch: t.ch, endLine: t.endLine, endCh: t.endCh }); } return out; } function flattenPositions(symbols: PasSymbol[], out: Set): void { for (const s of symbols) { if (s.name) out.add(`${s.line}:${s.ch}`); flattenPositions(s.children, out); } } function sameDecl(a: PasResolved, b: PasResolved): boolean { if (a.sym === b.sym) return true; return a.filePath === b.filePath && a.sym.line === b.sym.line && a.sym.ch === b.sym.ch && a.sym.endLine === b.sym.endLine && a.sym.endCh === b.sym.endCh; } /** All occurrences in `text` that resolve to `decl`. * The declaration occurrence itself is included iff `includeDeclaration`. */ export function findReferencesInText( text: string, filePath: string, docDir: string, decl: PasResolved, includeDeclaration = true, ): PasLocatedOccurrence[] { const symbols = parsePascal(text); const uses = parseUses(text); // Declaration sites belong to their own symbol: a shadowing // declaration must not count as a use of the symbol it shadows. const declared = new Set(); flattenPositions(symbols, declared); const declKey = `${decl.sym.line}:${decl.sym.ch}`; const out: PasLocatedOccurrence[] = []; for (const occ of identifierOccurrences(text)) { if (!eq(occ.name, decl.sym.name)) continue; const key = `${occ.line}:${occ.ch}`; if (filePath === decl.filePath && key === declKey) { if (includeDeclaration) out.push({ ...occ, filePath }); continue; } if (declared.has(key)) continue; const resolved = resolveAt(symbols, uses, filePath, docDir, occ.line, occ.ch, occ.qualifier, occ.name); if (!resolved || !sameDecl(resolved, decl)) continue; out.push({ ...occ, filePath }); } return out; } /** Extract `Qualifier.name` (or plain `name`) at a document offset. * Runs on masked text (length-preserving, so offsets align) to skip * comments and string literals. */ export function nameAtPosition(maskedText: string, offset: number): { qualifier: string | null; name: string } | null { let s = offset; while (s > 0 && /[A-Za-z0-9_]/.test(maskedText[s - 1] ?? '')) s--; let e = offset; while (e < maskedText.length && /[A-Za-z0-9_]/.test(maskedText[e] ?? '')) e++; if (s >= e) return null; const name = maskedText.slice(s, e); if (!/^[A-Za-z_]/.test(name)) return null; let k = s - 1; while (k >= 0 && (maskedText[k] === ' ' || maskedText[k] === '\t')) k--; if (maskedText[k] === '.') { let j = k - 1; while (j >= 0 && (maskedText[j] === ' ' || maskedText[j] === '\t')) j--; let h = j; while (h >= 0 && /[A-Za-z0-9_]/.test(maskedText[h] ?? '')) h--; const head = maskedText.slice(h + 1, j + 1); if (head && /^[A-Za-z_]/.test(head)) return { qualifier: head, name }; } return { qualifier: null, name }; } /** Identifier token covering a 0-based position, or null. */ export function tokenAtPosition(text: string, line: number, ch: number): Tok | null { for (const t of lex(mask(text))) { if (t.line === line && t.ch <= ch && ch <= t.endCh && isIdent(t)) return t; } return null; }