pascalResolve.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. /** Scope-aware name resolution over parsed Pascal units.
  2. *
  3. * Covers hover/definition/completion/references/rename: same-file
  4. * locals (innermost routine scope first, like `visibleAt`), record
  5. * fields (`var.field`, `Type.field`, inline records), enumeration
  6. * literals, and unit-qualified names (`Unit.Name`) via same-directory
  7. * interface lookup. Best-effort: unknowable names yield null/empty,
  8. * never diagnostics. Pascal is case-insensitive throughout.
  9. */
  10. import * as fs from 'fs';
  11. import * as path from 'path';
  12. import { isIdent, lex, mask, parsePascal, parseUses, PasSymbol, PasUse, Tok } from './pascalSymbols';
  13. export interface PasResolved {
  14. sym: PasSymbol;
  15. filePath: string;
  16. }
  17. const eq = (a: string, b: string) => a.toUpperCase() === b.toUpperCase();
  18. /** Top-level symbols (unwrapping the program/unit module wrapper). */
  19. function topLevel(symbols: PasSymbol[]): PasSymbol[] {
  20. const top: PasSymbol[] = [];
  21. for (const s of symbols) {
  22. if (s.kind === 'module') top.push(...s.children);
  23. else top.push(s);
  24. }
  25. return top;
  26. }
  27. /** True when (line, ch) lies within the symbol's scope, if known. */
  28. function inScope(s: PasSymbol, line: number, ch: number): boolean {
  29. if (s.scopeEndLine === undefined) return true;
  30. if (line < s.line) return false;
  31. if (line > s.scopeEndLine) return false;
  32. if (line === s.scopeEndLine && s.scopeEndCh !== undefined && ch > s.scopeEndCh) return false;
  33. return true;
  34. }
  35. /** A name among scope children, including enumeration literals
  36. * (constants declared inside a TYPE's `(A, B, ...)` list). Record
  37. * fields never match bare: they need a qualifier. */
  38. function findInScope(children: PasSymbol[], name: string): PasSymbol | null {
  39. const direct = children.find(c => c.name !== undefined && eq(c.name, name) && c.kind !== 'field');
  40. if (direct) return direct;
  41. for (const c of children) {
  42. if (c.kind === 'type') {
  43. const lit = c.children.find(g => g.kind === 'constant' && eq(g.name, name));
  44. if (lit) return lit;
  45. }
  46. }
  47. return null;
  48. }
  49. /** Resolve a same-file visible name to its declaration. */
  50. export function resolveLocal(symbols: PasSymbol[], line: number, ch: number, name: string): PasSymbol | null {
  51. const top = topLevel(symbols);
  52. const routines = top
  53. .filter(s => (s.kind === 'procedure' || s.kind === 'function') && inScope(s, line, ch))
  54. .sort((a, b) => (b.line - a.line) || (b.ch - a.ch));
  55. for (const r of routines) {
  56. const found = findInScope(r.children, name);
  57. if (found) return found;
  58. }
  59. const globals = top.filter(s => s.kind === 'constant' || s.kind === 'type' || s.kind === 'variable');
  60. const g = findInScope(globals, name);
  61. if (g) return g;
  62. return top.find(s => (s.kind === 'procedure' || s.kind === 'function') && eq(s.name, name)) ?? null;
  63. }
  64. // ---- Cross-file unit cache ----
  65. interface UnitEntry { mtimeMs: number; symbols: PasSymbol[]; uses: PasUse[]; }
  66. const unitCache = new Map<string, UnitEntry>();
  67. const dirCache = new Map<string, { mtimeMs: number; names: string[] }>();
  68. /** Case-insensitive same-directory unit lookup (`Name.pas`/`.pp`/`.p`). */
  69. function findUnitFile(dir: string, unitName: string): string | null {
  70. const exts = ['.pas', '.pp', '.p'];
  71. for (const ext of exts) {
  72. for (const cand of [`${unitName}${ext}`, `${unitName.toLowerCase()}${ext}`, `${unitName.toUpperCase()}${ext}`]) {
  73. try {
  74. if (fs.statSync(path.join(dir, cand)).isFile()) return path.join(dir, cand);
  75. } catch {
  76. // Try the next candidate.
  77. }
  78. }
  79. }
  80. try {
  81. const st = fs.statSync(dir);
  82. const hit = dirCache.get(dir);
  83. let names = hit?.names;
  84. if (!hit || hit.mtimeMs !== st.mtimeMs) {
  85. names = fs.readdirSync(dir);
  86. dirCache.set(dir, { mtimeMs: st.mtimeMs, names });
  87. }
  88. const want = unitName.toUpperCase();
  89. for (const ext of exts) {
  90. const hitName = names!.find(n => n.toUpperCase() === want + ext.toUpperCase());
  91. if (hitName) return path.join(dir, hitName);
  92. }
  93. } catch {
  94. // Unreadable directory: no unit found.
  95. }
  96. return null;
  97. }
  98. /** Parse a used unit's interface (whole file; callers only read top level). */
  99. function loadUnit(dir: string, unitName: string): { symbols: PasSymbol[]; uses: PasUse[]; filePath: string } | null {
  100. const filePath = findUnitFile(dir, unitName);
  101. if (!filePath) return null;
  102. try {
  103. const st = fs.statSync(filePath);
  104. const hit = unitCache.get(filePath);
  105. if (hit && hit.mtimeMs === st.mtimeMs) return { ...hit, filePath };
  106. const text = fs.readFileSync(filePath, 'utf8');
  107. const entry: UnitEntry = { mtimeMs: st.mtimeMs, symbols: parsePascal(text), uses: parseUses(text) };
  108. unitCache.set(filePath, entry);
  109. return { ...entry, filePath };
  110. } catch {
  111. return null;
  112. }
  113. }
  114. /** Record fields of a named type: same file first, then used units. */
  115. export function recordFieldsOf(
  116. symbols: PasSymbol[], uses: PasUse[], docDir: string, typeName: string,
  117. ): { fields: PasSymbol[]; filePath: string } | null {
  118. for (const s of topLevel(symbols)) {
  119. if (s.kind === 'type' && eq(s.name, typeName) && s.children.some(c => c.kind === 'field')) {
  120. return { fields: s.children.filter(c => c.kind === 'field'), filePath: '' };
  121. }
  122. }
  123. for (const u of uses) {
  124. const loaded = loadUnit(docDir, u.name);
  125. if (!loaded) continue;
  126. for (const s of topLevel(loaded.symbols)) {
  127. if (s.kind === 'type' && eq(s.name, typeName) && s.children.some(c => c.kind === 'field')) {
  128. return { fields: s.children.filter(c => c.kind === 'field'), filePath: loaded.filePath };
  129. }
  130. }
  131. }
  132. return null;
  133. }
  134. /** Exported top-level names of a unit (for `Unit.` completion). */
  135. export function moduleExports(docDir: string, unitName: string): PasSymbol[] {
  136. const loaded = loadUnit(docDir, unitName);
  137. if (!loaded) return [];
  138. return topLevel(loaded.symbols).filter(s => s.kind !== 'module');
  139. }
  140. /** Resolve `name` or `Qualifier.name` at a document position. */
  141. export function resolveAt(
  142. symbols: PasSymbol[], uses: PasUse[], filePath: string, docDir: string,
  143. line: number, ch: number, qualifier: string | null, name: string,
  144. ): PasResolved | null {
  145. if (qualifier) {
  146. // Record field on a local (`r.f`) or on a named type (`T.f`).
  147. const head = resolveLocal(symbols, line, ch, qualifier);
  148. if (head) {
  149. if (head.kind === 'type' && head.children.length > 0) {
  150. const f = head.children.find(c => c.kind === 'field' && eq(c.name, name));
  151. if (f) return { sym: f, filePath };
  152. }
  153. if (head.kind === 'variable' || head.kind === 'parameter' || head.kind === 'constant') {
  154. if (head.children.length > 0) {
  155. const f = head.children.find(c => c.kind === 'field' && eq(c.name, name));
  156. if (f) return { sym: f, filePath };
  157. }
  158. const typeMatch = /:\s*([A-Za-z_][A-Za-z0-9_]*)/.exec(head.detail);
  159. if (typeMatch) {
  160. const rec = recordFieldsOf(symbols, uses, docDir, typeMatch[1]);
  161. if (rec) {
  162. const f = rec.fields.find(c => eq(c.name, name));
  163. if (f) return { sym: f, filePath: rec.filePath || filePath };
  164. }
  165. }
  166. }
  167. }
  168. // Unit-qualified name: qualifier is a used unit (or any same-dir unit).
  169. const used = uses.find(u => eq(u.name, qualifier));
  170. const loaded = loadUnit(docDir, used ? used.name : qualifier);
  171. if (loaded) {
  172. const sym = findInScope(
  173. topLevel(loaded.symbols).filter(s => s.kind !== 'module'), name,
  174. ) ?? topLevel(loaded.symbols).find(s => (s.kind === 'procedure' || s.kind === 'function') && eq(s.name, name));
  175. if (sym) return { sym, filePath: loaded.filePath };
  176. return null;
  177. }
  178. return null;
  179. }
  180. const local = resolveLocal(symbols, line, ch, name);
  181. if (local) return { sym: local, filePath };
  182. // Bare name from a used unit's interface.
  183. for (const u of uses) {
  184. const loaded = loadUnit(docDir, u.name);
  185. if (!loaded) continue;
  186. const sym = findInScope(
  187. topLevel(loaded.symbols).filter(s => s.kind !== 'module'), name,
  188. ) ?? topLevel(loaded.symbols).find(s => (s.kind === 'procedure' || s.kind === 'function') && eq(s.name, name));
  189. if (sym) return { sym, filePath: loaded.filePath };
  190. }
  191. return null;
  192. }
  193. /** The unit name at a USES-clause position, for go-to-unit. */
  194. export function unitAt(uses: PasUse[], docDir: string, line: number, ch: number): PasResolved | null {
  195. const hit = uses.find(u => u.line === line && ch >= u.ch && ch <= u.endCh);
  196. if (!hit) return null;
  197. const filePath = findUnitFile(docDir, hit.name);
  198. if (!filePath) return null;
  199. return {
  200. sym: {
  201. name: hit.name, kind: 'module', detail: `UNIT ${hit.name}`,
  202. line, ch: hit.ch, endLine: line, endCh: hit.endCh, children: [],
  203. },
  204. filePath,
  205. };
  206. }
  207. // ---- References & rename ----
  208. export interface PasOccurrence {
  209. qualifier: string | null;
  210. name: string;
  211. line: number; ch: number; endLine: number; endCh: number;
  212. }
  213. export interface PasLocatedOccurrence extends PasOccurrence {
  214. filePath: string;
  215. }
  216. /** Every identifier token in masked-lexed text with its qualifier (`A` in `A.B`). */
  217. export function identifierOccurrences(text: string): PasOccurrence[] {
  218. const toks = lex(mask(text));
  219. const out: PasOccurrence[] = [];
  220. for (let k = 0; k < toks.length; k++) {
  221. const t = toks[k];
  222. if (!isIdent(t)) continue;
  223. const qualifier =
  224. toks[k - 1]?.text === '.' && toks[k - 2] && isIdent(toks[k - 2]) ? toks[k - 2].text : null;
  225. out.push({ qualifier, name: t.text, line: t.line, ch: t.ch, endLine: t.endLine, endCh: t.endCh });
  226. }
  227. return out;
  228. }
  229. function flattenPositions(symbols: PasSymbol[], out: Set<string>): void {
  230. for (const s of symbols) {
  231. if (s.name) out.add(`${s.line}:${s.ch}`);
  232. flattenPositions(s.children, out);
  233. }
  234. }
  235. function sameDecl(a: PasResolved, b: PasResolved): boolean {
  236. if (a.sym === b.sym) return true;
  237. return a.filePath === b.filePath &&
  238. a.sym.line === b.sym.line && a.sym.ch === b.sym.ch &&
  239. a.sym.endLine === b.sym.endLine && a.sym.endCh === b.sym.endCh;
  240. }
  241. /** All occurrences in `text` that resolve to `decl`.
  242. * The declaration occurrence itself is included iff `includeDeclaration`. */
  243. export function findReferencesInText(
  244. text: string, filePath: string, docDir: string,
  245. decl: PasResolved, includeDeclaration = true,
  246. ): PasLocatedOccurrence[] {
  247. const symbols = parsePascal(text);
  248. const uses = parseUses(text);
  249. // Declaration sites belong to their own symbol: a shadowing
  250. // declaration must not count as a use of the symbol it shadows.
  251. const declared = new Set<string>();
  252. flattenPositions(symbols, declared);
  253. const declKey = `${decl.sym.line}:${decl.sym.ch}`;
  254. const out: PasLocatedOccurrence[] = [];
  255. for (const occ of identifierOccurrences(text)) {
  256. if (!eq(occ.name, decl.sym.name)) continue;
  257. const key = `${occ.line}:${occ.ch}`;
  258. if (filePath === decl.filePath && key === declKey) {
  259. if (includeDeclaration) out.push({ ...occ, filePath });
  260. continue;
  261. }
  262. if (declared.has(key)) continue;
  263. const resolved = resolveAt(symbols, uses, filePath, docDir, occ.line, occ.ch, occ.qualifier, occ.name);
  264. if (!resolved || !sameDecl(resolved, decl)) continue;
  265. out.push({ ...occ, filePath });
  266. }
  267. return out;
  268. }
  269. /** Extract `Qualifier.name` (or plain `name`) at a document offset.
  270. * Runs on masked text (length-preserving, so offsets align) to skip
  271. * comments and string literals. */
  272. export function nameAtPosition(maskedText: string, offset: number): { qualifier: string | null; name: string } | null {
  273. let s = offset;
  274. while (s > 0 && /[A-Za-z0-9_]/.test(maskedText[s - 1] ?? '')) s--;
  275. let e = offset;
  276. while (e < maskedText.length && /[A-Za-z0-9_]/.test(maskedText[e] ?? '')) e++;
  277. if (s >= e) return null;
  278. const name = maskedText.slice(s, e);
  279. if (!/^[A-Za-z_]/.test(name)) return null;
  280. let k = s - 1;
  281. while (k >= 0 && (maskedText[k] === ' ' || maskedText[k] === '\t')) k--;
  282. if (maskedText[k] === '.') {
  283. let j = k - 1;
  284. while (j >= 0 && (maskedText[j] === ' ' || maskedText[j] === '\t')) j--;
  285. let h = j;
  286. while (h >= 0 && /[A-Za-z0-9_]/.test(maskedText[h] ?? '')) h--;
  287. const head = maskedText.slice(h + 1, j + 1);
  288. if (head && /^[A-Za-z_]/.test(head)) return { qualifier: head, name };
  289. }
  290. return { qualifier: null, name };
  291. }
  292. /** Identifier token covering a 0-based position, or null. */
  293. export function tokenAtPosition(text: string, line: number, ch: number): Tok | null {
  294. for (const t of lex(mask(text))) {
  295. if (t.line === line && t.ch <= ch && ch <= t.endCh && isIdent(t)) return t;
  296. }
  297. return null;
  298. }