Kaynağa Gözat

Diagnostics: eliminate false positives against real-world code

Validated against the six m2compiler-V3 modules (all gm2-clean):
FileIO went from 222 false positives to zero, all six now clean.
- implementation modules see their own .def; local-module EXPORT
- FORWARD headings are not duplicates; FORWARD not an unknown identifier
- field access after ^/[] (f^.x), flattened multi-index (a[i, j])
- numeric suffixes (0C is CHAR), no backslash escapes in strings
- type arguments to builtins incl. SYSTEM.X, typed set constructors
- opaque types, procedure variables (calls and P := Q), LENGTH/ORDL
- arithmetic preserves named types (CARDINAL + 1 stays CARDINAL)
Eric Streit 1 hafta önce
ebeveyn
işleme
c6a80f0bf4

+ 19 - 0
extensions/modula2-language/DIAGNOSTICS.md

@@ -60,3 +60,22 @@ calibrated case-by-case against `gm2`:
 
 
 Still out of scope: use-before-declaration order, range checking,
 Still out of scope: use-before-declaration order, range checking,
 builtin-procedure signatures, record-field types, `WITH`-block internals.
 builtin-procedure signatures, record-field types, `WITH`-block internals.
+
+## Real-file validation
+
+The checker is validated against a real codebase — the six modules of the
+`m2compiler-V3` compiler (`FileIO`, `SymTab`, `QbeGen`, `M2S`, `M2P`, `M2`),
+all of which `gm2 -fiso` accepts with zero errors. The analyser reports zero
+issues on all six. Getting there exposed and fixed:
+
+- implementation modules see their own `.def` (`FileIO.mod` + `FileIO.def`);
+- `FORWARD` procedure headings (not duplicates of the later definition);
+- field access after `^`/`[]` (`f^.x`, `a[i].x`) and flattened multi-index
+  (`a[i, j]` over nested arrays);
+- octal/hex/char literal suffixes (`0C` is `CHAR`, `177B`/`0FFH` whole);
+- no backslash escapes in strings (`'\'` is one character);
+- type arguments to builtins (`SIZE(T)`, `VAL(T, x)`, `MAX(T)`,
+  `SYSTEM.TSIZE(T)`) and typed set constructors (`BITSET{...}`);
+- opaque types (`TYPE File;`), local-module `EXPORT` visibility,
+  procedure variables (calls and `P := Q` assignment), the pervasive
+  `LENGTH`/`ORDL`, arithmetic that preserves `CARDINAL` (`c + 1`).

+ 17 - 10
extensions/modula2-language/src/m2/analyse.ts

@@ -36,10 +36,10 @@ const BUILTINS = new Set([
   'OCTET',
   'OCTET',
   // Constants
   // Constants
   'NIL', 'TRUE', 'FALSE',
   'NIL', 'TRUE', 'FALSE',
-  // Procedures and functions
+  // Procedures and functions (including the pervasive LENGTH and ORDL).
   'ABS', 'ADR', 'CAP', 'CHR', 'DEC', 'DISPOSE', 'EXCL', 'FLOAT', 'HALT',
   'ABS', 'ADR', 'CAP', 'CHR', 'DEC', 'DISPOSE', 'EXCL', 'FLOAT', 'HALT',
   'HIGH', 'INC', 'INCL', 'LFLOAT', 'MAX', 'MIN', 'NEW', 'ODD', 'ORD',
   'HIGH', 'INC', 'INCL', 'LFLOAT', 'MAX', 'MIN', 'NEW', 'ODD', 'ORD',
-  'SIZE', 'TRUNC', 'TSIZE', 'VAL', 'CODE',
+  'ORDL', 'SIZE', 'TRUNC', 'TSIZE', 'VAL', 'CODE', 'LENGTH',
 ]);
 ]);
 
 
 const OPENERS = new Set(['BEGIN', 'IF', 'CASE', 'LOOP', 'WHILE', 'FOR', 'WITH', 'RECORD', 'MODULE']);
 const OPENERS = new Set(['BEGIN', 'IF', 'CASE', 'LOOP', 'WHILE', 'FOR', 'WITH', 'RECORD', 'MODULE']);
@@ -71,16 +71,21 @@ function duplicateDeclarations(unit: M2Unit): M2Issue[] {
     let seen = scopes.get(scope);
     let seen = scopes.get(scope);
     if (!seen) { seen = new Map(); scopes.set(scope, seen); }
     if (!seen) { seen = new Map(); scopes.set(scope, seen); }
     const first = seen.get(sym.name);
     const first = seen.get(sym.name);
-    if (first) {
-      issues.push({
-        line: sym.nameRange.startLine, ch: sym.nameRange.startCh,
-        endLine: sym.nameRange.endLine, endCh: sym.nameRange.endCh,
-        message: `Duplicate declaration "${sym.name}" (first declared at line ${first.nameRange.startLine + 1})`,
-        severity: 'error',
-      });
-    } else {
+    if (!first) {
       seen.set(sym.name, sym);
       seen.set(sym.name, sym);
+      continue;
+    }
+    // A `FORWARD` heading and its later definition are one declaration.
+    if (first.forward || sym.forward) {
+      if (!first.forward) seen.set(sym.name, sym);
+      continue;
     }
     }
+    issues.push({
+      line: sym.nameRange.startLine, ch: sym.nameRange.startCh,
+      endLine: sym.nameRange.endLine, endCh: sym.nameRange.endCh,
+      message: `Duplicate declaration "${sym.name}" (first declared at line ${first.nameRange.startLine + 1})`,
+      severity: 'error',
+    });
   }
   }
   return issues;
   return issues;
 }
 }
@@ -98,6 +103,8 @@ function unknownIdentifiers(tokens: Token[], unit: M2Unit, docDir: string): M2Is
   for (let k = 0; k < tokens.length; k++) {
   for (let k = 0; k < tokens.length; k++) {
     const t = tokens[k];
     const t = tokens[k];
     if (t.kind !== 'ident') continue;
     if (t.kind !== 'ident') continue;
+    // Reserved words the lexer leaves as identifiers (`FORWARD` headings).
+    if (t.text === 'FORWARD') continue;
     const prev = tokens[k - 1];
     const prev = tokens[k - 1];
     const next = tokens[k + 1];
     const next = tokens[k + 1];
     // Qualified access (`M.X`, `r.field`): unverifiable without deeper analysis.
     // Qualified access (`M.X`, `r.field`): unverifiable without deeper analysis.

+ 102 - 44
extensions/modula2-language/src/m2/checker.ts

@@ -16,8 +16,8 @@ import { Token } from './lexer';
 import { M2Symbol, M2Unit } from './parser';
 import { M2Symbol, M2Unit } from './parser';
 import { resolveName } from './resolve';
 import { resolveName } from './resolve';
 import {
 import {
-  M2Type, UNKNOWN, arithKind, displayType, isArrayArgCompatible, isAssignable,
-  isComparable, isOrdinal, parseTypeText, resolveDeclaredType, resolveQualified,
+  M2Type, UNKNOWN, arithKind, arithResultType, displayType, isArrayArgCompatible, isAssignable,
+  isComparable, isOrdinal, numberLiteralType, parseTypeText, resolveDeclaredType, resolveQualified,
   resolveType, returnTypeOf, unitOfFile,
   resolveType, returnTypeOf, unitOfFile,
 } from './types';
 } from './types';
 import * as path from 'path';
 import * as path from 'path';
@@ -30,7 +30,7 @@ const TYPE_BUILTINS = new Set([
 const PROC_BUILTINS = new Set([
 const PROC_BUILTINS = new Set([
   'ABS', 'ADR', 'CAP', 'CHR', 'DEC', 'DISPOSE', 'EXCL', 'FLOAT', 'HALT',
   'ABS', 'ADR', 'CAP', 'CHR', 'DEC', 'DISPOSE', 'EXCL', 'FLOAT', 'HALT',
   'HIGH', 'INC', 'INCL', 'LFLOAT', 'MAX', 'MIN', 'NEW', 'ODD', 'ORD',
   'HIGH', 'INC', 'INCL', 'LFLOAT', 'MAX', 'MIN', 'NEW', 'ODD', 'ORD',
-  'SIZE', 'TRUNC', 'TSIZE', 'VAL', 'CODE', 'COPY', 'FILL',
+  'ORDL', 'SIZE', 'TRUNC', 'TSIZE', 'VAL', 'CODE', 'COPY', 'FILL', 'LENGTH',
 ]);
 ]);
 
 
 const OPENERS = new Set(['BEGIN', 'IF', 'CASE', 'LOOP', 'WHILE', 'FOR', 'WITH', 'RECORD', 'MODULE']);
 const OPENERS = new Set(['BEGIN', 'IF', 'CASE', 'LOOP', 'WHILE', 'FOR', 'WITH', 'RECORD', 'MODULE']);
@@ -95,6 +95,10 @@ function bodyIndexes(tokens: Token[], sl: number, sc: number, el: number, ec: nu
 class Walker {
 class Walker {
   private pos: number;
   private pos: number;
   private returnType: M2Type | null | undefined;
   private returnType: M2Type | null | undefined;
+  /** Depth of nested builtin-procedure argument lists (type args allowed). */
+  private builtinArgs = 0;
+  /** Parsing the right-hand side of an assignment to a procedure variable. */
+  private expectingProc = false;
   constructor(
   constructor(
     private ctx: Ctx,
     private ctx: Ctx,
     private toks: Token[],
     private toks: Token[],
@@ -212,7 +216,8 @@ class Walker {
     }
     }
     const ret = this.procReturn();
     const ret = this.procReturn();
     const t = this.peek();
     const t = this.peek();
-    if (!t || t.text === ';' || t.kind === 'keyword') {
+    // `;`, `|` (CASE separator), `ELSE`/`END`/etc. terminate a bare RETURN.
+    if (!t || t.text === ';' || t.text === '|' || t.kind === 'keyword') {
       if (ret) this.issue(kw, `RETURN without a value in a function returning ${displayType(ret)}`);
       if (ret) this.issue(kw, `RETURN without a value in a function returning ${displayType(ret)}`);
       return;
       return;
     }
     }
@@ -445,7 +450,14 @@ class Walker {
       tok = nm;
       tok = nm;
       if (info.kind === 'module') {
       if (info.kind === 'module') {
         const q = resolveQualified(this.ctx.unit, this.ctx.docDir, info.name, nm.text);
         const q = resolveQualified(this.ctx.unit, this.ctx.docDir, info.name, nm.text);
-        if (!q) { info = { kind: 'unknown' }; continue; }
+        if (!q) {
+          // Qualified builtin (`SYSTEM.TSIZE`, `SYSTEM.ADR`, ...): the defining
+          // `.def` is usually unavailable, but the name is still a builtin call.
+          info = PROC_BUILTINS.has(nm.text)
+            ? { kind: 'builtinProc', name: nm.text }
+            : { kind: 'unknown' };
+          continue;
+        }
         switch (q.sym.kind) {
         switch (q.sym.kind) {
           case 'variable': case 'parameter': info = { kind: 'var', sym: q.sym, host: q.host, dir: q.dir }; break;
           case 'variable': case 'parameter': info = { kind: 'var', sym: q.sym, host: q.host, dir: q.dir }; break;
           case 'procedure': info = { kind: 'proc', sym: q.sym, host: q.host, dir: q.dir }; break;
           case 'procedure': info = { kind: 'proc', sym: q.sym, host: q.host, dir: q.dir }; break;
@@ -464,17 +476,21 @@ class Walker {
         const open = this.next()!;
         const open = this.next()!;
         const idxTypes = this.parseExprList();
         const idxTypes = this.parseExprList();
         if (this.atSym(']')) this.next();
         if (this.atSym(']')) this.next();
-        if (type.kind === 'array') {
-          for (const it of idxTypes) {
-            if (it.kind !== 'unknown' && !isOrdinal(it)) {
+        // Consume one dimension per index: gm2 flattens `a[i, j]` over
+        // nested arrays (`symSet[0, i]` means `symSet[0][i]`).
+        let badIndex = false;
+        for (const it of idxTypes) {
+          if (type.kind === 'array') {
+            if (!badIndex && it.kind !== 'unknown' && !isOrdinal(it)) {
               this.issue(open, `Array index must have an ordinal type, found ${displayType(it)}`);
               this.issue(open, `Array index must have an ordinal type, found ${displayType(it)}`);
-              break;
+              badIndex = true;
             }
             }
+            type = type.element;
+          } else if (type.kind !== 'unknown') {
+            this.issue(open, `Cannot index ${displayType(type)}`);
+            type = UNKNOWN;
+            break;
           }
           }
-          type = type.element;
-        } else if (type.kind !== 'unknown') {
-          this.issue(open, `Cannot index ${displayType(type)}`);
-          type = UNKNOWN;
         }
         }
       } else if (this.atSym('^')) {
       } else if (this.atSym('^')) {
         const op = this.next()!;
         const op = this.next()!;
@@ -484,10 +500,25 @@ class Walker {
           this.issue(op, `Cannot dereference ${displayType(type)}`);
           this.issue(op, `Cannot dereference ${displayType(type)}`);
           type = UNKNOWN;
           type = UNKNOWN;
         }
         }
+      } else if (this.atSym('.')) {
+        // Field selection after indexing or dereferencing (`f^.x`, `a[i].x`).
+        // (Direct `r.x` is consumed by the qualifier chain above.)
+        this.next(); // .
+        const nm = this.peek();
+        if (nm?.kind === 'ident') this.next();
+        // Field types are untracked: any structured value yields unknown.
+        type = UNKNOWN;
+        info = { kind: 'field' };
       } else {
       } else {
         break;
         break;
       }
       }
     }
     }
+    // Set constructor with explicit type (`BITSET{...}`, `MySet{...}`).
+    if ((info.kind === 'type' || info.kind === 'builtinType') && this.atSym('{')) {
+      this.next();
+      this.parseSetConstructor();
+      return { type: { kind: 'setLit' }, info: { kind: 'unknown' }, tok };
+    }
     // Optional call.
     // Optional call.
     if (this.atSym('(')) {
     if (this.atSym('(')) {
       const call = this.parseCall(info, tok);
       const call = this.parseCall(info, tok);
@@ -504,11 +535,18 @@ class Walker {
       type = call.type;
       type = call.type;
     } else if (mode === 'expr') {
     } else if (mode === 'expr') {
       if (info.kind === 'proc') {
       if (info.kind === 'proc') {
+        // A bare procedure name on the right of a `P := Q` assignment
+        // denotes the procedure itself, not a call.
+        if (this.expectingProc) return { type: { kind: 'proc' }, info, tok };
         const checked = this.checkCall(info, [], tok);
         const checked = this.checkCall(info, [], tok);
         if (checked.isVoid) this.issue(tok, `Procedure "${info.sym.name}" does not return a value`);
         if (checked.isVoid) this.issue(tok, `Procedure "${info.sym.name}" does not return a value`);
         type = checked.type;
         type = checked.type;
       } else if (info.kind === 'type' || info.kind === 'builtinType') {
       } else if (info.kind === 'type' || info.kind === 'builtinType') {
-        this.issue(tok, `"${tok.text}" is a type and cannot be used as a value`);
+        // Type names are legal arguments to builtin procedures
+        // (`SIZE(T)`, `TSIZE(T)`, `VAL(T, x)`, `MAX(T)`).
+        if (this.builtinArgs === 0) {
+          this.issue(tok, `"${tok.text}" is a type and cannot be used as a value`);
+        }
         type = UNKNOWN;
         type = UNKNOWN;
       } else if (info.kind === 'module') {
       } else if (info.kind === 'module') {
         this.issue(tok, `"${tok.text}" is a module and cannot be used as a value`);
         this.issue(tok, `"${tok.text}" is a module and cannot be used as a value`);
@@ -535,12 +573,31 @@ class Walker {
   }
   }
 
 
   private parseCall(info: HeadInfo, tok: Token): { type: M2Type; isVoid: boolean; returnsValue: boolean } {
   private parseCall(info: HeadInfo, tok: Token): { type: M2Type; isVoid: boolean; returnsValue: boolean } {
+    // Builtin procedures (`SIZE`, `VAL`, `MAX`, ...) accept type names and
+    // are otherwise unchecked: parse arguments in lenient mode.
+    if (info.kind === 'builtinProc') {
+      this.next(); // (
+      this.builtinArgs++;
+      try {
+        this.parseExprList();
+        if (this.atSym(')')) this.next();
+      } finally {
+        this.builtinArgs--;
+      }
+      return { type: UNKNOWN, isVoid: false, returnsValue: false };
+    }
     this.next(); // (
     this.next(); // (
     const args = this.parseExprList();
     const args = this.parseExprList();
     if (this.atSym(')')) this.next();
     if (this.atSym(')')) this.next();
     if (info.kind === 'proc') return this.checkCall(info, args, tok);
     if (info.kind === 'proc') return this.checkCall(info, args, tok);
+    // Calls through procedure variables (`handler(x)`): signatures are
+    // untracked, so such calls are unchecked but legal.
+    if (info.kind === 'var' || info.kind === 'const') {
+      const vt = this.valueType(info, tok);
+      if (vt.kind === 'proc') return { type: UNKNOWN, isVoid: false, returnsValue: false };
+    }
     // Builtin procedures, casts through type names and unknown callees: unchecked.
     // Builtin procedures, casts through type names and unknown callees: unchecked.
-    if (info.kind === 'builtinProc' || info.kind === 'type' || info.kind === 'builtinType' ||
+    if (info.kind === 'type' || info.kind === 'builtinType' ||
         info.kind === 'unknown' || info.kind === 'field') {
         info.kind === 'unknown' || info.kind === 'field') {
       return { type: UNKNOWN, isVoid: false, returnsValue: false };
       return { type: UNKNOWN, isVoid: false, returnsValue: false };
     }
     }
@@ -583,8 +640,22 @@ class Walker {
   }
   }
 
 
   private checkAssignment(d: Value, op: Token): void {
   private checkAssignment(d: Value, op: Token): void {
-    const rhs = this.parseExpr(0);
     const info = d.info;
     const info = d.info;
+    // Assignments to procedure variables accept bare procedure designators.
+    const lhsProc = info.kind === 'var' && d.type.kind === 'proc';
+    let rhs: Value;
+    if (lhsProc) {
+      this.expectingProc = true;
+      try {
+        rhs = this.parseExpr(0);
+      } finally {
+        this.expectingProc = false;
+      }
+      if (rhs.type.kind === 'proc' || rhs.type.kind === 'unknown') return;
+      this.issue(op, `Cannot assign ${displayType(rhs.type)} to procedure variable "${d.tok.text}"`);
+      return;
+    }
+    rhs = this.parseExpr(0);
     if (info.kind === 'const' || info.kind === 'literal') {
     if (info.kind === 'const' || info.kind === 'literal') {
       this.issue(d.tok, `Cannot assign to constant "${d.tok.text}"`);
       this.issue(d.tok, `Cannot assign to constant "${d.tok.text}"`);
       return;
       return;
@@ -704,36 +775,25 @@ class Walker {
   }
   }
 
 
   private arithResult(op: Token, a: M2Type, b: M2Type): M2Type {
   private arithResult(op: Token, a: M2Type, b: M2Type): M2Type {
-    const ka = arithKind(a);
-    const kb = arithKind(b);
-    if (ka === 'unknown' || kb === 'unknown') return UNKNOWN;
-    if (ka === 'set' && kb === 'set') return { kind: 'bitset' };
-    if ((ka === 'whole' || ka === 'real') && (kb === 'whole' || kb === 'real')) {
-      if (ka !== kb) {
-        this.issue(op, `Cannot apply "${op.text}" to ${displayType(a)} and ${displayType(b)}`);
-        return UNKNOWN;
-      }
-      if (ka === 'real') {
-        const lit = a.kind === 'real' && b.kind === 'real' && a.literal && b.literal;
-        return { kind: 'real', id: 'REAL', literal: lit };
-      }
-      const lit = a.kind === 'whole' && b.kind === 'whole' && a.literal && b.literal;
-      return { kind: 'whole', id: 'INTEGER', literal: lit };
+    const t = arithResultType(a, b);
+    if (!t) {
+      this.issue(op, `Cannot apply "${op.text}" to ${displayType(a)} and ${displayType(b)}`);
+      return UNKNOWN;
     }
     }
-    this.issue(op, `Cannot apply "${op.text}" to ${displayType(a)} and ${displayType(b)}`);
-    return UNKNOWN;
+    return t;
   }
   }
 
 
   private divResult(op: Token, a: M2Type, b: M2Type): M2Type {
   private divResult(op: Token, a: M2Type, b: M2Type): M2Type {
-    const ka = arithKind(a);
-    const kb = arithKind(b);
-    if (ka === 'unknown' || kb === 'unknown') return UNKNOWN;
-    if (ka === 'whole' && kb === 'whole') {
-      const lit = a.kind === 'whole' && b.kind === 'whole' && a.literal && b.literal;
-      return { kind: 'whole', id: 'INTEGER', literal: lit };
+    const t = arithResultType(a, b);
+    if (!t) {
+      this.issue(op, `Operator "${op.text}" requires whole-number operands`);
+      return UNKNOWN;
     }
     }
-    this.issue(op, `Operator "${op.text}" requires whole-number operands`);
-    return UNKNOWN;
+    if (t.kind !== 'unknown' && arithKind(t) !== 'whole') {
+      this.issue(op, `Operator "${op.text}" requires whole-number operands`);
+      return UNKNOWN;
+    }
+    return t;
   }
   }
 
 
   private checkBoolOperands(op: Token, a: M2Type, b: M2Type): void {
   private checkBoolOperands(op: Token, a: M2Type, b: M2Type): void {
@@ -782,9 +842,7 @@ class Walker {
     if (t.kind === 'number') {
     if (t.kind === 'number') {
       this.next();
       this.next();
       return {
       return {
-        type: /[.eE]/.test(t.text)
-          ? { kind: 'real', id: 'REAL', literal: true }
-          : { kind: 'whole', id: 'INTEGER', literal: true },
+        type: numberLiteralType(t.text),
         info: { kind: 'unknown' }, tok: t,
         info: { kind: 'unknown' }, tok: t,
       };
       };
     }
     }

+ 3 - 2
extensions/modula2-language/src/m2/lexer.ts

@@ -77,13 +77,14 @@ export function lex(text: string): Token[] {
       continue;
       continue;
     }
     }
 
 
-    // String literal (single/double quoted, terminated at end of line)
+    // String literal (single/double quoted, terminated at end of line).
+    // Modula-2 has no backslash escapes: backslash is an ordinary character
+    // (e.g. '\' is a one-character string), so only the quote terminates.
     if (c === "'" || c === '"') {
     if (c === "'" || c === '"') {
       const s = i, sl = line, sc = col;
       const s = i, sl = line, sc = col;
       const quote = c;
       const quote = c;
       advance(1);
       advance(1);
       while (i < text.length && text[i] !== '\n' && text[i] !== '\r') {
       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; }
         if (text[i] === quote) { advance(1); break; }
         advance(1);
         advance(1);
       }
       }

+ 48 - 1
extensions/modula2-language/src/m2/parser.ts

@@ -29,6 +29,10 @@ export interface M2Symbol {
   extent: M2Range;
   extent: M2Range;
   /** Statement body range (BEGIN..END) for modules/procedures with bodies. */
   /** Statement body range (BEGIN..END) for modules/procedures with bodies. */
   body?: M2Range;
   body?: M2Range;
+  /** True for `PROCEDURE ... FORWARD` headings (definition follows later). */
+  forward?: boolean;
+  /** Names this local module exports to its enclosing scope (bare-visible). */
+  exports?: string[];
   children: M2Symbol[];
   children: M2Symbol[];
 }
 }
 
 
@@ -253,6 +257,12 @@ class Parser {
           });
           });
         }
         }
         parent.children.push(typeSym);
         parent.children.push(typeSym);
+      } else {
+        // Opaque type (`TYPE File;`): no expansion, recorded for visibility.
+        parent.children.push({
+          name: nm.text, kind: 'type', detail: nm.text,
+          nameRange: rangeOf(nm), extent: rangeOf(nm), children: [],
+        });
       }
       }
       if (!this.eatSym(';')) this.syncDecl();
       if (!this.eatSym(';')) this.syncDecl();
     }
     }
@@ -300,6 +310,20 @@ class Parser {
     let enumOk = true;
     let enumOk = true;
     let firstTok: Token | null = null;
     let firstTok: Token | null = null;
     let lastTok: Token | null = null;
     let lastTok: Token | null = null;
+    // A leading PROCEDURE introduces a procedure type (`P: PROCEDURE (...): T`,
+    // `GetCH = PROCEDURE (...): CHAR`): consume it with its signature instead
+    // of mistaking it for the next declaration section.
+    if (this.atKw('PROCEDURE')) {
+      this.next();
+      if (this.atSym('(')) this.skipBalanced('(', ')');
+      if (this.atSym(':')) {
+        this.next();
+        if (this.peek().kind === 'ident') {
+          this.next();
+          while (this.atSym('.') && this.peek(1)?.kind === 'ident') { this.next(); this.next(); }
+        }
+      }
+    }
     while (!this.atEof()) {
     while (!this.atEof()) {
       const t = this.peek();
       const t = this.peek();
       if (t.kind === 'symbol') {
       if (t.kind === 'symbol') {
@@ -398,6 +422,18 @@ class Parser {
     this.skipAlignment();
     this.skipAlignment();
     if (this.atSym(';')) this.next();
     if (this.atSym(';')) this.next();
     else { this.syncDecl(); }
     else { this.syncDecl(); }
+    // Forward declaration: `PROCEDURE P (...); FORWARD;` — no body follows.
+    if (this.peek().kind === 'ident' && this.peek().text === 'FORWARD') {
+      this.next();
+      this.eatSym(';');
+      proc.forward = true;
+      const endTok = this.prevTok();
+      proc.extent = {
+        startLine: proc.extent.startLine, startCh: proc.extent.startCh,
+        endLine: endTok.endLine, endCh: endTok.endCh,
+      };
+      return proc;
+    }
     if (!withBody) {
     if (!withBody) {
       const endTok = this.prevTok();
       const endTok = this.prevTok();
       proc.extent = {
       proc.extent = {
@@ -500,7 +536,18 @@ class Parser {
     if (this.atSym('[')) this.skipBalanced('[', ']');
     if (this.atSym('[')) this.skipBalanced('[', ']');
     this.eatSym(';');
     this.eatSym(';');
     this.parseImportSeq(unit);
     this.parseImportSeq(unit);
-    if (this.atKw('EXPORT')) this.skipToSym(';');
+    // A local module's EXPORT list makes those names visible bare in the
+    // enclosing scope (`EXPORT QUALIFIED` names stay qualification-only).
+    if (this.atKw('EXPORT')) {
+      this.next();
+      if (this.atKw('QUALIFIED')) {
+        this.next();
+        this.skipToSym(';');
+      } else {
+        mod.exports = this.parseIdentList();
+        this.eatSym(';');
+      }
+    }
     this.parseBlock(mod, unit, true);
     this.parseBlock(mod, unit, true);
     if (this.peek().kind === 'ident') this.next();
     if (this.peek().kind === 'ident') this.next();
     const endTok = this.prevTok();
     const endTok = this.prevTok();

+ 52 - 2
extensions/modula2-language/src/m2/resolve.ts

@@ -65,6 +65,22 @@ export function visibleSymbols(unit: M2Unit, line: number, ch: number): M2Symbol
           if (g.kind === 'constant' && !seen.has(g.name)) { seen.add(g.name); out.push(g); }
           if (g.kind === 'constant' && !seen.has(g.name)) { seen.add(g.name); out.push(g); }
         }
         }
       }
       }
+      // Names exported bare by directly-nested local modules.
+      if (c.kind === 'module' && c.exports) {
+        for (const n of c.exports) {
+          const e = c.children.find(g => g.name === n);
+          if (e && !seen.has(e.name)) { seen.add(e.name); out.push(e); }
+        }
+      }
+    }
+  }
+  // The unit's own definition module (implicit scope for implementations).
+  const own = ownDefUnit(unit);
+  if (own) {
+    for (const s of own.symbols) {
+      for (const c of s.children) {
+        if (c.kind !== 'module' && !seen.has(c.name)) { seen.add(c.name); out.push(c); }
+      }
     }
     }
   }
   }
   // Imported names.
   // Imported names.
@@ -141,18 +157,52 @@ function findInScope(children: M2Symbol[], name: string): M2Symbol | null {
   return null;
   return null;
 }
 }
 
 
+/** The unit's own definition module, if any.
+ *
+ *  An implementation module (`MODULE M` in `X.mod`) implicitly sees the
+ *  declarations of its own definition module (`M.def` in the same
+ *  directory). Returns null for definition modules and when no sibling
+ *  `.def` exists. Never returns the unit itself. */
+export function ownDefUnit(unit: M2Unit): M2Unit | null {
+  if (unit.definition || !unit.moduleName) return null;
+  const def = loadDefModule(path.dirname(unit.filePath), unit.moduleName);
+  if (!def || def.filePath === unit.filePath) return null;
+  return def;
+}
+
+/** A name exported bare by a directly-nested local module (`EXPORT a` in `MODULE M`). */
+function exportedIn(mods: M2Symbol[], name: string): M2Symbol | null {
+  for (const m of mods) {
+    if (m.kind === 'module' && m.exports?.includes(name)) {
+      const found = m.children.find(c => c.name === name);
+      if (found) return found;
+    }
+  }
+  return null;
+}
+
 /** Resolve a same-file visible name to its declaration. */
 /** Resolve a same-file visible name to its declaration. */
 export function resolveLocal(unit: M2Unit, line: number, ch: number, name: string): M2Symbol | null {
 export function resolveLocal(unit: M2Unit, line: number, ch: number, name: string): M2Symbol | null {
   const chain = scopeChainAt(unit, line, ch);
   const chain = scopeChainAt(unit, line, ch);
   for (let k = chain.length - 1; k >= 0; k--) {
   for (let k = chain.length - 1; k >= 0; k--) {
-    const found = findInScope(chain[k].children, name);
+    const found = findInScope(chain[k].children, name)
+      ?? exportedIn(chain[k].children, name);
     if (found) return found;
     if (found) return found;
   }
   }
   // Module-level fallback.
   // Module-level fallback.
   for (const s of unit.symbols) {
   for (const s of unit.symbols) {
-    const found = findInScope(s.children, name);
+    const found = findInScope(s.children, name)
+      ?? exportedIn(s.children, name);
     if (found) return found;
     if (found) return found;
   }
   }
+  // The unit's own definition module (implicit scope for implementations).
+  const own = ownDefUnit(unit);
+  if (own) {
+    for (const s of own.symbols) {
+      const found = findInScope(s.children, name);
+      if (found) return found;
+    }
+  }
   return null;
   return null;
 }
 }
 
 

+ 71 - 5
extensions/modula2-language/src/m2/types.ts

@@ -97,9 +97,7 @@ class TypeParser {
     }
     }
     if (t.kind === 'number') {
     if (t.kind === 'number') {
       this.next();
       this.next();
-      return /[.eE]/.test(t.text)
-        ? { kind: 'real', id: 'REAL', literal: true }
-        : { kind: 'whole', id: 'INTEGER', literal: true };
+      return numberLiteralType(t.text);
     }
     }
     if (t.kind === 'string') {
     if (t.kind === 'string') {
       this.next();
       this.next();
@@ -308,7 +306,14 @@ function resolveT(t: M2Type, unit: M2Unit, dir: string, line: number, ch: number
   const host = resolved.filePath === unit.filePath ? unit : unitOfFile(resolved.filePath);
   const host = resolved.filePath === unit.filePath ? unit : unitOfFile(resolved.filePath);
   const detail = resolved.sym.detail;
   const detail = resolved.sym.detail;
   const eq = detail.indexOf('=');
   const eq = detail.indexOf('=');
-  if (eq < 0 || !host) return UNKNOWN;
+  if (!host) return UNKNOWN;
+  if (eq < 0) {
+    // Opaque type (`TYPE File;`): model as a distinct named pointer so that
+    // NIL assignment, same-type comparison and mismatch detection work.
+    // (Approximation: an opaque type need not be pointer-implemented, but
+    // treating it as unknown would silence all checking on it.)
+    return { kind: 'pointer', target: UNKNOWN, name: t.qualifier ? `${t.qualifier}.${t.name}` : t.name };
+  }
   seen.add(key);
   seen.add(key);
   const inner = resolveT(
   const inner = resolveT(
     parseTypeText(detail.slice(eq + 1)), host, path.dirname(resolved.filePath),
     parseTypeText(detail.slice(eq + 1)), host, path.dirname(resolved.filePath),
@@ -429,7 +434,68 @@ function isSetish(t: M2Type): boolean {
   return t.kind === 'bitset' || t.kind === 'setLit' || t.kind === 'setNamed';
   return t.kind === 'bitset' || t.kind === 'setLit' || t.kind === 'setNamed';
 }
 }
 
 
-/** Numeric (whole/real) or set type for arithmetic operators. */
+/** Classify a numeric literal token (`12C` is CHAR, `177B`/`0FFH` are whole). */
+export function numberLiteralType(text: string): M2Type {
+  if (/[.eE]/.test(text)) return { kind: 'real', id: 'REAL', literal: true };
+  if (/C$/i.test(text)) return { kind: 'char' };
+  return { kind: 'whole', id: 'INTEGER', literal: true };
+}
+
+/** Result of `+ - * / DIV MOD` over two operand types.
+ *
+ *  Calibrated against gm2: literals adapt to the other side's type
+ *  (`CARDINAL + 1` stays CARDINAL); two different named types are
+ *  incompatible (`CARDINAL + INTEGER`, `REAL + LONGREAL`); mixed
+ *  whole/real is incompatible. Returns null on incompatibility
+ *  (the caller reports) and `unknown` for unknowable operands.
+ */
+export function arithResultType(a: M2Type, b: M2Type): M2Type | null {
+  const sa = stripSub(a);
+  const sb = stripSub(b);
+  const ka = famOf(sa);
+  const kb = famOf(sb);
+  if (ka === 'unknown' || kb === 'unknown') return UNKNOWN;
+  if (ka === 'set' && kb === 'set') return { kind: 'bitset' };
+  if ((ka === 'whole' || ka === 'real') && ka === kb) {
+    const la = isLit(sa);
+    const lb = isLit(sb);
+    if (la && lb) {
+      return ka === 'whole'
+        ? { kind: 'whole', id: 'INTEGER', literal: true }
+        : { kind: 'real', id: 'REAL', literal: true };
+    }
+    if (la) return unlit(sb);
+    if (lb) return unlit(sa);
+    if (sa.kind === sb.kind && namedId(sa) === namedId(sb)) return unlit(sa);
+    return null;
+  }
+  return null;
+}
+
+function famOf(t: M2Type): 'whole' | 'real' | 'set' | 'other' | 'unknown' {
+  if (t.kind === 'unknown') return 'unknown';
+  if (t.kind === 'whole') return 'whole';
+  if (t.kind === 'real') return 'real';
+  if (t.kind === 'bitset' || t.kind === 'setLit' || t.kind === 'setNamed') return 'set';
+  return 'other';
+}
+
+function isLit(t: M2Type): boolean {
+  return (t.kind === 'whole' || t.kind === 'real') && t.literal;
+}
+
+/** The named type id for identity comparison (`INTEGER`, `REAL`, ...). */
+function namedId(t: M2Type): string | null {
+  if (t.kind === 'whole' || t.kind === 'real') return t.literal ? null : t.id;
+  return null;
+}
+
+/** Non-literal variant of a numeric type (literals already handled). */
+function unlit(t: M2Type): M2Type {
+  if (t.kind === 'whole') return { kind: 'whole', id: t.id, literal: false };
+  if (t.kind === 'real') return { kind: 'real', id: t.id, literal: false };
+  return t;
+}
 export function arithKind(t: M2Type): 'whole' | 'real' | 'set' | 'other' | 'unknown' {
 export function arithKind(t: M2Type): 'whole' | 'real' | 'set' | 'other' | 'unknown' {
   const b = stripSub(t);
   const b = stripSub(t);
   if (b.kind === 'unknown') return 'unknown';
   if (b.kind === 'unknown') return 'unknown';