Przeglądaj źródła

Oberon LSP: outline, completion, hover, definition, references, rename (O1); qualified record types in Pascal+Oberon resolvers

Eric Streit 3 dni temu
rodzic
commit
67daf2b31c

+ 309 - 0
extensions/oberon-language/src/oberonResolve.ts

@@ -0,0 +1,309 @@
+/** Scope-aware name resolution over parsed Oberon modules.
+ *
+ *  Covers hover/definition/completion/references/rename: same-file
+ *  locals (innermost routine scope first), record fields (`r.f`,
+ *  `T.f`, inline records), and module-qualified names (`M.Name`) via
+ *  same-directory lookup through the IMPORT clause (alias-aware).
+ *  Best-effort: unknowable names yield null/empty, never diagnostics.
+ *  Oberon is case-sensitive throughout.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import { isIdent, lex, mask, parseImports, parseOberon, ObImport, ObSymbol, Tok } from './oberonSymbols';
+
+export interface ObResolved {
+  sym: ObSymbol;
+  filePath: string;
+}
+
+/** Top-level symbols (unwrapping the module wrapper). */
+function topLevel(symbols: ObSymbol[]): ObSymbol[] {
+  const top: ObSymbol[] = [];
+  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: ObSymbol, 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. Record fields never match bare:
+ *  they need a qualifier. */
+function findInScope(children: ObSymbol[], name: string): ObSymbol | null {
+  return children.find(c => c.name === name && c.kind !== 'field') ?? null;
+}
+
+/** Resolve a same-file visible name to its declaration. */
+export function resolveLocal(symbols: ObSymbol[], line: number, ch: number, name: string): ObSymbol | null {
+  const top = topLevel(symbols);
+  const routines = top
+    .filter(s => s.kind === 'procedure' && 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.name === name) ?? null;
+}
+
+// ---- Cross-file module cache ----
+
+interface ModuleEntry { mtimeMs: number; symbols: ObSymbol[]; imports: ObImport[]; }
+
+const moduleCache = new Map<string, ModuleEntry>();
+
+const OBERON_EXTS = ['.Mod', '.mod', '.def', '.om', '.od'];
+
+/** Same-directory module lookup (`Name.Mod`/`.mod`/`.def`/`.om`/`.od`). */
+function findModuleFile(dir: string, moduleName: string): string | null {
+  for (const ext of OBERON_EXTS) {
+    try {
+      const cand = path.join(dir, moduleName + ext);
+      if (fs.statSync(cand).isFile()) return cand;
+    } catch {
+      // Try the next candidate.
+    }
+  }
+  return null;
+}
+
+/** Parse an imported module (whole file; callers only read top level). */
+function loadModule(dir: string, moduleName: string): { symbols: ObSymbol[]; imports: ObImport[]; filePath: string } | null {
+  const filePath = findModuleFile(dir, moduleName);
+  if (!filePath) return null;
+  try {
+    const st = fs.statSync(filePath);
+    const hit = moduleCache.get(filePath);
+    if (hit && hit.mtimeMs === st.mtimeMs) return { ...hit, filePath };
+    const text = fs.readFileSync(filePath, 'utf8');
+    const entry: ModuleEntry = { mtimeMs: st.mtimeMs, symbols: parseOberon(text), imports: parseImports(text) };
+    moduleCache.set(filePath, entry);
+    return { ...entry, filePath };
+  } catch {
+    return null;
+  }
+}
+
+/** Record fields of a named type (`T` or module-qualified `M.T`):
+ *  same file first, then imported modules. */
+export function recordFieldsOf(
+  symbols: ObSymbol[], imports: ObImport[], docDir: string, typeName: string,
+): { fields: ObSymbol[]; filePath: string } | null {
+  const dot = typeName.indexOf('.');
+  if (dot > 0) {
+    const mod = typeName.slice(0, dot);
+    const base = typeName.slice(dot + 1);
+    const imp = imports.find(u => u.name === mod);
+    const loaded = loadModule(docDir, imp ? imp.module : mod);
+    if (loaded) {
+      for (const s of topLevel(loaded.symbols)) {
+        if (s.kind === 'type' && s.name === base && s.children.some(c => c.kind === 'field')) {
+          return { fields: s.children.filter(c => c.kind === 'field'), filePath: loaded.filePath };
+        }
+      }
+    }
+    return null;
+  }
+  for (const s of topLevel(symbols)) {
+    if (s.kind === 'type' && s.name === typeName && s.children.some(c => c.kind === 'field')) {
+      return { fields: s.children.filter(c => c.kind === 'field'), filePath: '' };
+    }
+  }
+  for (const u of imports) {
+    const loaded = loadModule(docDir, u.module);
+    if (!loaded) continue;
+    for (const s of topLevel(loaded.symbols)) {
+      if (s.kind === 'type' && 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 module (for `M.` completion). */
+export function moduleExports(docDir: string, moduleName: string): ObSymbol[] {
+  const loaded = loadModule(docDir, moduleName);
+  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: ObSymbol[], imports: ObImport[], filePath: string, docDir: string,
+  line: number, ch: number, qualifier: string | null, name: string,
+): ObResolved | 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' && 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' && c.name === name);
+          if (f) return { sym: f, filePath };
+        }
+        const typeMatch = /:\s*((?:[A-Za-z_][A-Za-z0-9_]*)(?:\.[A-Za-z_][A-Za-z0-9_]*)?)/.exec(head.detail);
+        if (typeMatch) {
+          const rec = recordFieldsOf(symbols, imports, docDir, typeMatch[1]);
+          if (rec) {
+            const f = rec.fields.find(c => c.name === name);
+            if (f) return { sym: f, filePath: rec.filePath || filePath };
+          }
+        }
+      }
+    }
+    // Module-qualified name: qualifier is an import (alias-aware).
+    const imp = imports.find(u => u.name === qualifier);
+    const loaded = loadModule(docDir, imp ? imp.module : qualifier);
+    if (loaded) {
+      const sym = findInScope(
+        topLevel(loaded.symbols).filter(s => s.kind !== 'module'), name,
+      ) ?? topLevel(loaded.symbols).find(s => s.kind === 'procedure' && 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 exported (marked `*`) by an imported module.
+  for (const u of imports) {
+    const loaded = loadModule(docDir, u.module);
+    if (!loaded) continue;
+    const sym = findInScope(
+      topLevel(loaded.symbols).filter(s => s.kind !== 'module'), name,
+    ) ?? topLevel(loaded.symbols).find(s => s.kind === 'procedure' && s.name === name);
+    if (sym) return { sym, filePath: loaded.filePath };
+  }
+  return null;
+}
+
+/** The module name at an IMPORT-clause position, for go-to-module. */
+export function moduleAt(imports: ObImport[], docDir: string, line: number, ch: number): ObResolved | null {
+  const hit = imports.find(u => u.line === line && ch >= u.ch && ch <= u.endCh);
+  if (!hit) return null;
+  const filePath = findModuleFile(docDir, hit.module);
+  if (!filePath) return null;
+  return {
+    sym: {
+      name: hit.name, kind: 'module', detail: `MODULE ${hit.module}`,
+      line, ch: hit.ch, endLine: line, endCh: hit.endCh, children: [],
+    },
+    filePath,
+  };
+}
+
+// ---- References & rename ----
+
+export interface ObOccurrence {
+  qualifier: string | null;
+  name: string;
+  line: number; ch: number; endLine: number; endCh: number;
+}
+
+export interface ObLocatedOccurrence extends ObOccurrence {
+  filePath: string;
+}
+
+/** Every identifier token in masked-lexed text with its qualifier (`M` in `M.P`). */
+export function identifierOccurrences(text: string): ObOccurrence[] {
+  const toks = lex(mask(text));
+  const out: ObOccurrence[] = [];
+  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: ObSymbol[], 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: ObResolved, b: ObResolved): 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: ObResolved, includeDeclaration = true,
+): ObLocatedOccurrence[] {
+  const symbols = parseOberon(text);
+  const imports = parseImports(text);
+  const declared = new Set<string>();
+  flattenPositions(symbols, declared);
+  const declKey = `${decl.sym.line}:${decl.sym.ch}`;
+  const out: ObLocatedOccurrence[] = [];
+  for (const occ of identifierOccurrences(text)) {
+    if (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, imports, 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;
+}

+ 584 - 0
extensions/oberon-language/src/oberonSymbols.ts

@@ -0,0 +1,584 @@
+/** Local Oberon symbol model for Outline, completion and hover.
+ *
+ *  Conservative by design: only declaration shapes recognised with full
+ *  confidence are emitted (misses are acceptable, wrong entries are not).
+ *  Bodies are skipped with depth counting, so statement-level noise can
+ *  never leak into the symbol table. Scopes: module globals plus one
+ *  routine level (receiver, params and locals); deeper nesting folds
+ *  into its owner. Covers Oberon-07, Oberon-2 and Ulm Oberon shapes;
+ *  Oberon is case-sensitive throughout.
+ */
+
+export interface ObSymbol {
+  name: string;
+  kind: 'procedure' | 'function' | 'constant' | 'type' | 'variable' | 'parameter' | 'field' | 'module';
+  detail: string;
+  line: number; ch: number; endLine: number; endCh: number;
+  children: ObSymbol[];
+  /** Last line covered by this scope (routines/modules), if known. */
+  scopeEndLine?: number;
+  scopeEndCh?: number;
+}
+
+export interface Tok {
+  text: string;
+  upper: string;
+  line: number; ch: number; endLine: number; endCh: number;
+}
+
+/** Strip comments and string literals (length-preserving) so the
+ *  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;
+  const blank = (a: number, b: number) => { for (let k = a; k < b; k++) if (out[k] !== '\n') out[k] = ' '; };
+  while (i < n) {
+    const c = text[i];
+    const nx = i + 1 < n ? text[i + 1] : '';
+    if (c === '(' && nx === '*') {
+      let depth = 1;
+      let j = i + 2;
+      while (j < n && depth > 0) {
+        if (text[j] === '(' && text[j + 1] === '*') { depth++; j += 2; }
+        else if (text[j] === '*' && text[j + 1] === ')') { depth--; j += 2; }
+        else j++;
+      }
+      blank(i, j);
+      i = j;
+    } else if (c === '"' || c === "'") {
+      let j = i + 1;
+      while (j < n && text[j] !== c && text[j] !== '\n') j++;
+      blank(i, Math.min(j + 1, n));
+      i = Math.min(j + 1, n);
+    } else {
+      i++;
+    }
+  }
+  return out.join('');
+}
+
+export function lex(masked: string): Tok[] {
+  const toks: Tok[] = [];
+  const re = /[A-Za-z_][A-Za-z0-9_]*|\d+(?:\.\d+)?(?:[EDed][+-]?\d+)?|:=|<=|>=|\.\.|[^\sA-Za-z0-9_]/g;
+  let m: RegExpExecArray | null;
+  // Incremental line tracking (O(n)); never re-slices from the file start.
+  let line = 0;
+  let prevEnd = 0;
+  while ((m = re.exec(masked)) !== null) {
+    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(),
+      line, ch,
+      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;
+}
+
+export const isIdent = (t: Tok) => /^[A-Za-z_]/.test(t.text);
+
+class Parser {
+  pos = 0;
+  constructor(readonly toks: Tok[]) {}
+
+  peek(off = 0): Tok | null {
+    return this.pos + off < this.toks.length ? this.toks[this.pos + off] : null;
+  }
+
+  at(word: string): boolean {
+    const t = this.peek();
+    return !!t && t.upper === word;
+  }
+
+  next(): Tok {
+    return this.toks[this.pos++];
+  }
+
+  /** Skip a parenthesis/bracket group, returns true when balanced. */
+  skipGroup(open: string, close: string): boolean {
+    if (!this.at(open)) return false;
+    let depth = 0;
+    while (this.pos < this.toks.length) {
+      const t = this.next();
+      if (t.upper === open) depth++;
+      else if (t.upper === close) {
+        depth--;
+        if (depth === 0) return true;
+      }
+    }
+    return false;
+  }
+
+  /** Skip a routine body: declarations then an optional compound. */
+  skipBody(): void {
+    for (;;) {
+      const t = this.peek();
+      if (!t) return;
+      if (t.upper === 'BEGIN') {
+        this.skipCompound();
+        return;
+      }
+      if (['CONST', 'TYPE', 'VAR', 'PROCEDURE'].includes(t.upper)) {
+        this.skipSection();
+        continue;
+      }
+      return;
+    }
+  }
+
+  skipCompound(): void {
+    if (!this.at('BEGIN')) return;
+    this.next();
+    let depth = 1;
+    while (this.pos < this.toks.length && depth > 0) {
+      const t = this.next();
+      // BEGIN/CASE/RECORD/LOOP/WITH each consume their own END.
+      if (t.upper === 'BEGIN' || t.upper === 'CASE' || t.upper === 'RECORD' ||
+          t.upper === 'LOOP' || t.upper === 'WITH') depth++;
+      else if (t.upper === 'END') depth--;
+    }
+  }
+
+  /** Skip one declaration section (const/type/var). */
+  skipSection(): void {
+    const kw = this.peek()?.upper;
+    if (kw !== 'CONST' && kw !== 'TYPE' && kw !== 'VAR') return;
+    this.next();
+    for (;;) {
+      const t = this.peek();
+      if (!t) return;
+      if (['CONST', 'TYPE', 'VAR', 'PROCEDURE', 'BEGIN', 'END'].includes(t.upper)) return;
+      this.skipDeclItem();
+      if (this.at(';')) this.next();
+      else return;
+    }
+  }
+
+  /** Skip one declaration item up to `;` at depth 0. */
+  skipDeclItem(): void {
+    let depth = 0;
+    while (this.pos < this.toks.length) {
+      const t = this.peek()!;
+      if (t.upper === '(' || t.upper === '[') depth++;
+      else if (t.upper === ')' || t.upper === ']') depth--;
+      else if (t.upper === 'RECORD' || t.upper === 'CASE') depth++;
+      else if (t.upper === 'END' && depth > 0) depth--;
+      else if (depth === 0 && (t.upper === ';' || ['CONST', 'TYPE', 'VAR', 'PROCEDURE', 'BEGIN', 'END'].includes(t.upper))) return;
+      this.next();
+    }
+  }
+
+  /** Parse an identifier list with optional `*`/`-` export marks,
+   *  returning the name tokens. */
+  nameList(): Tok[] {
+    const out: Tok[] = [];
+    for (;;) {
+      const t = this.peek();
+      if (!t || !isIdent(t)) return out;
+      out.push(this.next());
+      if (this.peek()?.text === '*' || this.peek()?.text === '-') this.next();
+      if (this.at(',')) { this.next(); continue; }
+      return out;
+    }
+  }
+
+  /** Consume an optional `*`/`-` export mark after a declared name. */
+  exportMark(): void {
+    if (this.peek()?.text === '*' || this.peek()?.text === '-') this.next();
+  }
+}
+
+/** Parse a `RECORD ... END` field list; RECORD already consumed.
+ *
+ *  Only plain `name [, ...] : type;` items at depth 1 become fields
+ *  (export marks stripped). Anything unrecognised is skipped without
+ *  emitting. The closing END is consumed. */
+export function parseRecordFields(p: Parser): ObSymbol[] {
+  const fields: ObSymbol[] = [];
+  let depth = 1;
+  for (;;) {
+    const t = p.peek();
+    if (!t) return fields;
+    if (t.upper === 'RECORD' || t.upper === 'CASE') { 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', 'PROCEDURE', 'BEGIN'].includes(t.upper)) {
+      while (p.pos < p.toks.length && depth > 0) {
+        const k = p.next();
+        if (k.upper === 'RECORD' || k.upper === 'CASE') 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' || k.upper === 'CASE') 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', 'BEGIN'].includes(k.upper)) break;
+      typeToks.push(k.text);
+      p.next();
+    }
+    const typeText = typeToks.join(' ').replace(/\s+([,;:()[\]])/g, '$1').replace(/([(\[])\s+/g, '$1').replace(/\s*\.\s*/g, '.');
+    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();
+  }
+}
+
+/** An `IMPORT` clause entry: local name, real module, positions. */
+export interface ObImport {
+  /** Name used in this file (alias when `alias := Module` was written). */
+  name: string;
+  /** Real module name (after `:=`, or the name itself). */
+  module: string;
+  line: number; ch: number; endCh: number;
+}
+
+/** All `IMPORT a, b, alias := C, ...;` entries at paren-depth 0.
+ *
+ *  An entry not shaped `ident [:=> ident]` (or a clause without a
+ *  terminating `;`) yields nothing. */
+export function parseImports(text: string): ObImport[] {
+  const toks = lex(mask(text));
+  const out: ObImport[] = [];
+  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 !== 'IMPORT') 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 === 'CONST' ||
+          u.upper === 'TYPE' || u.upper === 'VAR' || u.upper === 'PROCEDURE') break;
+      if (!isIdent(u)) {
+        if (u.upper !== ',') break;
+        j++;
+        continue;
+      }
+      // ident [ := ident ] followed by ',' or ';'
+      let real = u.text;
+      let endCh = u.endCh;
+      let jj = j + 1;
+      if (toks[jj]?.text === ':=') {
+        const mod = toks[jj + 1];
+        if (!mod || !isIdent(mod)) break;
+        real = mod.text;
+        endCh = mod.endCh;
+        jj += 2;
+      }
+      const after = toks[jj];
+      if (!after || (after.upper !== ',' && after.upper !== ';')) break;
+      out.push({ name: u.text, module: real, line: u.line, ch: u.ch, endCh });
+      j = jj;
+      if (after.upper === ';') { ok = true; break; }
+      j++;
+    }
+    if (ok) k = j;
+  }
+  return out;
+}
+
+/** Parse the declarations of one scope; stops before BEGIN/END/EOF. */
+function parseScope(p: Parser, out: ObSymbol[], stopAtBegin: boolean): void {
+  for (;;) {
+    const t = p.peek();
+    if (!t) return;
+    const u = t.upper;
+    if (u === 'BEGIN') {
+      if (stopAtBegin) return;
+      p.skipBody();
+      return;
+    }
+    if (u === 'END') return;
+    if (u === 'CONST') {
+      p.next();
+      for (;;) {
+        const n = p.peek();
+        if (!n || !isIdent(n)) break;
+        if (['TYPE', 'VAR', 'CONST', 'PROCEDURE', 'BEGIN', 'END'].includes(n.upper)) break;
+        const nm = p.next();
+        p.exportMark();
+        if (!p.at('=')) { p.skipDeclItem(); if (p.at(';')) p.next(); continue; }
+        p.next();
+        p.skipDeclItem();
+        out.push({ name: nm.text, kind: 'constant', detail: `CONST ${nm.text}`, line: nm.line, ch: nm.ch, endLine: nm.endLine, endCh: nm.endCh, children: [] });
+        if (p.at(';')) p.next();
+        else break;
+      }
+      continue;
+    }
+    if (u === 'TYPE') {
+      p.next();
+      for (;;) {
+        const n = p.peek();
+        if (!n || !isIdent(n)) break;
+        if (['TYPE', 'VAR', 'CONST', 'PROCEDURE', 'BEGIN', 'END'].includes(n.upper)) break;
+        const nm = p.next();
+        p.exportMark();
+        if (!p.at('=')) { p.skipDeclItem(); if (p.at(';')) p.next(); continue; }
+        p.next();
+        const typeChildren: ObSymbol[] = [];
+        if (p.at('RECORD')) {
+          p.next();
+          typeChildren.push(...parseRecordFields(p));
+        } 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;
+      }
+      continue;
+    }
+    if (u === 'VAR') {
+      p.next();
+      for (;;) {
+        const n = p.peek();
+        if (!n || !isIdent(n)) break;
+        if (['TYPE', 'VAR', 'CONST', 'PROCEDURE', 'BEGIN', 'END'].includes(n.upper)) break;
+        const names = p.nameList();
+        if (!p.at(':')) { p.skipDeclItem(); if (p.at(';')) p.next(); continue; }
+        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 ';' at depth 0
+        const typeToks: string[] = [];
+        let depth = 0;
+        while (p.pos < p.toks.length) {
+          const k = p.peek()!;
+          if (k.upper === '(' || k.upper === '[') depth++;
+          else if (k.upper === ')' || k.upper === ']') depth--;
+          else if (k.upper === 'RECORD' || k.upper === 'CASE') depth++;
+          else if (k.upper === 'END' && depth > 0) depth--;
+          else if (depth === 0 && k.upper === ';') break;
+          else if (depth === 0 && ['CONST', 'TYPE', 'VAR', 'PROCEDURE', 'BEGIN', 'END'].includes(k.upper)) break;
+          typeToks.push(k.text);
+          p.next();
+        }
+        const typeText = typeToks.join(' ').replace(/\s+([,;:()[\]])/g, '$1').replace(/([(\[])\s+/g, '$1').replace(/\s*\.\s*/g, '.');
+        for (const nm of names) {
+          out.push({ name: nm.text, kind: 'variable', detail: `VAR ${nm.text}: ${typeText}`, line: nm.line, ch: nm.ch, endLine: nm.endLine, endCh: nm.endCh, children: [] });
+        }
+        if (p.at(';')) p.next();
+        else break;
+      }
+      continue;
+    }
+    if (u === 'PROCEDURE') {
+      const kw = p.next();
+      // Optional receiver `( [VAR] self [, ...] : Type )`: a type-bound
+      // procedure. Its names are in scope in the body, like parameters.
+      const receiver: Tok[] = [];
+      if (p.at('(')) {
+        p.next();
+        let rd = 1;
+        let sawColon = false;
+        while (p.pos < p.toks.length && rd > 0) {
+          const q = p.next();
+          if (q.upper === '(') { rd++; continue; }
+          if (q.upper === ')') {
+            rd--;
+            if (rd === 0) break;
+            continue;
+          }
+          if (rd !== 1) continue;
+          if (q.upper === ':') { sawColon = true; continue; }
+          if (q.upper === ';' || q.upper === ',') continue;
+          if (!sawColon && isIdent(q) && q.upper !== 'VAR' && q.upper !== 'IN') receiver.push(q);
+        }
+        if (rd !== 0) { p.skipDeclItem(); if (p.at(';')) p.next(); continue; }
+      }
+      const nt = p.peek();
+      if (!nt || !isIdent(nt)) {
+        p.skipDeclItem();
+        if (p.at(';')) p.next();
+        continue;
+      }
+      const nameTok = p.next();
+      p.exportMark();
+      // formal params
+      const params: ObSymbol[] = [];
+      if (p.at('(')) {
+        p.next();
+        let pd = 1;
+        const pnames: Tok[] = [];
+        let ptype: string[] = [];
+        let inType = false;
+        const flush = () => {
+          const tt = ptype.join(' ').replace(/\s+([,;:()[\]])/g, '$1').replace(/([(\[])\s+/g, '$1').replace(/\s*\.\s*/g, '.');
+          for (const nm of pnames) {
+            params.push({ name: nm.text, kind: 'parameter', detail: `${nm.text}: ${tt}`, line: nm.line, ch: nm.ch, endLine: nm.endLine, endCh: nm.endCh, children: [] });
+          }
+          pnames.length = 0; ptype = []; inType = false;
+        };
+        while (p.pos < p.toks.length && pd > 0) {
+          const k = p.next();
+          if (k.upper === '(') { pd++; if (inType) ptype.push(k.text); continue; }
+          if (k.upper === ')') { pd--; if (pd === 0) { if (pnames.length || ptype.length) flush(); break; } if (inType) ptype.push(k.text); continue; }
+          if (pd !== 1) { if (inType) ptype.push(k.text); continue; }
+          if (k.upper === ';') { flush(); continue; }
+          if (k.upper === ':' && !inType) { inType = true; continue; }
+          if (k.upper === ',') continue;
+          if ((k.upper === 'VAR' || k.upper === 'IN') && !inType && pnames.length === 0) continue;
+          if (k.text === '*' || k.text === '-') continue;
+          if (isIdent(k)) { if (inType) ptype.push(k.text); else pnames.push(k); }
+          else if (inType) ptype.push(k.text);
+        }
+      }
+      // result type
+      let restype = '';
+      if (p.at(':')) {
+        p.next();
+        const rt: string[] = [];
+        while (p.pos < p.toks.length) {
+          const k = p.peek()!;
+          if (k.upper === ';') break;
+          rt.push(k.text);
+          p.next();
+        }
+        restype = rt.join(' ').replace(/\s+([,;:()[\]])/g, '$1').replace(/([(\[])\s+/g, '$1').replace(/\s*\.\s*/g, '.');
+      }
+      const sym: ObSymbol = {
+        name: nameTok.text,
+        kind: 'procedure',
+        detail: `PROCEDURE ${nameTok.text}${restype ? ': ' + restype : ''}`,
+        line: kw.line, ch: kw.ch, endLine: nameTok.endLine, endCh: nameTok.endCh,
+        children: [
+          ...receiver.map(nm => ({
+            name: nm.text, kind: 'parameter' as const,
+            detail: `${nm.text}: receiver`,
+            line: nm.line, ch: nm.ch, endLine: nm.endLine, endCh: nm.endCh, children: [] as ObSymbol[]
+          })),
+          ...params
+        ]
+      };
+      out.push(sym);
+      if (p.at(';')) p.next();
+      for (;;) {
+        const k = p.peek();
+        if (!k) break;
+        if (k.upper === 'BEGIN') { p.skipBody(); break; }
+        if (k.upper === 'END' || k.upper === 'PROCEDURE') break;
+        if (['CONST', 'TYPE', 'VAR'].includes(k.upper)) {
+          parseScope(p, sym.children, true);
+          continue;
+        }
+        break;
+      }
+      const tail = p.toks[p.pos - 1];
+      if (tail) { sym.scopeEndLine = tail.endLine; sym.scopeEndCh = tail.endCh; }
+      continue;
+    }
+    // anything else at scope level: skip one item to stay in sync
+    p.skipDeclItem();
+    if (p.at(';')) p.next();
+    else if (p.pos < p.toks.length) p.next();
+  }
+}
+
+/** Entry: top-level symbols of an Oberon module. */
+export function parseOberon(text: string): ObSymbol[] {
+  const toks = lex(mask(text));
+  const p = new Parser(toks);
+  // skip an optional DEFINITION prefix and the MODULE header to ';'
+  if (p.at('DEFINITION')) p.next();
+  if (p.at('MODULE')) {
+    p.next();
+    let depth = 0;
+    while (p.pos < p.toks.length) {
+      const t = p.next();
+      if (t.upper === '(') depth++;
+      else if (t.upper === ')') depth--;
+      else if (t.upper === ';' && depth === 0) break;
+    }
+  }
+  const out: ObSymbol[] = [];
+  parseScope(p, out, false);
+  const first = toks.length > 0 ? toks[0] : null;
+  if (first && first.upper === 'MODULE' && toks.length > 1 && isIdent(toks[1])) {
+    const nm = toks[1];
+    const last = toks[toks.length - 1];
+    const mod: ObSymbol = { name: nm.text, kind: 'module', detail: `MODULE ${nm.text}`, line: first.line, ch: first.ch, endLine: nm.endLine, endCh: nm.endCh, children: out };
+    if (last) { mod.scopeEndLine = last.endLine; mod.scopeEndCh = last.endCh; }
+    return [mod];
+  }
+  return out;
+}
+
+/** True when (line, ch) lies within the symbol's scope, if known. */
+function inScope(s: ObSymbol, 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;
+}
+
+/** Symbols visible at a position: globals plus the innermost enclosing
+ *  routine's receiver, params and locals. Forgiving (completion-only). */
+export function visibleAt(symbols: ObSymbol[], line: number, ch: number): ObSymbol[] {
+  const top: ObSymbol[] = [];
+  for (const s of symbols) {
+    if (s.kind === 'module') top.push(...s.children);
+    else top.push(s);
+  }
+  const result: ObSymbol[] = [];
+  for (const s of top) {
+    if (s.kind === 'constant' || s.kind === 'type' || s.kind === 'variable') result.push(s);
+  }
+  const routines = top.filter(s => s.kind === 'procedure' && inScope(s, line, ch));
+  routines.sort((a, b) => (b.line - a.line) || ((b.ch ?? 0) - (a.ch ?? 0)));
+  for (const r of routines) {
+    result.push(...r.children);
+    result.push(r);
+  }
+  for (const s of top) {
+    if (s.kind === 'procedure' && !result.includes(s)) result.push(s);
+  }
+  return result;
+}

+ 344 - 3
extensions/oberon-language/src/server.ts

@@ -6,9 +6,18 @@ 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 './oberonListing';
+import { mask, parseImports, parseOberon, visibleAt, ObSymbol } from './oberonSymbols';
+import {
+  findReferencesInText, moduleExports, nameAtPosition, recordFieldsOf,
+  resolveAt, tokenAtPosition, moduleAt, ObLocatedOccurrence
+} from './oberonResolve';
+import {
+  CompletionItem, CompletionItemKind, DocumentSymbol, SymbolKind, Hover, Location
+} from 'vscode-languageserver/node';
 
 const connection = createConnection(ProposedFeatures.all);
 const documents = new TextDocuments(TextDocument);
@@ -23,7 +32,13 @@ connection.onInitialize((params: InitializeParams): InitializeResult => {
   oberonValidators = options.oberonValidators || {};
   return {
     capabilities: {
-      textDocumentSync: TextDocumentSyncKind.Full
+      textDocumentSync: TextDocumentSyncKind.Full,
+      completionProvider: { triggerCharacters: ['.'] },
+      hoverProvider: true,
+      definitionProvider: true,
+      documentSymbolProvider: true,
+      referencesProvider: true,
+      renameProvider: { prepareProvider: true }
     }
   };
 });
@@ -42,7 +57,7 @@ documents.onDidOpen(e => validate(e.document));
 documents.onDidChangeContent(e => validate(e.document));
 documents.onDidClose(e => connection.sendDiagnostics({ uri: e.document.uri, diagnostics: [] }));
 
-/** Validate a Pascal document with the configured dialect validator.
+/** Validate an Oberon document with the configured dialect validator.
  *
  *  Each pilot validator takes the source file as its single argument and
  *  writes `<base>.LST` next to it; diagnostics come from that listing, whose
@@ -97,5 +112,331 @@ function uriToFilePath(uri: string): string {
   return uri;
 }
 
+// ---- Local intelligence (Outline, completion, hover, definition, references, rename) ----
+
+const OBERON_KEYWORDS = [
+  'ARRAY', 'BEGIN', 'BY', 'CASE', 'CONST', 'DEFINITION', 'DIV', 'DO',
+  'ELSE', 'ELSIF', 'END', 'EXIT', 'FOR', 'IF', 'IMPORT', 'IN', 'IS',
+  'LOOP', 'MOD', 'MODULE', 'NIL', 'OF', 'OR', 'POINTER', 'PROCEDURE',
+  'RECORD', 'REPEAT', 'RETURN', 'THEN', 'TO', 'TYPE', 'UNTIL', 'VAR',
+  'WHILE', 'WITH'
+];
+
+const OBERON_BUILTINS = [
+  'ABS', 'ASH', 'BOOLEAN', 'CAP', 'CHAR', 'CHR', 'COPY', 'DEC', 'ENTIER',
+  'EXCL', 'HALT', 'INC', 'INCL', 'INTEGER', 'LEN', 'LONGINT', 'MAX',
+  'MIN', 'NEW', 'ODD', 'ORD', 'REAL', 'SHORTINT', 'SIZE'
+];
+
+function symbolKind(kind: ObSymbol['kind']): SymbolKind {
+  switch (kind) {
+    case 'module': return SymbolKind.Module;
+    case 'procedure':
+    case 'function': return SymbolKind.Function;
+    case 'constant': return SymbolKind.Constant;
+    case 'type': return SymbolKind.Class;
+    case 'variable':
+    case 'parameter': return SymbolKind.Variable;
+    case 'field': return SymbolKind.Field;
+  }
+}
+
+function toDocumentSymbol(s: ObSymbol): DocumentSymbol {
+  const endLine = s.scopeEndLine ?? s.endLine;
+  const endCh = s.scopeEndCh ?? s.endCh;
+  return {
+    name: s.name,
+    kind: symbolKind(s.kind),
+    range: {
+      start: { line: s.line, character: s.ch },
+      end: { line: endLine, character: endCh }
+    },
+    selectionRange: {
+      start: { line: s.line, character: s.ch },
+      end: { line: s.endLine, character: s.endCh }
+    },
+    detail: s.detail,
+    children: s.children.map(toDocumentSymbol)
+  };
+}
+
+connection.onDocumentSymbol(params => {
+  const document = documents.get(params.textDocument.uri);
+  if (!document) return [];
+  try {
+    return parseOberon(document.getText()).map(toDocumentSymbol);
+  } catch {
+    return [];
+  }
+});
+
+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: ObSymbol[] = [];
+  try {
+    symbols = parseOberon(text);
+  } catch {
+    symbols = [];
+  }
+  const imports = parseImports(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];
+    // Imported module (alias-aware): offer its top-level names.
+    const imp = imports.find(u => u.name === qualifier);
+    const exps = moduleExports(docDir, imp ? imp.module : 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, imports, 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_]*)(?:\.[A-Za-z_][A-Za-z0-9_]*)?)/.exec(head.detail);
+      if (typeMatch) {
+        const rec = recordFieldsOf(symbols, imports, 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, imports, 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,
+    kind: s.kind === 'procedure' || s.kind === 'function'
+      ? CompletionItemKind.Function
+      : s.kind === 'constant' ? CompletionItemKind.Constant
+      : s.kind === 'type' ? CompletionItemKind.Class
+      : s.kind === 'field' ? CompletionItemKind.Field
+      : CompletionItemKind.Variable,
+    detail: s.detail
+  } as CompletionItem));
+  for (const kw of OBERON_KEYWORDS) {
+    items.push({ label: kw, kind: CompletionItemKind.Keyword, detail: 'Oberon keyword' } as CompletionItem);
+  }
+  for (const b of OBERON_BUILTINS) {
+    items.push({ label: b, kind: CompletionItemKind.Function, detail: 'Oberon predefined routine' } as CompletionItem);
+  }
+  return items;
+});
+
+connection.onHover(params => {
+  const document = documents.get(params.textDocument.uri);
+  if (!document) return null;
+  const text = document.getText();
+  let symbols: ObSymbol[] = [];
+  try {
+    symbols = parseOberon(text);
+  } catch {
+    return null;
+  }
+  const offset = document.offsetAt(params.position);
+  const found = nameAtPosition(mask(text), offset);
+  if (!found) return null;
+  const filePath = uriToFilePath(document.uri);
+  const docDir = path.dirname(filePath);
+  const resolved = resolveAt(symbols, parseImports(text), filePath, docDir,
+    params.position.line, params.position.character, found.qualifier, found.name);
+  const show = resolved ?? (OBERON_BUILTINS.includes(found.name.toUpperCase())
+    ? { sym: { name: found.name.toUpperCase(), kind: 'procedure', detail: 'Oberon predefined routine' } as ObSymbol, 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: ['```oberon', 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: ObSymbol[] = [];
+  try {
+    symbols = parseOberon(text);
+  } catch {
+    return null;
+  }
+  const filePath = uriToFilePath(document.uri);
+  const docDir = path.dirname(filePath);
+  const imports = parseImports(text);
+  // IMPORT-clause module name: jump to the module file.
+  const mod = moduleAt(imports, docDir, params.position.line, params.position.character);
+  if (mod) {
+    return {
+      uri: pathToFileURL(mod.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, imports, 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: ObSymbol[]; imports: ReturnType<typeof parseImports>; filePath: string; docDir: string; qualifier: string | null; name: string } | null {
+  const text = document.getText();
+  let symbols: ObSymbol[];
+  try {
+    symbols = parseOberon(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, imports: parseImports(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: ObSymbol[], imports: ReturnType<typeof parseImports>,
+  filePath: string, docDir: string, line: number, character: number,
+  qualifier: string | null, name: string, includeDeclaration: boolean,
+): { uri: string; occurrences: ObLocatedOccurrence[] }[] {
+  const decl = resolveAt(symbols, imports, 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 an imported module 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: ObLocatedOccurrence[] }[] = [];
+  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.imports, 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.imports, 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.imports, 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();

+ 18 - 2
extensions/pascal-language/src/pascalResolve.ts

@@ -124,10 +124,26 @@ function loadUnit(dir: string, unitName: string): { symbols: PasSymbol[]; uses:
   }
 }
 
-/** Record fields of a named type: same file first, then used units. */
+/** Record fields of a named type (`T` or unit-qualified `U.T`):
+ *  same file first, then used units. */
 export function recordFieldsOf(
   symbols: PasSymbol[], uses: PasUse[], docDir: string, typeName: string,
 ): { fields: PasSymbol[]; filePath: string } | null {
+  const dot = typeName.indexOf('.');
+  if (dot > 0) {
+    const unit = typeName.slice(0, dot);
+    const base = typeName.slice(dot + 1);
+    const used = uses.find(u => eq(u.name, unit));
+    const loaded = loadUnit(docDir, used ? used.name : unit);
+    if (loaded) {
+      for (const s of topLevel(loaded.symbols)) {
+        if (s.kind === 'type' && eq(s.name, base) && s.children.some(c => c.kind === 'field')) {
+          return { fields: s.children.filter(c => c.kind === 'field'), filePath: loaded.filePath };
+        }
+      }
+    }
+    return 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: '' };
@@ -170,7 +186,7 @@ export function resolveAt(
           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);
+        const typeMatch = /:\s*((?:[A-Za-z_][A-Za-z0-9_]*)(?:\.[A-Za-z_][A-Za-z0-9_]*)?)/.exec(head.detail);
         if (typeMatch) {
           const rec = recordFieldsOf(symbols, uses, docDir, typeMatch[1]);
           if (rec) {

+ 4 - 4
extensions/pascal-language/src/pascalSymbols.ts

@@ -262,7 +262,7 @@ export function parseRecordFields(p: Parser): PasSymbol[] {
       typeToks.push(k.text);
       p.next();
     }
-    const typeText = typeToks.join(' ').replace(/\s+([,;:()[\]])/g, '$1').replace(/([(\[])\s+/g, '$1');
+    const typeText = typeToks.join(' ').replace(/\s+([,;:()[\]])/g, '$1').replace(/([(\[])\s+/g, '$1').replace(/\s*\.\s*/g, '.');
     for (const nm of names) {
       fields.push({
         name: nm.text, kind: 'field', detail: `${nm.text}: ${typeText}`,
@@ -472,7 +472,7 @@ function parseScope(p: Parser, out: PasSymbol[], stopAtBegin: boolean): void {
           typeToks.push(k.text);
           p.next();
         }
-        const typeText = typeToks.join(' ').replace(/\s+([,;:()[\]])/g, '$1').replace(/([(\[])\s+/g, '$1');
+        const typeText = typeToks.join(' ').replace(/\s+([,;:()[\]])/g, '$1').replace(/([(\[])\s+/g, '$1').replace(/\s*\.\s*/g, '.');
         if (p.at('=')) { p.next(); p.skipDeclItem(); }
         for (const nm of names) {
           out.push({ name: nm.text, kind: 'variable', detail: `VAR ${nm.text}: ${typeText}`, line: nm.line, ch: nm.ch, endLine: nm.endLine, endCh: nm.endCh, children: [] });
@@ -516,7 +516,7 @@ function parseScope(p: Parser, out: PasSymbol[], stopAtBegin: boolean): void {
         let ptype: string[] = [];
         let inType = false;
         const flush = () => {
-          const tt = ptype.join(' ').replace(/\s+([,;:()[\]])/g, '$1').replace(/([(\[])\s+/g, '$1');
+          const tt = ptype.join(' ').replace(/\s+([,;:()[\]])/g, '$1').replace(/([(\[])\s+/g, '$1').replace(/\s*\.\s*/g, '.');
           for (const nm of pnames) {
             params.push({ name: nm.text, kind: 'parameter', detail: `${nm.text}: ${tt}`, line: nm.line, ch: nm.ch, endLine: nm.endLine, endCh: nm.endCh, children: [] });
           }
@@ -557,7 +557,7 @@ function parseScope(p: Parser, out: PasSymbol[], stopAtBegin: boolean): void {
           rt.push(k.text);
           p.next();
         }
-        restype = rt.join(' ').replace(/\s+([,;:()[\]])/g, '$1').replace(/([(\[])\s+/g, '$1');
+        restype = rt.join(' ').replace(/\s+([,;:()[\]])/g, '$1').replace(/([(\[])\s+/g, '$1').replace(/\s*\.\s*/g, '.');
       }
       if (!nameTok) {
         // unrecognised header: skip one item and continue (never emit)

+ 1 - 1
extensions/pascal-language/src/server.ts

@@ -225,7 +225,7 @@ connection.onCompletion(params => {
           label: f.name, kind: CompletionItemKind.Field, detail: f.detail
         } as CompletionItem));
       }
-      const typeMatch = /:\s*([A-Za-z_][A-Za-z0-9_]*)/.exec(head.detail);
+      const typeMatch = /:\s*((?:[A-Za-z_][A-Za-z0-9_]*)(?:\.[A-Za-z_][A-Za-z0-9_]*)?)/.exec(head.detail);
       if (typeMatch) {
         const rec = recordFieldsOf(symbols, uses, docDir, typeMatch[1]);
         if (rec) {