Sfoglia il codice sorgente

Phase 2: AST-backed outline, completion, hover, go-to-definition

Lenient Modula-2 declaration parser in the language server
(src/m2/lexer.ts, parser.ts, resolve.ts): modules, imports,
CONST/TYPE/VAR (incl. RECORD fields), procedures with nested
scopes/parameters/GNU optional args, skipped statement bodies,
error recovery that never aborts the file.

Server capabilities added: documentSymbol, completion (symbols,
keywords, Module./record-field members with '.' trigger), hover
with declaration detail, definition with cross-file .def lookup
next to the importing file.

Verified by node harness: local/qualified/FROM-import
resolution, exports and record fields for completion, scope-aware
visibility; 14 real-world .mod files parse in milliseconds with
rich symbol trees; malformed inputs degrade gracefully.
Eric Streit 2 settimane fa
parent
commit
fb865ea0ab

+ 5 - 3
README.md

@@ -12,6 +12,8 @@ A starting point for a dedicated GNU Modula-2 IDE built on Eclipse Theia.
 - Modula-2 comments, brackets and indentation
 - Modula-2 snippets
 - Keyword-only automatic uppercasing
+- AST-backed document outline, completion, hover and go-to-definition
+  (lenient declaration parser with cross-file `.def` resolution)
 - Initial Modula-2 commands/menu
 - Project/example/grammar placeholders
 
@@ -42,9 +44,9 @@ The language extension is intentionally a VS Code-compatible extension because T
 
 ## Roadmap
 
-1. Replace the placeholder parser with the tested GNU Modula-2/Coco/R grammar.
-2. Add AST, symbols and semantic analysis.
-3. Add LSP.
+1. Stabilize the GNU Modula-2/Coco/R grammar against `gm2` (see `GNU-grammar`).
+2. ~~Add AST, symbols and semantic analysis~~ AST/symbols done (lenient parser); semantic analysis pending.
+3. ~~Add LSP~~ LSP live (diagnostics bridge + outline/completion/hover/definition).
 4. Add `modula2.json` project support.
 5. Add GNU Modula-2 build/run/diagnostics.
 6. Add debugger integration.

+ 1 - 1
extensions/modula2-language/package.json

@@ -1,7 +1,7 @@
 {
   "name": "modula2-language",
   "displayName": "GNU Modula-2 Language Support",
-  "version": "0.1.4",
+  "version": "0.1.5",
   "publisher": "modula2",
   "license": "MIT",
   "engines": {

+ 129 - 0
extensions/modula2-language/src/m2/lexer.ts

@@ -0,0 +1,129 @@
+/** Minimal Modula-2 lexer for IDE services (Phase 2).
+ *  Produces tokens with line/character positions. Lenient by design:
+ *  it never throws on malformed input. */
+
+export type TokenKind = 'ident' | 'keyword' | 'number' | 'string' | 'symbol' | 'eof';
+
+export interface Token {
+  text: string;
+  kind: TokenKind;
+  line: number;      // 0-based
+  ch: number;        // 0-based offset of first character
+  endLine: number;
+  endCh: number;     // 0-based offset just past the last character
+  offset: number;    // absolute offset of first character
+  endOffset: number; // absolute offset just past the last character
+}
+
+const KEYWORDS = new Set([
+  'AND', 'ARRAY', 'ASM', 'BEGIN', 'BY', 'CASE', 'CONST', 'DEFINITION', 'DIV',
+  'DO', 'ELSE', 'ELSIF', 'END', 'EXCEPT', 'EXIT', 'EXPORT', 'FINALLY', 'FOR',
+  'FROM', 'IF', 'IMPLEMENTATION', 'IMPORT', 'IN', 'LOOP', 'MOD', 'MODULE',
+  'NOT', 'OF', 'OR', 'PACKEDSET', 'POINTER', 'PROCEDURE', 'QUALIFIED',
+  'RECORD', 'REM', 'REPEAT', 'RETRY', 'RETURN', 'SET', 'THEN', 'TO', 'TYPE',
+  'UNQUALIFIED', 'UNTIL', 'VAR', 'VOLATILE', 'WHILE', 'WITH',
+]);
+
+const MULTI = new Set(['..', ':=', '<=', '>=', '<>', '<*', '*>']);
+
+function isLetter(c: string): boolean {
+  return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c === '_';
+}
+
+function isDigit(c: string): boolean {
+  return c >= '0' && c <= '9';
+}
+
+export function lex(text: string): Token[] {
+  const tokens: Token[] = [];
+  let i = 0;
+  let line = 0;
+  let col = 0;
+
+  function advance(n: number): void {
+    for (let k = 0; k < n; k++) {
+      if (text[i] === '\n') { line++; col = 0; } else { col++; }
+      i++;
+    }
+  }
+
+  function push(kind: TokenKind, start: number, sl: number, sc: number): void {
+    const t: Token = {
+      text: text.slice(start, i), kind,
+      line: sl, ch: sc, endLine: line, endCh: col,
+      offset: start, endOffset: i,
+    };
+    if (kind === 'ident' && KEYWORDS.has(t.text)) t.kind = 'keyword';
+    tokens.push(t);
+  }
+
+  while (i < text.length) {
+    const c = text[i];
+
+    if (c === '\n' || c === '\r' || c === ' ' || c === '\t' || c === '\f' || c === '\v') {
+      advance(1);
+      continue;
+    }
+
+    // Nested comment
+    if (c === '(' && text[i + 1] === '*') {
+      let level = 1;
+      advance(2);
+      while (i < text.length && level > 0) {
+        if (text[i] === '(' && text[i + 1] === '*') { level++; advance(2); continue; }
+        if (text[i] === '*' && text[i + 1] === ')') { level--; advance(2); continue; }
+        advance(1);
+      }
+      continue;
+    }
+
+    // String literal (single/double quoted, terminated at end of line)
+    if (c === "'" || c === '"') {
+      const s = i, sl = line, sc = col;
+      const quote = c;
+      advance(1);
+      while (i < text.length && text[i] !== '\n' && text[i] !== '\r') {
+        if (text[i] === '\\' && i + 1 < text.length && text[i + 1] !== '\n') { advance(2); continue; }
+        if (text[i] === quote) { advance(1); break; }
+        advance(1);
+      }
+      push('string', s, sl, sc);
+      continue;
+    }
+
+    // Number: hex/octal/char-code suffixes, reals; never consumes ".."
+    if (isDigit(c)) {
+      const s = i, sl = line, sc = col;
+      while (i < text.length && /[0-9A-Za-z]/.test(text[i])) advance(1);
+      if (text[i] === '.' && isDigit(text[i + 1] || '')) {
+        advance(1);
+        while (i < text.length && isDigit(text[i])) advance(1);
+        if (text[i] === 'E' || text[i] === 'e') {
+          let j = i + 1;
+          if (text[j] === '+' || text[j] === '-') j++;
+          if (isDigit(text[j] || '')) {
+            advance(j - i);
+            while (i < text.length && isDigit(text[i])) advance(1);
+          }
+        }
+      }
+      push('number', s, sl, sc);
+      continue;
+    }
+
+    if (isLetter(c)) {
+      const s = i, sl = line, sc = col;
+      while (i < text.length && (isLetter(text[i]) || isDigit(text[i]))) advance(1);
+      push('ident', s, sl, sc);
+      continue;
+    }
+
+    const s = i, sl = line, sc = col;
+    if (MULTI.has(text.substr(i, 2))) advance(2);
+    else advance(1);
+    push('symbol', s, sl, sc);
+  }
+
+  tokens.push({ text: '', kind: 'eof', line, ch: col, endLine: line, endCh: col, offset: i, endOffset: i });
+  return tokens;
+}

+ 584 - 0
extensions/modula2-language/src/m2/parser.ts

@@ -0,0 +1,584 @@
+/** Lenient Modula-2 declaration parser for IDE services (Phase 2).
+ *
+ *  Parses module structure, imports, CONST/TYPE/VAR sections, procedures
+ *  (including nested scopes, parameters and RECORD fields) and builds a
+ *  symbol tree with source ranges. Statement and expression bodies are
+ *  skipped with bracket/END matching. Recovery is best-effort: an
+ *  unexpected token skips to the next synchronization point instead of
+ *  aborting the whole file.
+ */
+
+import { Token, lex } from './lexer';
+
+export type M2SymbolKind =
+  | 'module' | 'procedure' | 'variable' | 'field'
+  | 'parameter' | 'constant' | 'type';
+
+export interface M2Range {
+  startLine: number; startCh: number; endLine: number; endCh: number;
+}
+
+export interface M2Symbol {
+  name: string;
+  kind: M2SymbolKind;
+  /** Short declaration text, e.g. `a : ARRAY [0..10] OF CHAR`. */
+  detail: string;
+  /** Range of the declared name. */
+  nameRange: M2Range;
+  /** Full extent (for containers: up to the closing END). */
+  extent: M2Range;
+  children: M2Symbol[];
+}
+
+export interface M2Import {
+  /** Module the names come from. */
+  module: string;
+  /** Imported identifiers (FROM-imports). Empty when plain `IMPORT m`. */
+  names: string[];
+  /** True for plain `IMPORT m` (all exports visible as m.X). */
+  all: boolean;
+}
+
+export interface M2Unit {
+  filePath: string;
+  moduleName: string;
+  definition: boolean;
+  symbols: M2Symbol[];
+  imports: M2Import[];
+}
+
+const SECTION = new Set(['CONST', 'TYPE', 'VAR', 'PROCEDURE', 'MODULE', 'BEGIN', 'END', 'FINALLY', 'EXCEPT']);
+const OPENERS = new Set(['BEGIN', 'IF', 'CASE', 'LOOP', 'WHILE', 'FOR', 'WITH', 'RECORD', 'MODULE']);
+
+function rangeOf(t: Token): M2Range {
+  return { startLine: t.line, startCh: t.ch, endLine: t.endLine, endCh: t.endCh };
+}
+
+function posLe(aL: number, aC: number, bL: number, bC: number): boolean {
+  return aL < bL || (aL === bL && aC <= bC);
+}
+
+export function rangeContains(r: M2Range, line: number, ch: number): boolean {
+  return posLe(r.startLine, r.startCh, line, ch) && posLe(line, ch, r.endLine, r.endCh);
+}
+
+class Parser {
+  private pos = 0;
+  constructor(private tokens: Token[], private text: string) {}
+
+  private peek(off = 0): Token {
+    return this.tokens[Math.min(this.pos + off, this.tokens.length - 1)];
+  }
+
+  private next(): Token {
+    const t = this.peek();
+    if (this.pos < this.tokens.length - 1) this.pos++;
+    return t;
+  }
+
+  private atKw(word: string): boolean {
+    const t = this.peek();
+    return t.kind === 'keyword' && t.text === word;
+  }
+
+  private atSym(sym: string): boolean {
+    const t = this.peek();
+    return t.kind === 'symbol' && t.text === sym;
+  }
+
+  private eatKw(word: string): boolean {
+    if (this.atKw(word)) { this.next(); return true; }
+    return false;
+  }
+
+  private eatSym(sym: string): boolean {
+    if (this.atSym(sym)) { this.next(); return true; }
+    return false;
+  }
+
+  private expectIdent(): Token | null {
+    const t = this.peek();
+    if (t.kind === 'ident') return this.next();
+    return null;
+  }
+
+  /** Raw source between two tokens (for detail/type text). */
+  private slice(a: Token, b: Token): string {
+    return this.text.slice(a.offset, b.endOffset);
+  }
+
+  parseUnit(filePath: string): M2Unit {
+    const definition = this.atKw('DEFINITION');
+    if (definition) this.next();
+    this.eatKw('IMPLEMENTATION');
+    let moduleName = '';
+    let startTok: Token = this.peek();
+    if (this.eatKw('MODULE')) {
+      const nm = this.expectIdent();
+      if (nm) { moduleName = nm.text; startTok = nm; }
+    }
+    // FOR "..." in definition modules, priority [..], then ';'
+    if (this.atKw('FOR')) {
+      this.next();
+      if (this.peek().kind === 'string') this.next();
+    }
+    if (this.atSym('[')) this.skipBalanced('[', ']');
+    this.eatSym(';');
+
+    const unit: M2Unit = { filePath, moduleName, definition, symbols: [], imports: [] };
+    const modSym: M2Symbol = {
+      name: moduleName, kind: 'module', detail: (definition ? 'DEFINITION MODULE ' : 'MODULE ') + moduleName,
+      nameRange: rangeOf(startTok), extent: rangeOf(startTok), children: [],
+    };
+
+    this.parseImportSeq(unit);
+    if (!definition) {
+      if (this.atKw('EXPORT')) this.skipToSym(';');
+      this.parseBlock(modSym, unit, true);
+      this.eatSym('.');
+    } else {
+      if (this.atKw('EXPORT')) this.skipToSym(';');
+      this.parseDeclarations(modSym, unit, true);
+    }
+    // Module extent runs to end of file.
+    const last = this.tokens[this.tokens.length - 1];
+    modSym.extent = {
+      startLine: modSym.extent.startLine, startCh: modSym.extent.startCh,
+      endLine: last.endLine, endCh: last.endCh,
+    };
+    unit.symbols.push(modSym);
+    return unit;
+  }
+
+  private parseImportSeq(unit: M2Unit): void {
+    for (;;) {
+      if (this.atKw('FROM')) {
+        this.next();
+        const m = this.expectIdent();
+        if (!m || !this.eatKw('IMPORT')) { this.syncDecl(); continue; }
+        const names = this.parseIdentList();
+        this.eatSym(';');
+        if (m) unit.imports.push({ module: m.text, names, all: false });
+      } else if (this.atKw('IMPORT')) {
+        this.next();
+        const names = this.parseIdentList();
+        this.eatSym(';');
+        for (const n of names) unit.imports.push({ module: n, names: [], all: true });
+      } else {
+        return;
+      }
+    }
+  }
+
+  private parseIdentList(): string[] {
+    const names: string[] = [];
+    for (;;) {
+      const t = this.expectIdent();
+      if (!t) break;
+      names.push(t.text);
+      if (!this.eatSym(',')) break;
+    }
+    return names;
+  }
+
+  /** Declarations up to BEGIN / END / FINALLY / EOF.
+   *  In definition modules, procedures are headings without bodies. */
+  private parseDeclarations(parent: M2Symbol, unit: M2Unit, defOnly = false): void {
+    for (;;) {
+      if (this.atKw('CONST')) {
+        this.next();
+        this.parseConstSection(parent);
+      } else if (this.atKw('TYPE')) {
+        this.next();
+        this.parseTypeSection(parent);
+      } else if (this.atKw('VAR')) {
+        this.next();
+        this.parseVarSection(parent, 'variable');
+      } else if (this.atKw('PROCEDURE')) {
+        const p = this.parseProcedure(unit, !defOnly);
+        if (p) parent.children.push(p);
+        else this.syncDecl();
+        this.eatSym(';');
+      } else if (this.atKw('MODULE')) {
+        if (defOnly) { this.syncDecl(); continue; }
+        const m = this.parseLocalModule(unit);
+        if (m) parent.children.push(m);
+        else this.syncDecl();
+        this.eatSym(';');
+      } else {
+        return;
+      }
+    }
+  }
+
+  private parseConstSection(parent: M2Symbol): void {
+    while (this.peek().kind === 'ident') {
+      const nm = this.next();
+      if (!this.eatSym('=')) { this.syncDecl(); continue; }
+      const start = this.peek();
+      this.skipToSymAtDepth0(';');
+      const detail = `${nm.text} = ${this.rawText(start, this.prev()).trim()}`;
+      parent.children.push({
+        name: nm.text, kind: 'constant', detail,
+        nameRange: rangeOf(nm), extent: rangeOf(nm), children: [],
+      });
+      this.eatSym(';');
+    }
+  }
+
+  private parseTypeSection(parent: M2Symbol): void {
+    while (this.peek().kind === 'ident') {
+      const nm = this.next();
+      if (this.eatSym('=')) {
+        const { text, fields } = this.captureType();
+        this.skipAlignment();
+        parent.children.push({
+          name: nm.text, kind: 'type', detail: `${nm.text} = ${text}`,
+          nameRange: rangeOf(nm), extent: rangeOf(nm), children: fields,
+        });
+      }
+      if (!this.eatSym(';')) this.syncDecl();
+    }
+  }
+
+  private parseVarSection(parent: M2Symbol, kind: 'variable' | 'field'): void {
+    while (this.peek().kind === 'ident') {
+      const names: Token[] = [];
+      const first = this.expectIdent();
+      if (!first) break;
+      names.push(first);
+      while (this.eatSym(',')) {
+        const n = this.expectIdent();
+        if (!n) break;
+        names.push(n);
+      }
+      if (!this.eatSym(':')) { this.syncDecl(); continue; }
+      const { text, fields } = this.captureType();
+      this.skipAlignment();
+      for (const n of names) {
+        const sym: M2Symbol = {
+          name: n.text, kind, detail: `${n.text} : ${text}`,
+          nameRange: rangeOf(n), extent: rangeOf(n), children: [],
+        };
+        // Attach record fields so `var.field` completion works best-effort.
+        for (const f of fields) sym.children.push({ ...f, children: [] });
+        parent.children.push(sym);
+      }
+      if (!this.eatSym(';')) { this.syncDecl(); break; }
+    }
+  }
+
+  /** Capture a type expression up to ';' at depth 0; collect RECORD fields. */
+  private captureType(): { text: string; fields: M2Symbol[] } {
+    const startIdx = this.pos;
+    const fields: M2Symbol[] = [];
+    let depth = 0;       // ( [ {
+    let recDepth = 0;    // RECORD..END nesting
+    let prevSignificant: Token | null = null;
+    const fieldCands: Token[] = [];
+    while (!this.atEof()) {
+      const t = this.peek();
+      if (t.kind === 'symbol') {
+        if (t.text === '(' || t.text === '[' || t.text === '{') depth++;
+        else if (t.text === ')' || t.text === ']' || t.text === '}') depth = Math.max(0, depth - 1);
+        else if (t.text === ';' && depth === 0 && recDepth === 0) break;
+      } else if (t.kind === 'keyword') {
+        if (t.text === 'RECORD' && depth === 0) recDepth++;
+        else if (t.text === 'END' && depth === 0 && recDepth > 0) recDepth--;
+        else if ((t.text === 'CONST' || t.text === 'TYPE' || t.text === 'VAR' ||
+                  t.text === 'PROCEDURE' || t.text === 'MODULE' || t.text === 'BEGIN' ||
+                  t.text === 'FINALLY') && depth === 0 && recDepth === 0) break;
+      } else if (t.kind === 'ident' && recDepth > 0 && depth === 0) {
+        // Candidate record field: ident followed by ',' or ':' at field level.
+        const nx = this.peek(1);
+        const afterCase = prevSignificant !== null && prevSignificant.kind === 'keyword' &&
+          (prevSignificant.text === 'CASE' || prevSignificant.text === 'OF');
+        if (!afterCase && nx.kind === 'symbol' && (nx.text === ',' || nx.text === ':')) {
+          fieldCands.push(t);
+        }
+      }
+      if (t.kind !== 'eof') prevSignificant = t;
+      this.next();
+    }
+    // Promote candidates that precede ':' (name lists) to fields.
+    for (let k = 0; k < fieldCands.length; k++) {
+      const t = fieldCands[k];
+      fields.push({
+        name: t.text, kind: 'field', detail: t.text,
+        nameRange: rangeOf(t), extent: rangeOf(t), children: [],
+      });
+    }
+    const endTok = this.peek();
+    const text = this.rawRange(startIdx, this.pos).trim().replace(/\s+/g, ' ');
+    void endTok;
+    return { text, fields };
+  }
+
+  private skipAlignment(): void {
+    if (this.atSym('<*')) {
+      this.next();
+      let depth = 1;
+      while (!this.atEof() && depth > 0) {
+        if (this.atSym('<*')) depth++;
+        else if (this.atSym('*>')) depth--;
+        this.next();
+      }
+    }
+  }
+
+  private parseProcedure(unit: M2Unit, withBody = true): M2Symbol | null {
+    const kw = this.peek();
+    this.next(); // PROCEDURE
+    // GNU builtin attributes: __ATTRIBUTE__ __BUILTIN__ (( ident )) | __INLINE__
+    if (this.peek().text === '__ATTRIBUTE__') {
+      this.next();
+      if (this.peek().text === '__BUILTIN__') {
+        this.next();
+        if (this.atSym('(')) this.skipBalanced('(', ')');
+      }
+    } else if (this.peek().text === '__INLINE__') {
+      this.next();
+    }
+    const nm = this.expectIdent();
+    if (!nm) return null;
+    const proc: M2Symbol = {
+      name: nm.text, kind: 'procedure', detail: `PROCEDURE ${nm.text}`,
+      nameRange: rangeOf(nm), extent: rangeOf(kw), children: [],
+    };
+    if (this.atSym('(')) this.parseFormalParams(proc, false);
+    else if (this.atSym('[')) this.parseFormalParams(proc, true);
+    if (this.atSym(':')) {
+      this.next();
+      this.eatSym('[');
+      const rt: string[] = [];
+      while (this.peek().kind === 'ident') {
+        rt.push(this.next().text);
+        if (!this.eatSym('.')) break;
+      }
+      this.eatSym(']');
+      if (rt.length > 0) proc.detail += `: ${rt.join('.')}`;
+    }
+    this.skipAlignment();
+    if (this.atSym(';')) this.next();
+    else { this.syncDecl(); }
+    if (!withBody) {
+      const endTok = this.prevTok();
+      proc.extent = {
+        startLine: proc.extent.startLine, startCh: proc.extent.startCh,
+        endLine: endTok.endLine, endCh: endTok.endCh,
+      };
+      return proc;
+    }
+    this.parseBlock(proc, unit, false);
+    if (this.peek().kind === 'ident') this.next(); // closing name
+    const endTok = this.prevTok();
+    proc.extent = {
+      startLine: proc.extent.startLine, startCh: proc.extent.startCh,
+      endLine: endTok.endLine, endCh: endTok.endCh,
+    };
+    return proc;
+  }
+
+  /** Formal parameter list; bracketed form holds GNU optional args. */
+  private parseFormalParams(proc: M2Symbol, bracket: boolean): void {
+    const open = bracket ? '[' : '(';
+    const close = bracket ? ']' : ')';
+    this.next(); // open
+    if (bracket && this.atSym(']')) { this.next(); return; }
+    if (!bracket && this.atSym(')')) { this.next(); return; }
+    for (;;) {
+      if (this.atSym(close) || this.atEof()) { this.eatSym(close); return; }
+      if (this.atSym('...')) { this.next(); }
+      else if (this.peek().text === '...') { this.next(); }
+      else if (this.atKw('VAR')) {
+        this.next();
+        this.parseParamNames(proc, 'parameter');
+      } else if (this.atSym('[')) {
+        // GNU optional argument: [ name : type [= expr] ]
+        this.next();
+        const n = this.expectIdent();
+        let dtype = '';
+        if (n && this.eatSym(':')) dtype = this.captureType().text;
+        if (this.atSym('=')) { this.next(); this.skipBalancedUpTo([']', ';']); }
+        if (n) {
+          proc.children.push({
+            name: n.text, kind: 'parameter',
+            detail: dtype ? `${n.text} : ${dtype} (optional)` : `${n.text} (optional)`,
+            nameRange: rangeOf(n), extent: rangeOf(n), children: [],
+          });
+        }
+        this.eatSym(']');
+      } else if (this.peek().kind === 'ident') {
+        this.parseParamNames(proc, 'parameter');
+      } else {
+        this.next();
+      }
+      if (this.atSym(';')) { this.next(); continue; }
+      if (this.atSym(close)) { this.next(); return; }
+      if (this.atSym(',')) { this.next(); continue; }
+      // Recovery inside parameter lists.
+      if (this.atKw('CONST') || this.atKw('TYPE') || this.atKw('VAR') ||
+          this.atKw('BEGIN') || this.atKw('END') || this.atEof()) return;
+      this.next();
+    }
+  }
+
+  private parseParamNames(proc: M2Symbol, kind: 'parameter'): void {
+    const names: Token[] = [];
+    for (;;) {
+      const n = this.expectIdent();
+      if (!n) return;
+      names.push(n);
+      if (!this.eatSym(',')) break;
+    }
+    let dtype = '';
+    if (this.eatSym(':')) {
+      // FormalType: { ARRAY OF } Qualident
+      const parts: string[] = [];
+      while (this.atKw('ARRAY')) { parts.push('ARRAY'); this.next(); this.eatKw('OF'); parts.push('OF'); }
+      while (this.peek().kind === 'ident') {
+        parts.push(this.next().text);
+        if (!this.eatSym('.')) break;
+      }
+      dtype = parts.join(' ');
+    }
+    this.skipAlignment();
+    for (const n of names) {
+      proc.children.push({
+        name: n.text, kind, detail: dtype ? `${n.text} : ${dtype}` : n.text,
+        nameRange: rangeOf(n), extent: rangeOf(n), children: [],
+      });
+    }
+  }
+
+  private parseLocalModule(unit: M2Unit): M2Symbol | null {
+    const kw = this.peek();
+    this.next(); // MODULE
+    const nm = this.expectIdent();
+    if (!nm) return null;
+    const mod: M2Symbol = {
+      name: nm.text, kind: 'module', detail: `MODULE ${nm.text}`,
+      nameRange: rangeOf(nm), extent: rangeOf(kw), children: [],
+    };
+    if (this.atSym('[')) this.skipBalanced('[', ']');
+    this.eatSym(';');
+    this.parseImportSeq(unit);
+    if (this.atKw('EXPORT')) this.skipToSym(';');
+    this.parseBlock(mod, unit, true);
+    if (this.peek().kind === 'ident') this.next();
+    const endTok = this.prevTok();
+    mod.extent = {
+      startLine: mod.extent.startLine, startCh: mod.extent.startCh,
+      endLine: endTok.endLine, endCh: endTok.endCh,
+    };
+    return mod;
+  }
+
+  /** Block = { declarations } [BEGIN body] [FINALLY body] END. */
+  private parseBlock(parent: M2Symbol, unit: M2Unit, _isModule: boolean): void {
+    this.parseDeclarations(parent, unit);
+    if (this.atKw('BEGIN')) {
+      this.next();
+      this.skipToMatchingEnd();
+    }
+    if (this.atKw('FINALLY')) {
+      this.next();
+      this.skipToMatchingEnd();
+    }
+    if (this.atKw('EXCEPT')) { // tolerate EXCEPT part inside body skipping
+      this.next();
+      this.skipToMatchingEnd();
+    }
+    this.eatKw('END');
+  }
+
+  /** Skip until the END matching an already-consumed opener. */
+  private skipToMatchingEnd(): void {
+    let depth = 1;
+    while (!this.atEof() && depth > 0) {
+      const t = this.next();
+      if (t.kind === 'keyword' && OPENERS.has(t.text)) depth++;
+      else if (t.kind === 'keyword' && t.text === 'END') depth--;
+    }
+  }
+
+  private skipBalanced(open: string, close: string): void {
+    if (!this.eatSym(open)) return;
+    let depth = 1;
+    while (!this.atEof() && depth > 0) {
+      if (this.atSym(open)) depth++;
+      else if (this.atSym(close)) depth--;
+      this.next();
+    }
+  }
+
+  private skipBalancedUpTo(stops: string[]): void {
+    let depth = 0;
+    while (!this.atEof()) {
+      const t = this.peek();
+      if (t.kind === 'symbol') {
+        if (t.text === '(' || t.text === '[' || t.text === '{') depth++;
+        else if (t.text === ')' || t.text === ']' || t.text === '}') {
+          if (depth === 0 && stops.includes(t.text)) return;
+          depth = Math.max(0, depth - 1);
+        } else if (depth === 0 && stops.includes(t.text)) return;
+      }
+      this.next();
+    }
+  }
+
+  private skipToSym(sym: string): void {
+    while (!this.atEof() && !this.atSym(sym)) this.next();
+    this.eatSym(sym);
+  }
+
+  private skipToSymAtDepth0(sym: string): void {
+    let depth = 0;
+    while (!this.atEof()) {
+      const t = this.peek();
+      if (t.kind === 'symbol') {
+        if (t.text === '(' || t.text === '[' || t.text === '{') depth++;
+        else if (t.text === ')' || t.text === ']' || t.text === '}') depth = Math.max(0, depth - 1);
+        else if (t.text === sym && depth === 0) return;
+      }
+      this.next();
+    }
+  }
+
+  /** Skip to a plausible declaration boundary. */
+  private syncDecl(): void {
+    while (!this.atEof()) {
+      const t = this.peek();
+      if (t.kind === 'symbol' && t.text === ';') { this.next(); return; }
+      if (t.kind === 'keyword' && SECTION.has(t.text)) return;
+      this.next();
+    }
+  }
+
+  private atEof(): boolean {
+    return this.peek().kind === 'eof';
+  }
+
+  private prevTok(): Token {
+    return this.tokens[Math.max(0, Math.min(this.pos - 1, this.tokens.length - 1))];
+  }
+
+  private prev(): Token {
+    return this.prevTok();
+  }
+
+  private rawRange(from: number, to: number): string {
+    const toks = this.tokens.slice(from, to).filter(t => t.kind !== 'eof');
+    if (toks.length === 0) return '';
+    return this.text.slice(toks[0].offset, toks[toks.length - 1].endOffset);
+  }
+
+  private rawText(_a: Token, _b: Token): string {
+    return this.text.slice(_a.offset, _b.endOffset);
+  }
+}
+
+export function parseUnitText(text: string, filePath: string): M2Unit {
+  const parser = new Parser(lex(text), text);
+  return parser.parseUnit(filePath);
+}

+ 203 - 0
extensions/modula2-language/src/m2/resolve.ts

@@ -0,0 +1,203 @@
+/** Name resolution over parsed Modula-2 units (Phase 2).
+ *
+ *  Provides scope-aware lookup for hover/definition/completion, including
+ *  cross-file resolution of imported definition modules (`X.def` next to
+ *  the importing file). Best-effort: unresolved names yield null/empty. */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import { M2Import, M2Symbol, M2Unit, parseUnitText, rangeContains } from './parser';
+
+export interface M2Location {
+  filePath: string;
+  startLine: number; startCh: number;
+  endLine: number; endCh: number;
+}
+
+interface FlatEntry {
+  sym: M2Symbol;
+  /** Innermost-first chain of enclosing container symbols. */
+  chain: M2Symbol[];
+}
+
+function flatten(syms: M2Symbol[], chain: M2Symbol[], out: FlatEntry[]): void {
+  for (const s of syms) {
+    out.push({ sym: s, chain: [...chain] });
+    if (s.children.length > 0) flatten(s.children, [...chain, s], out);
+  }
+}
+
+export function flattenUnit(unit: M2Unit): FlatEntry[] {
+  const out: FlatEntry[] = [];
+  flatten(unit.symbols, [], out);
+  return out;
+}
+
+/** Innermost-first scope chain (containers whose extent holds the position). */
+export function scopeChainAt(unit: M2Unit, line: number, ch: number): M2Symbol[] {
+  const chain: M2Symbol[] = [];
+  function visit(syms: M2Symbol[]): void {
+    for (const s of syms) {
+      if ((s.kind === 'module' || s.kind === 'procedure') && rangeContains(s.extent, line, ch)) {
+        chain.push(s);
+        visit(s.children);
+        return;
+      }
+    }
+  }
+  visit(unit.symbols);
+  return chain;
+}
+
+/** All symbols visible at a position: innermost scope outward, then imports. */
+export function visibleSymbols(unit: M2Unit, line: number, ch: number): M2Symbol[] {
+  const seen = new Set<string>();
+  const out: M2Symbol[] = [];
+  const chain = scopeChainAt(unit, line, ch);
+  // Innermost scope first: its children, excluding containers handled below.
+  for (let k = chain.length - 1; k >= 0; k--) {
+    for (const c of chain[k].children) {
+      if (!seen.has(c.name)) { seen.add(c.name); out.push(c); }
+    }
+  }
+  // Imported names.
+  for (const imp of unit.imports) {
+    if (imp.all) {
+      if (!seen.has(imp.module)) {
+        seen.add(imp.module);
+        out.push({
+          name: imp.module, kind: 'module', detail: `IMPORT ${imp.module}`,
+          nameRange: { startLine: 0, startCh: 0, endLine: 0, endCh: 0 },
+          extent: { startLine: 0, startCh: 0, endLine: 0, endCh: 0 },
+          children: [],
+        });
+      }
+    } else {
+      for (const n of imp.names) {
+        if (!seen.has(n)) {
+          seen.add(n);
+          out.push({
+            name: n, kind: 'variable', detail: `${n} (from ${imp.module})`,
+            nameRange: { startLine: 0, startCh: 0, endLine: 0, endCh: 0 },
+            extent: { startLine: 0, startCh: 0, endLine: 0, endCh: 0 },
+            children: [],
+          });
+        }
+      }
+    }
+  }
+  return out;
+}
+
+// ---- Cross-file .def cache ----
+
+interface CacheEntry { mtimeMs: number; unit: M2Unit; }
+
+const defCache = new Map<string, CacheEntry>();
+
+export function loadDefModule(dir: string, moduleName: string): M2Unit | null {
+  const filePath = path.join(dir, `${moduleName}.def`);
+  try {
+    const st = fs.statSync(filePath);
+    const hit = defCache.get(filePath);
+    if (hit && hit.mtimeMs === st.mtimeMs) return hit.unit;
+    const text = fs.readFileSync(filePath, 'utf8');
+    const unit = parseUnitText(text, filePath);
+    defCache.set(filePath, { mtimeMs: st.mtimeMs, unit });
+    return unit;
+  } catch {
+    return null;
+  }
+}
+
+function topLevel(unit: M2Unit, name: string): M2Symbol | null {
+  for (const s of unit.symbols) {
+    for (const c of s.children) {
+      if (c.name === name && c.kind !== 'module') return c;
+    }
+    if (s.name === name) return s;
+  }
+  return null;
+}
+
+/** Resolve a same-file visible name to its declaration. */
+export function resolveLocal(unit: M2Unit, line: number, ch: number, name: string): M2Symbol | null {
+  const chain = scopeChainAt(unit, line, ch);
+  for (let k = chain.length - 1; k >= 0; k--) {
+    const found = chain[k].children.find(c => c.name === name);
+    if (found) return found;
+  }
+  // Module-level fallback.
+  for (const s of unit.symbols) {
+    const found = s.children.find(c => c.name === name);
+    if (found) return found;
+  }
+  return null;
+}
+
+export interface Resolved {
+  sym: M2Symbol;
+  filePath: string;
+}
+
+/** Resolve `name` or `Module.name` at a document position. */
+export function resolveName(
+  unit: M2Unit, docDir: string, line: number, ch: number,
+  qualifier: string | null, name: string,
+): Resolved | null {
+  if (qualifier) {
+    const imp: M2Import | undefined = unit.imports.find(i => i.module === qualifier);
+    const fromFile = imp ? imp.module : qualifier;
+    const def = loadDefModule(docDir, fromFile);
+    if (!def) return null;
+    const sym = topLevel(def, name);
+    return sym ? { sym, filePath: def.filePath } : null;
+  }
+  const local = resolveLocal(unit, line, ch, name);
+  if (local) return { sym: local, filePath: unit.filePath };
+  // FROM-imported name: look into the defining .def.
+  for (const imp of unit.imports) {
+    if (!imp.all && imp.names.includes(name)) {
+      const def = loadDefModule(docDir, imp.module);
+      if (!def) continue;
+      const sym = topLevel(def, name);
+      if (sym) return { sym, filePath: def.filePath };
+    }
+  }
+  return null;
+}
+
+/** Exported symbols of a module (for `Module.` completion). */
+export function moduleExports(docDir: string, moduleName: string): M2Symbol[] {
+  const def = loadDefModule(docDir, moduleName);
+  if (!def) return [];
+  const out: M2Symbol[] = [];
+  for (const s of def.symbols) {
+    for (const c of s.children) {
+      if (c.kind !== 'module') out.push(c);
+    }
+  }
+  return out;
+}
+
+/** Find a record type's fields by type name (best-effort, same file then imports). */
+export function recordFieldsOf(unit: M2Unit, docDir: string, typeName: string): M2Symbol[] {
+  const local = topLevel(unit, typeName);
+  if (local && local.kind === 'type' && local.children.length > 0) return local.children;
+  for (const imp of unit.imports) {
+    const def = imp.all ? loadDefModule(docDir, imp.module)
+      : (imp.names.length > 0 ? loadDefModule(docDir, imp.module) : null);
+    if (!def) continue;
+    const t = topLevel(def, typeName);
+    if (t && t.kind === 'type' && t.children.length > 0) return t.children;
+  }
+  return [];
+}
+
+export function toLocation(filePath: string, sym: M2Symbol): M2Location {
+  return {
+    filePath,
+    startLine: sym.nameRange.startLine, startCh: sym.nameRange.startCh,
+    endLine: sym.nameRange.endLine, endCh: sym.nameRange.endCh,
+  };
+}

+ 217 - 2
extensions/modula2-language/src/server.ts

@@ -1,13 +1,20 @@
 import {
   createConnection, ProposedFeatures, InitializeParams, InitializeResult,
   TextDocuments, TextDocumentSyncKind, Diagnostic, DiagnosticSeverity,
-  DidChangeConfigurationParams
+  DidChangeConfigurationParams, CompletionItem, CompletionItemKind,
+  DocumentSymbol, SymbolKind, Hover, Location
 } from 'vscode-languageserver/node';
 import { TextDocument } from 'vscode-languageserver-textdocument';
 import * as path from 'path';
 import * as os from 'os';
 import { promises as fs } from 'fs';
 import { spawn } from 'child_process';
+import { pathToFileURL } from 'url';
+import { lex, Token } from './m2/lexer';
+import { M2Symbol, M2Unit, parseUnitText } from './m2/parser';
+import {
+  moduleExports, recordFieldsOf, resolveName, toLocation, visibleSymbols,
+} from './m2/resolve';
 
 const connection = createConnection(ProposedFeatures.all);
 const documents = new TextDocuments(TextDocument);
@@ -18,7 +25,15 @@ connection.onInitialize((params: InitializeParams): InitializeResult => {
   const options = (params.initializationOptions || {}) as { validatorCommand?: string; validatorArguments?: string[] };
   validatorCommand = options.validatorCommand || '';
   validatorArguments = options.validatorArguments || ['{file}'];
-  return { capabilities: { textDocumentSync: TextDocumentSyncKind.Full } };
+  return {
+    capabilities: {
+      textDocumentSync: TextDocumentSyncKind.Full,
+      completionProvider: { triggerCharacters: ['.'] },
+      hoverProvider: true,
+      definitionProvider: true,
+      documentSymbolProvider: true,
+    },
+  };
 });
 
 connection.onDidChangeConfiguration((params: DidChangeConfigurationParams) => {
@@ -92,5 +107,205 @@ function uriToFilePath(uri: string): string {
   return uri;
 }
 
+// ---- Phase 2: AST-backed language intelligence ----
+
+const SERVER_KEYWORDS = [
+  'AND', 'ARRAY', 'BEGIN', 'BY', 'CASE', 'CONST', 'DEFINITION', 'DIV',
+  'DO', 'ELSE', 'ELSIF', 'END', 'EXIT', 'EXPORT', 'FOR', 'FROM', 'IF',
+  'IMPLEMENTATION', 'IMPORT', 'IN', 'LOOP', 'MOD', 'MODULE', 'NOT', 'OF',
+  'OR', 'POINTER', 'PROCEDURE', 'QUALIFIED', 'RECORD', 'REPEAT', 'RETURN',
+  'SET', 'THEN', 'TO', 'TYPE', 'UNTIL', 'VAR', 'WHILE', 'WITH',
+];
+
+function parseDocument(document: TextDocument): { unit: M2Unit; tokens: Token[] } {
+  const text = document.getText();
+  const filePath = uriToFilePath(document.uri);
+  return { unit: parseUnitText(text, filePath), tokens: lex(text) };
+}
+
+function tokenAt(tokens: Token[], offset: number): Token | null {
+  for (const t of tokens) {
+    if (t.kind === 'eof') break;
+    if (t.offset <= offset && offset <= t.endOffset) return t;
+  }
+  return null;
+}
+
+/** Extract `Qualifier.name` (or plain `name`) at a document offset. */
+function nameAt(tokens: Token[], offset: number): { qualifier: string | null; name: string } | null {
+  for (let k = 0; k < tokens.length; k++) {
+    const t = tokens[k];
+    if (t.kind === 'eof') break;
+    if (!(t.offset <= offset && offset <= t.endOffset)) continue;
+    if (t.kind !== 'ident') return null;
+    // Cursor on the qualifier part: A|.B
+    if (tokens[k + 1]?.text === '.' && tokens[k + 2]?.kind === 'ident') {
+      return { qualifier: null, name: t.text };
+    }
+    // Cursor on the qualified part: A.|B  (or second ident after dot)
+    if (t.text !== undefined && tokens[k - 1]?.text === '.' && tokens[k - 2]?.kind === 'ident') {
+      return { qualifier: tokens[k - 2].text, name: t.text };
+    }
+    return { qualifier: null, name: t.text };
+  }
+  return null;
+}
+
+function lspKind(kind: M2Symbol['kind']): SymbolKind {
+  switch (kind) {
+    case 'module': return SymbolKind.Module;
+    case 'procedure': return SymbolKind.Function;
+    case 'variable': return SymbolKind.Variable;
+    case 'parameter': return SymbolKind.Variable;
+    case 'field': return SymbolKind.Field;
+    case 'constant': return SymbolKind.Constant;
+    case 'type': return SymbolKind.Class;
+  }
+}
+
+function completionKind(kind: M2Symbol['kind']): CompletionItemKind {
+  switch (kind) {
+    case 'module': return CompletionItemKind.Module;
+    case 'procedure': return CompletionItemKind.Function;
+    case 'variable': return CompletionItemKind.Variable;
+    case 'parameter': return CompletionItemKind.Variable;
+    case 'field': return CompletionItemKind.Field;
+    case 'constant': return CompletionItemKind.Constant;
+    case 'type': return CompletionItemKind.Class;
+  }
+}
+
+function toDocumentSymbol(s: M2Symbol): DocumentSymbol {
+  return {
+    name: s.name,
+    kind: lspKind(s.kind),
+    range: {
+      start: { line: s.extent.startLine, character: s.extent.startCh },
+      end: { line: s.extent.endLine, character: s.extent.endCh },
+    },
+    selectionRange: {
+      start: { line: s.nameRange.startLine, character: s.nameRange.startCh },
+      end: { line: s.nameRange.endLine, character: s.nameRange.endCh },
+    },
+    detail: s.detail,
+    children: s.children
+      .filter(c => c.kind === 'procedure' || c.kind === 'module' || c.kind === 'type')
+      .map(toDocumentSymbol),
+  };
+}
+
+connection.onDocumentSymbol(params => {
+  const document = documents.get(params.textDocument.uri);
+  if (!document) return [];
+  const { unit } = parseDocument(document);
+  const out: DocumentSymbol[] = [];
+  for (const s of unit.symbols) {
+    for (const c of s.children) out.push(toDocumentSymbol(c));
+    if (s.children.length === 0 && s.name) out.push(toDocumentSymbol(s));
+  }
+  return out;
+});
+
+connection.onCompletion(params => {
+  const document = documents.get(params.textDocument.uri);
+  if (!document) return [];
+  const { unit } = parseDocument(document);
+  const filePath = uriToFilePath(document.uri);
+  const docDir = path.dirname(filePath);
+  const offset = document.offsetAt(params.position);
+  const prefix = document.getText().slice(0, offset);
+  const dotMatch = /([A-Za-z_][A-Za-z0-9_]*)\.\s*[A-Za-z_0-9]*$/.exec(prefix);
+
+  if (dotMatch) {
+    const qualifier = dotMatch[1];
+    // Imported module: offer its exports.
+    const exps = moduleExports(docDir, qualifier);
+    if (exps.length > 0) {
+      return exps.map(s => ({
+        label: s.name, kind: completionKind(s.kind), detail: s.detail,
+      } as CompletionItem));
+    }
+    // Variable of record type: offer fields (inline or via named type).
+    const local = unit.symbols.flatMap(s => s.children).find(c => c.name === qualifier);
+    if (local) {
+      if (local.children.length > 0) {
+        return local.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(local.detail);
+      if (typeMatch) {
+        const fields = recordFieldsOf(unit, docDir, typeMatch[1]);
+        if (fields.length > 0) {
+          return fields.map(f => ({
+            label: f.name, kind: CompletionItemKind.Field, detail: f.detail,
+          } as CompletionItem));
+        }
+      }
+    }
+    // Bare type name with record fields.
+    const fields = recordFieldsOf(unit, docDir, qualifier);
+    if (fields.length > 0) {
+      return fields.map(f => ({
+        label: f.name, kind: CompletionItemKind.Field, detail: f.detail,
+      } as CompletionItem));
+    }
+    return [];
+  }
+
+  const items: CompletionItem[] = visibleSymbols(unit, params.position.line, params.position.character)
+    .map(s => ({ label: s.name, kind: completionKind(s.kind), detail: s.detail } as CompletionItem));
+  for (const kw of SERVER_KEYWORDS) {
+    items.push({ label: kw, kind: CompletionItemKind.Keyword, detail: 'Modula-2 keyword' });
+  }
+  return items;
+});
+
+connection.onHover(params => {
+  const document = documents.get(params.textDocument.uri);
+  if (!document) return null;
+  const { unit, tokens } = parseDocument(document);
+  const offset = document.offsetAt(params.position);
+  const found = nameAt(tokens, offset);
+  if (!found) return null;
+  const docDir = path.dirname(uriToFilePath(document.uri));
+  const resolved = resolveName(unit, docDir, params.position.line, params.position.character, found.qualifier, found.name);
+  if (!resolved) return null;
+  const hover: Hover = {
+    contents: {
+      kind: 'markdown',
+      value: [
+        '```modula2',
+        resolved.sym.detail || resolved.sym.name,
+        '```',
+        `*${resolved.sym.kind}* — \`${path.basename(resolved.filePath)}:${resolved.sym.nameRange.startLine + 1}\``,
+      ].join('\n'),
+    },
+  };
+  return hover;
+});
+
+connection.onDefinition(params => {
+  const document = documents.get(params.textDocument.uri);
+  if (!document) return null;
+  const { unit, tokens } = parseDocument(document);
+  const offset = document.offsetAt(params.position);
+  const found = nameAt(tokens, offset);
+  if (!found) return null;
+  const filePath = uriToFilePath(document.uri);
+  const resolved = resolveName(unit, path.dirname(filePath), params.position.line, params.position.character, found.qualifier, found.name);
+  if (!resolved) return null;
+  const loc = toLocation(resolved.filePath, resolved.sym);
+  const targetUri = resolved.filePath === filePath ? document.uri : pathToFileURL(resolved.filePath).toString();
+  const location: Location = {
+    uri: targetUri,
+    range: {
+      start: { line: loc.startLine, character: loc.startCh },
+      end: { line: loc.endLine, character: loc.endCh },
+    },
+  };
+  return location;
+});
+
 documents.listen(connection);
 connection.listen();