| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323 |
- /** 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<string, UnitEntry>();
- const dirCache = new Map<string, { mtimeMs: number; names: string[] }>();
- /** 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<string>): 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<string>();
- 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;
- }
|