Procházet zdrojové kódy

Pascal LSP: go-to-definition, references, rename (P1)

Eric Streit před 2 dny
rodič
revize
091c6e47ff

+ 323 - 0
extensions/pascal-language/src/pascalResolve.ts

@@ -0,0 +1,323 @@
+/** 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;
+}

+ 217 - 14
extensions/pascal-language/src/pascalSymbols.ts

@@ -18,7 +18,7 @@ export interface PasSymbol {
   scopeEndCh?: number;
 }
 
-interface Tok {
+export interface Tok {
   text: string;
   upper: string;
   line: number; ch: number; endLine: number; endCh: number;
@@ -27,8 +27,9 @@ interface Tok {
 const WS = /\s/;
 
 /** Strip comments and string literals (length-preserving) so the
- *  declaration scan never sees keywords inside them. */
-function mask(text: string): string {
+ *  declaration scan never sees keywords inside them. Exported for the
+ *  resolver's occurrence scan, which needs identical masking. */
+export function mask(text: string): string {
   const out = text.split('');
   const n = text.length;
   let i = 0;
@@ -68,14 +69,19 @@ function mask(text: string): string {
   return out.join('');
 }
 
-function lex(masked: string): Tok[] {
+export function lex(masked: string): Tok[] {
   const toks: Tok[] = [];
   const re = /[A-Za-z_][A-Za-z0-9_]*|\d+(?:\.\d+)?(?:[Ee][+-]?\d+)?|:=|\.\.|<=|>=|<>|[^\sA-Za-z0-9_]/g;
   let m: RegExpExecArray | null;
+  // Incremental line tracking: the previous version re-sliced from the
+  // file start per token (O(n^2)), which stalls on ~800KB sources.
+  let line = 0;
+  let prevEnd = 0;
   while ((m = re.exec(masked)) !== null) {
-    const before = masked.slice(0, m.index).split('\n');
-    const line = before.length - 1;
-    const ch = before[before.length - 1].length;
+    for (let k = prevEnd; k < m.index; k++) {
+      if (masked[k] === '\n') line++;
+    }
+    const ch = m.index - (masked.lastIndexOf('\n', m.index - 1) + 1);
     const mid = m[0].split('\n');
     toks.push({
       text: m[0], upper: m[0].toUpperCase(),
@@ -83,11 +89,13 @@ function lex(masked: string): Tok[] {
       endLine: line + mid.length - 1,
       endCh: mid.length > 1 ? mid[mid.length - 1].length : ch + m[0].length
     });
+    line += mid.length - 1;
+    prevEnd = m.index + m[0].length;
   }
   return toks;
 }
 
-const isIdent = (t: Tok) => /^[A-Za-z_]/.test(t.text);
+export const isIdent = (t: Tok) => /^[A-Za-z_]/.test(t.text);
 
 class Parser {
   pos = 0;
@@ -126,7 +134,10 @@ class Parser {
     return false;
   }
 
-  /** Skip a routine body: declarations then an optional compound. */
+  /** Skip a routine body: declarations then an optional compound.
+   *  Only section words skipSection() accepts are delegated to it;
+   *  anything else (notably a nested PROCEDURE/FUNCTION, which the
+   *  caller parses itself) ends the skip so the loop always progresses. */
   skipBody(name: string): void {
     for (;;) {
       const t = this.peek();
@@ -137,11 +148,10 @@ class Parser {
         if (this.at('END')) { this.next(); if (this.peek()?.upper !== '.' && /^[A-Za-z_]/.test(this.peek()?.text ?? '')) this.next(); }
         return;
       }
-      if (['CONST', 'TYPE', 'VAR', 'LABEL', 'PROCEDURE', 'FUNCTION'].includes(t.upper)) {
+      if (['CONST', 'TYPE', 'VAR', 'LABEL'].includes(t.upper)) {
         this.skipSection();
         continue;
       }
-      if (t.upper === 'END') return;
       return;
     }
   }
@@ -201,6 +211,154 @@ class Parser {
 
 }
 
+/** Parse a `RECORD ... END` field list; RECORD already consumed.
+ *
+ *  Only plain `name [, ...] : type;` items at depth 1 become fields.
+ *  Variant parts (CASE), nested records and anything unrecognised are
+ *  skipped without emitting (misses acceptable, wrong entries are not).
+ *  The closing END is consumed. */
+export function parseRecordFields(p: Parser): PasSymbol[] {
+  const fields: PasSymbol[] = [];
+  let depth = 1;
+  for (;;) {
+    const t = p.peek();
+    if (!t) return fields;
+    if (t.upper === 'RECORD') { depth++; p.next(); continue; }
+    if (t.upper === 'END') {
+      depth--;
+      p.next();
+      if (depth <= 0) return fields;
+      continue;
+    }
+    if (depth !== 1) { p.next(); continue; }
+    if (!isIdent(t)) {
+      p.skipDeclItem();
+      if (p.at(';')) p.next();
+      else if (p.peek() && p.peek()!.upper !== 'END') p.next();
+      continue;
+    }
+    if (['CONST', 'TYPE', 'VAR', 'LABEL', 'PROCEDURE', 'FUNCTION', 'BEGIN'].includes(t.upper)) {
+      // Lost sync: skip to the matching END so the caller stays aligned.
+      while (p.pos < p.toks.length && depth > 0) {
+        const k = p.next();
+        if (k.upper === 'RECORD') depth++;
+        else if (k.upper === 'END') depth--;
+      }
+      return fields;
+    }
+    const names = p.nameList();
+    if (!p.at(':')) { p.skipDeclItem(); if (p.at(';')) p.next(); continue; }
+    p.next();
+    const typeToks: string[] = [];
+    let dd = 0;
+    while (p.pos < p.toks.length) {
+      const k = p.peek()!;
+      if (k.upper === '(' || k.upper === '[') dd++;
+      else if (k.upper === ')' || k.upper === ']') dd--;
+      else if (k.upper === 'RECORD') dd++;
+      else if (k.upper === 'END' && dd > 0) dd--;
+      else if (dd === 0 && (k.upper === ';' || k.upper === 'END')) break;
+      else if (dd === 0 && ['CONST', 'TYPE', 'VAR', 'PROCEDURE', 'FUNCTION', 'BEGIN'].includes(k.upper)) break;
+      typeToks.push(k.text);
+      p.next();
+    }
+    const typeText = typeToks.join(' ').replace(/\s+([,;:()[\]])/g, '$1').replace(/([(\[])\s+/g, '$1');
+    for (const nm of names) {
+      fields.push({
+        name: nm.text, kind: 'field', detail: `${nm.text}: ${typeText}`,
+        line: nm.line, ch: nm.ch, endLine: nm.endLine, endCh: nm.endCh, children: []
+      });
+    }
+    if (p.at(';')) p.next();
+  }
+}
+
+/** Parse an enumeration literal list `(A, B, ...)`; `(` already consumed.
+ *  Returns the literal names with their tokens. Stops at `)` (consumed)
+ *  or on anything unparseable (partial list kept, never throws). */
+export function parseEnumLiterals(p: Parser): Tok[] {
+  const out: Tok[] = [];
+  for (;;) {
+    const e = p.peek();
+    if (!e) return out;
+    if (e.upper === ')') { p.next(); return out; }
+    if (e.upper === ';') return out;
+    if (isIdent(e)) {
+      out.push(p.next());
+      const nx = p.peek();
+      if (nx && (nx.upper === '=' || nx.upper === ':=')) {
+        // Explicit ordinal (some dialects): skip value to ',' or ')'.
+        p.next();
+        let dd = 0;
+        while (p.pos < p.toks.length) {
+          const q = p.peek()!;
+          if (q.upper === '(') dd++;
+          else if (q.upper === ')') {
+            if (dd === 0) break;
+            dd--;
+          }
+          else if (dd === 0 && (q.upper === ',' || q.upper === ';')) break;
+          p.next();
+        }
+      }
+      if (p.at(',')) { p.next(); continue; }
+      continue;
+    }
+    p.next();
+  }
+}
+
+/** A `USES` clause unit name with its position (for go-to-unit). */
+export interface PasUse {
+  name: string;
+  line: number; ch: number; endCh: number;
+}
+
+/** All `USES a, b, ...;` unit names at paren-depth 0.
+ *
+ *  `IN 'file'` segments are skipped (string contents are masked away,
+ *  so IN is followed by `,`/`;`/ident — everything up to the next `,`
+ *  or `;` is ignored). A USES not followed by an identifier, or one
+ *  without a terminating `;`, yields nothing. */
+export function parseUses(text: string): PasUse[] {
+  const toks = lex(mask(text));
+  const out: PasUse[] = [];
+  let depth = 0;
+  for (let k = 0; k < toks.length; k++) {
+    const t = toks[k];
+    if (t.upper === '(') { depth++; continue; }
+    if (t.upper === ')') { depth--; continue; }
+    if (depth !== 0 || t.upper !== 'USES') continue;
+    const nx = toks[k + 1];
+    if (!nx || !isIdent(nx)) continue;
+    let j = k + 1;
+    let ok = false;
+    for (;;) {
+      const u = toks[j];
+      if (!u) break;
+      if (u.upper === ';') { ok = true; break; }
+      if (u.upper === 'BEGIN' || u.upper === 'END' || u.upper === 'IMPLEMENTATION' ||
+          u.upper === 'PROCEDURE' || u.upper === 'FUNCTION') break;
+      if (u.upper === 'IN') {
+        j++;
+        while (j < toks.length && toks[j].upper !== ',' && toks[j].upper !== ';') j++;
+        continue;
+      }
+      if (isIdent(u)) {
+        // Must be `name`, `name,`, or `name;` — anything else aborts.
+        const after = toks[j + 1];
+        if (!after || (after.upper !== ',' && after.upper !== ';' && after.upper !== 'IN')) break;
+        out.push({ name: u.text, line: u.line, ch: u.ch, endCh: u.endCh });
+      } else if (u.upper !== ',') {
+        break;
+      }
+      j++;
+    }
+    if (ok) k = j;
+  }
+  return out;
+}
+
 /** Parse the declarations of one scope; stops before BEGIN/END/EOF. */
 function parseScope(p: Parser, out: PasSymbol[], stopAtBegin: boolean): void {
   for (;;) {
@@ -212,7 +370,22 @@ function parseScope(p: Parser, out: PasSymbol[], stopAtBegin: boolean): void {
       p.skipBody('');
       return;
     }
-    if (u === 'END' || u === 'IMPLEMENTATION' || u === 'INTERFACE' || u === 'FINALIZATION' || u === 'INITIALIZATION') return;
+    if (u === 'END') return;
+    if (u === 'INTERFACE' || u === 'IMPLEMENTATION') { p.next(); continue; }
+    if (u === 'INITIALIZATION' || u === 'FINALIZATION') {
+      // Statement section: skip to its END so no statement noise leaks in.
+      p.next();
+      let depth = 0;
+      while (p.pos < p.toks.length) {
+        const k = p.next();
+        if (k.upper === 'BEGIN' || k.upper === 'CASE' || k.upper === 'RECORD') depth++;
+        else if (k.upper === 'END') {
+          if (depth === 0) break;
+          depth--;
+        }
+      }
+      continue;
+    }
     if (u === 'LABEL') { p.skipSection(); continue; }
     if (u === 'CONST') {
       p.next();
@@ -240,8 +413,24 @@ function parseScope(p: Parser, out: PasSymbol[], stopAtBegin: boolean): void {
         const nm = p.next();
         if (!p.at('=')) { p.skipDeclItem(); if (p.at(';')) p.next(); continue; }
         p.next();
-        p.skipDeclItem();
-        out.push({ name: nm.text, kind: 'type', detail: `TYPE ${nm.text}`, line: nm.line, ch: nm.ch, endLine: nm.endLine, endCh: nm.endCh, children: [] });
+        const typeChildren: PasSymbol[] = [];
+        const packedRec = p.at('PACKED') && p.peek(1)?.upper === 'RECORD';
+        if (packedRec) p.next();
+        if (p.at('RECORD')) {
+          p.next();
+          typeChildren.push(...parseRecordFields(p));
+        } else if (p.at('(')) {
+          p.next();
+          for (const lit of parseEnumLiterals(p)) {
+            typeChildren.push({
+              name: lit.text, kind: 'constant', detail: `${nm.text}.${lit.text}`,
+              line: lit.line, ch: lit.ch, endLine: lit.endLine, endCh: lit.endCh, children: []
+            });
+          }
+        } else {
+          p.skipDeclItem();
+        }
+        out.push({ name: nm.text, kind: 'type', detail: `TYPE ${nm.text}`, line: nm.line, ch: nm.ch, endLine: nm.endLine, endCh: nm.endCh, children: typeChildren });
         if (p.at(';')) p.next();
         else break;
       }
@@ -256,6 +445,19 @@ function parseScope(p: Parser, out: PasSymbol[], stopAtBegin: boolean): void {
         const names = p.nameList();
         if (!p.at(':')) { p.skipDeclItem(); if (p.at(';')) p.next(); continue; }
         p.next();
+        // Inline `RECORD ... END` type: fields become the variable's children.
+        const packedInline = p.at('PACKED') && p.peek(1)?.upper === 'RECORD';
+        if (packedInline) p.next();
+        if (p.at('RECORD')) {
+          p.next();
+          const fields = parseRecordFields(p);
+          for (const nm of names) {
+            out.push({ name: nm.text, kind: 'variable', detail: `VAR ${nm.text}: RECORD`, line: nm.line, ch: nm.ch, endLine: nm.endLine, endCh: nm.endCh, children: fields });
+          }
+          if (p.at(';')) p.next();
+          else break;
+          continue;
+        }
         // capture type text up to ';' or '=' (initializer) at depth 0
         const typeToks: string[] = [];
         let depth = 0;
@@ -376,6 +578,7 @@ function parseScope(p: Parser, out: PasSymbol[], stopAtBegin: boolean): void {
       for (;;) {
         const k = p.peek();
         if (!k) break;
+        if (k.upper === 'INTERFACE' || k.upper === 'IMPLEMENTATION') { p.next(); continue; }
         if (k.upper === 'BEGIN') { p.skipBody(nameTok.text); break; }
         if (k.upper === 'FORWARD' || k.upper === 'EXTERNAL') {
           // bodyless: consume through ';'

+ 232 - 19
extensions/pascal-language/src/server.ts

@@ -6,12 +6,17 @@ import {
 import { TextDocument } from 'vscode-languageserver-textdocument';
 import * as path from 'path';
 import * as os from 'os';
-import { promises as fs } from 'fs';
+import { promises as fs, readFileSync } from 'fs';
 import { spawn } from 'child_process';
+import { pathToFileURL } from 'url';
 import { parseListing } from './pascalListing';
-import { parsePascal, visibleAt, PasSymbol } from './pascalSymbols';
+import { mask, parsePascal, parseUses, visibleAt, PasSymbol } from './pascalSymbols';
 import {
-  CompletionItem, CompletionItemKind, DocumentSymbol, SymbolKind, Hover
+  findReferencesInText, moduleExports, nameAtPosition, recordFieldsOf,
+  resolveAt, tokenAtPosition, unitAt, PasLocatedOccurrence, PasResolved
+} from './pascalResolve';
+import {
+  CompletionItem, CompletionItemKind, DocumentSymbol, SymbolKind, Hover, Location
 } from 'vscode-languageserver/node';
 
 const connection = createConnection(ProposedFeatures.all);
@@ -30,7 +35,10 @@ connection.onInitialize((params: InitializeParams): InitializeResult => {
       textDocumentSync: TextDocumentSyncKind.Full,
       completionProvider: { triggerCharacters: ['.'] },
       hoverProvider: true,
-      documentSymbolProvider: true
+      definitionProvider: true,
+      documentSymbolProvider: true,
+      referencesProvider: true,
+      renameProvider: { prepareProvider: true }
     }
   };
 });
@@ -164,12 +172,64 @@ connection.onDocumentSymbol(params => {
 connection.onCompletion(params => {
   const document = documents.get(params.textDocument.uri);
   if (!document) return [];
+  const text = document.getText();
+  const filePath = uriToFilePath(document.uri);
+  const docDir = path.dirname(filePath);
   let symbols: PasSymbol[] = [];
   try {
-    symbols = parsePascal(document.getText());
+    symbols = parsePascal(text);
   } catch {
     symbols = [];
   }
+  const uses = parseUses(text);
+  const offset = document.offsetAt(params.position);
+  const dotMatch = /([A-Za-z_][A-Za-z0-9_]*)\.\s*[A-Za-z_0-9]*$/.exec(text.slice(0, offset));
+
+  if (dotMatch) {
+    const qualifier = dotMatch[1];
+    // Used unit: offer its interface names.
+    const exps = moduleExports(docDir, qualifier);
+    if (exps.length > 0) {
+      return exps.map(s => ({
+        label: s.name,
+        kind: s.kind === 'procedure' || s.kind === 'function'
+          ? CompletionItemKind.Function
+          : s.kind === 'constant' ? CompletionItemKind.Constant
+          : s.kind === 'type' ? CompletionItemKind.Class
+          : CompletionItemKind.Variable,
+        detail: s.detail
+      } as CompletionItem));
+    }
+    // Variable of record type: inline fields first, then the named type.
+    const resolved = resolveAt(symbols, uses, filePath, docDir,
+      params.position.line, params.position.character, null, qualifier);
+    const head = resolved?.sym;
+    if (head && (head.kind === 'variable' || head.kind === 'parameter' || head.kind === 'constant')) {
+      if (head.children.length > 0) {
+        return head.children.map(f => ({
+          label: f.name, kind: CompletionItemKind.Field, detail: f.detail
+        } as CompletionItem));
+      }
+      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) {
+          return rec.fields.map(f => ({
+            label: f.name, kind: CompletionItemKind.Field, detail: f.detail
+          } as CompletionItem));
+        }
+      }
+    }
+    // Bare type name with record fields.
+    const rec = recordFieldsOf(symbols, uses, docDir, qualifier);
+    if (rec) {
+      return rec.fields.map(f => ({
+        label: f.name, kind: CompletionItemKind.Field, detail: f.detail
+      } as CompletionItem));
+    }
+    return [];
+  }
+
   const vis = visibleAt(symbols, params.position.line, params.position.character);
   const items: CompletionItem[] = vis.map(s => ({
     label: s.name,
@@ -192,35 +252,188 @@ connection.onCompletion(params => {
 connection.onHover(params => {
   const document = documents.get(params.textDocument.uri);
   if (!document) return null;
+  const text = document.getText();
   let symbols: PasSymbol[] = [];
   try {
-    symbols = parsePascal(document.getText());
+    symbols = parsePascal(text);
   } catch {
     return null;
   }
   const offset = document.offsetAt(params.position);
-  const text = document.getText();
-  let start = offset;
-  while (start > 0 && /[A-Za-z0-9_]/.test(text[start - 1] ?? '')) start--;
-  let end = offset;
-  while (end < text.length && /[A-Za-z0-9_]/.test(text[end] ?? '')) end++;
-  const word = text.slice(start, end);
-  if (!word) return null;
-  const upper = word.toUpperCase();
-  const hit = visibleAt(symbols, params.position.line, params.position.character)
-    .find(s => s.name.toUpperCase() === upper);
-  const show = hit ?? (PASCAL_BUILTINS.includes(upper)
-    ? { name: upper, kind: 'procedure' as const, detail: 'Pascal predefined routine' }
+  const found = nameAtPosition(mask(text), offset);
+  if (!found) return null;
+  const filePath = uriToFilePath(document.uri);
+  const docDir = path.dirname(filePath);
+  const resolved = resolveAt(symbols, parseUses(text), filePath, docDir,
+    params.position.line, params.position.character, found.qualifier, found.name);
+  const show = resolved ?? (PASCAL_BUILTINS.includes(found.name.toUpperCase())
+    ? { sym: { name: found.name.toUpperCase(), kind: 'procedure', detail: 'Pascal predefined routine' } as PasSymbol, filePath }
     : null);
   if (!show) return null;
+  const origin = show.filePath === filePath
+    ? ''
+    : ` — \`${path.basename(show.filePath)}:${show.sym.line + 1}\``;
   const hover: Hover = {
     contents: {
       kind: 'markdown',
-      value: ['```pascal', show.detail || show.name, '```', `*${show.kind}*`].join('\n')
+      value: ['```pascal', show.sym.detail || show.sym.name, '```', `*${show.sym.kind}*${origin}`].join('\n')
     }
   };
   return hover;
 });
 
+connection.onDefinition(params => {
+  const document = documents.get(params.textDocument.uri);
+  if (!document) return null;
+  const text = document.getText();
+  let symbols: PasSymbol[] = [];
+  try {
+    symbols = parsePascal(text);
+  } catch {
+    return null;
+  }
+  const filePath = uriToFilePath(document.uri);
+  const docDir = path.dirname(filePath);
+  const uses = parseUses(text);
+  // USES-clause unit name: jump to the unit file.
+  const unit = unitAt(uses, docDir, params.position.line, params.position.character);
+  if (unit) {
+    return {
+      uri: pathToFileURL(unit.filePath).toString(),
+      range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }
+    } as Location;
+  }
+  const offset = document.offsetAt(params.position);
+  const found = nameAtPosition(mask(text), offset);
+  if (!found) return null;
+  const resolved = resolveAt(symbols, uses, filePath, docDir,
+    params.position.line, params.position.character, found.qualifier, found.name);
+  if (!resolved) return null;
+  const targetUri = resolved.filePath === filePath ? document.uri : pathToFileURL(resolved.filePath).toString();
+  return {
+    uri: targetUri,
+    range: {
+      start: { line: resolved.sym.line, character: resolved.sym.ch },
+      end: { line: resolved.sym.endLine, character: resolved.sym.endCh }
+    }
+  } as Location;
+});
+
+/** Resolve the declaration for a rename/references request, or null. */
+function resolveRequest(
+  document: TextDocument, line: number, character: number,
+): { symbols: PasSymbol[]; uses: ReturnType<typeof parseUses>; filePath: string; docDir: string; qualifier: string | null; name: string } | null {
+  const text = document.getText();
+  let symbols: PasSymbol[];
+  try {
+    symbols = parsePascal(text);
+  } catch {
+    return null;
+  }
+  const offset = document.offsetAt({ line, character });
+  const found = nameAtPosition(mask(text), offset);
+  if (!found) return null;
+  const filePath = uriToFilePath(document.uri);
+  return { symbols, uses: parseUses(text), filePath, docDir: path.dirname(filePath), qualifier: found.qualifier, name: found.name };
+}
+
+/** Reference occurrences across the open documents plus the declaration file. */
+function collectReferences(
+  documents: TextDocument[],
+  symbols: PasSymbol[], uses: ReturnType<typeof parseUses>,
+  filePath: string, docDir: string, line: number, character: number,
+  qualifier: string | null, name: string, includeDeclaration: boolean,
+): { uri: string; occurrences: PasLocatedOccurrence[] }[] {
+  const decl = resolveAt(symbols, uses, filePath, docDir, line, character, qualifier, name);
+  if (!decl) return [];
+  const seen = new Map<string, TextDocument>();
+  for (const doc of documents) {
+    if (!seen.has(doc.uri)) seen.set(doc.uri, doc);
+  }
+  // The declaration may live in a used unit that is not open.
+  let extra: { uri: string; text: string } | null = null;
+  if (!seen.has(pathToFileURL(decl.filePath).toString())) {
+    try {
+      extra = { uri: pathToFileURL(decl.filePath).toString(), text: readFileSync(decl.filePath, 'utf8') };
+    } catch {
+      extra = null;
+    }
+  }
+  const out: { uri: string; occurrences: PasLocatedOccurrence[] }[] = [];
+  for (const doc of seen.values()) {
+    const fp = uriToFilePath(doc.uri);
+    const occurrences = findReferencesInText(doc.getText(), fp, path.dirname(fp), decl, includeDeclaration);
+    if (occurrences.length > 0) out.push({ uri: doc.uri, occurrences });
+  }
+  if (extra) {
+    const occurrences = findReferencesInText(extra.text, decl.filePath, path.dirname(decl.filePath), decl, includeDeclaration);
+    if (occurrences.length > 0) out.push({ uri: extra.uri, occurrences });
+  }
+  return out;
+}
+
+connection.onReferences(params => {
+  const document = documents.get(params.textDocument.uri);
+  if (!document) return [];
+  const req = resolveRequest(document, params.position.line, params.position.character);
+  if (!req) return [];
+  const locations: Location[] = [];
+  for (const { uri, occurrences } of collectReferences(
+    documents.all(), req.symbols, req.uses, req.filePath, req.docDir,
+    params.position.line, params.position.character,
+    req.qualifier, req.name, params.context.includeDeclaration,
+  )) {
+    for (const occ of occurrences) {
+      locations.push({
+        uri,
+        range: {
+          start: { line: occ.line, character: occ.ch },
+          end: { line: occ.endLine, character: occ.endCh }
+        }
+      });
+    }
+  }
+  return locations;
+});
+
+connection.onPrepareRename(params => {
+  const document = documents.get(params.textDocument.uri);
+  if (!document) return null;
+  const req = resolveRequest(document, params.position.line, params.position.character);
+  if (!req) return null;
+  const decl = resolveAt(req.symbols, req.uses, req.filePath, req.docDir,
+    params.position.line, params.position.character, req.qualifier, req.name);
+  if (!decl) return null;
+  const token = tokenAtPosition(document.getText(), params.position.line, params.position.character);
+  if (!token) return null;
+  return {
+    start: { line: token.line, character: token.ch },
+    end: { line: token.endLine, character: token.endCh }
+  };
+});
+
+connection.onRenameRequest(params => {
+  const document = documents.get(params.textDocument.uri);
+  if (!document) return null;
+  const req = resolveRequest(document, params.position.line, params.position.character);
+  if (!req) return null;
+  const changes: { [uri: string]: { range: Location['range']; newText: string }[] } = {};
+  for (const { uri, occurrences } of collectReferences(
+    documents.all(), req.symbols, req.uses, req.filePath, req.docDir,
+    params.position.line, params.position.character,
+    req.qualifier, req.name, true,
+  )) {
+    changes[uri] = occurrences.map(occ => ({
+      range: {
+        start: { line: occ.line, character: occ.ch },
+        end: { line: occ.endLine, character: occ.endCh }
+      },
+      newText: params.newName
+    }));
+  }
+  if (Object.keys(changes).length === 0) return null;
+  return { changes };
+});
+
 documents.listen(connection);
 connection.listen();