Explorar el Código

Diagnostics: circular type/constant dependency detection

gm2 is multi-pass so declaration order never matters; what it rejects
is dependency cycles that never ground. The analyser now mirrors that:
- aliases (A = B), pointers, arrays, subranges and procedure signatures
  are followed; RECORDs, enumerations, predefined types and unknowns
  ground the search (callback pattern H = PROCEDURE (e: E) stays silent)
- constant initializers are followed the same way (a = b; b = a)
- one report per cycle, positions always in the analysed document
- procedure types retain signatures; type-expression formals no longer
  flag as unknown identifiers
Eric Streit hace 1 semana
padre
commit
dc3c049ce1

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

@@ -79,3 +79,15 @@ issues on all six. Getting there exposed and fixed:
 - 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`).
+
+## Circular dependencies (no use-before-declaration)
+
+gm2 is multi-pass, so declaration order never matters — there is
+deliberately no use-before-declaration check. What is checked instead is
+dependency cycles that never ground, which gm2 rejects: `A = B; B = A`,
+`A = POINTER TO B; B = POINTER TO A`, `T = POINTER TO T`, constant cycles
+(`a = b; b = a`), and aliases through procedure types. Chains grounding at
+a `RECORD`, enumeration, predefined type or unknown stay silent, as does the
+callback pattern (`H = PROCEDURE (e: E)` with `E` a record). Only
+same-file declarations are followed, so reported positions always belong
+to the open document.

+ 161 - 0
extensions/modula2-language/src/m2/analyse.ts

@@ -21,6 +21,7 @@ import { lex, Token } from './lexer';
 import { M2Symbol, M2Unit, parseUnitText, rangeContains } from './parser';
 import { flattenUnit, loadDefModule, resolveName, topLevel } from './resolve';
 import { checkBodies } from './checker';
+import { M2Type, parseTypeText } from './types';
 
 export interface M2Issue {
   line: number; ch: number; endLine: number; endCh: number;
@@ -51,6 +52,7 @@ export function analyseUnit(text: string, filePath: string, docDir: string): M2I
     ...duplicateDeclarations(unit),
     ...unknownIdentifiers(tokens, unit, docDir),
     ...fromMembership(unit, docDir),
+    ...circularDependencies(unit, docDir),
     ...checkBodies(tokens, unit, docDir),
   ];
 }
@@ -97,6 +99,11 @@ function unknownIdentifiers(tokens: Token[], unit: M2Unit, docDir: string): M2Is
   for (const { sym } of flattenUnit(unit)) {
     declared.add(`${sym.nameRange.startLine}:${sym.nameRange.startCh}`);
   }
+  // Formal parameters of procedure TYPE expressions (`P: PROCEDURE (e: E)`)
+  // are not recorded as symbols; their positions declare them.
+  for (const pos of procTypeFormalPositions(tokens)) {
+    declared.add(pos);
+  }
   const fromNames = new Set<string>();
   for (const imp of unit.imports) for (const n of imp.names) fromNames.add(n);
   const withRegions = withRegionsOf(tokens);
@@ -153,6 +160,160 @@ function fromMembership(unit: M2Unit, docDir: string): M2Issue[] {
   return issues;
 }
 
+/** Circular type/constant dependencies.
+ *
+ *  gm2 is multi-pass: declaration order never matters, so there is no
+ *  use-before-declaration checking. What gm2 does reject is dependency
+ *  cycles that never ground (`A = B; B = A`, `A = POINTER TO B;
+ *  B = POINTER TO A`). Chains grounding at a RECORD, enumeration,
+ *  predefined type or unknown are fine, as is the callback pattern
+ *  (`H = PROCEDURE (e: E)` with `E` a record, which terminates because
+ *  signatures are descended into but records ground). Only same-file
+ *  declarations are followed, so every reported position belongs to the
+ *  analysed document.
+ */
+function circularDependencies(unit: M2Unit, docDir: string): M2Issue[] {
+  const issues: M2Issue[] = [];
+  const grounded = new Set<string>();
+  const reportedCycles = new Set<string>();
+
+  const keyOf = (sym: M2Symbol): string =>
+    `${sym.nameRange.startLine}:${sym.nameRange.startCh}:${sym.name}`;
+
+  /** Same-file type/constant declaration a reference resolves to, if any. */
+  function follow(
+    line: number, ch: number, qualifier: string | null, name: string,
+  ): M2Symbol | null {
+    const r = resolveName(unit, docDir, line, ch, qualifier, name);
+    if (!r || r.filePath !== unit.filePath) return null;
+    if (r.sym.kind !== 'type' && r.sym.kind !== 'constant') return null;
+    return r.sym;
+  }
+
+  /** Outgoing dependency edges of one declaration. */
+  function edges(sym: M2Symbol): Array<{ qualifier: string | null; name: string }> {
+    if (sym.kind === 'type') {
+      const detail = sym.detail;
+      const eq = detail.indexOf('=');
+      if (eq < 0) return []; // opaque type: grounded
+      return typeRefs(parseTypeText(detail.slice(eq + 1)));
+    }
+    if (sym.kind === 'constant') {
+      const m = /^\w+\.\w+$/.exec(sym.detail);
+      if (m) return []; // enumeration literal: grounded
+      const eq = sym.detail.indexOf('=');
+      if (eq < 0) return [];
+      return valueRefs(sym.detail.slice(eq + 1));
+    }
+    return [];
+  }
+
+  /** Named references inside a parsed type (descent mirrors gm2 grounding). */
+  function typeRefs(t: M2Type): Array<{ qualifier: string | null; name: string }> {
+    switch (t.kind) {
+      case 'named': return [{ qualifier: t.qualifier, name: t.name }];
+      case 'pointer': return typeRefs(t.target);
+      case 'array': return typeRefs(t.element);
+      case 'subrange': return typeRefs(t.base);
+      case 'proc': {
+        const out: Array<{ qualifier: string | null; name: string }> = [];
+        for (const p of t.params ?? []) out.push(...typeRefs(p));
+        if (t.result) out.push(...typeRefs(t.result));
+        return out;
+      }
+      default: return []; // record, enum, predefined, unknown, literals: grounded
+    }
+  }
+
+  /** Identifier references inside a constant value fragment. */
+  function valueRefs(fragment: string): Array<{ qualifier: string | null; name: string }> {
+    const out: Array<{ qualifier: string | null; name: string }> = [];
+    const toks = lex(fragment).filter(t => t.kind !== 'eof');
+    for (let k = 0; k < toks.length; k++) {
+      const t = toks[k];
+      if (t.kind !== 'ident') continue;
+      if (toks[k + 1]?.text === '.' && toks[k + 2]?.kind === 'ident') {
+        out.push({ qualifier: t.text, name: toks[k + 2].text });
+        k += 2;
+      } else if (toks[k - 1]?.text === '.') {
+        continue; // qualified-away part, handled with its qualifier
+      } else {
+        out.push({ qualifier: null, name: t.text });
+      }
+    }
+    return out;
+  }
+
+  function visit(sym: M2Symbol, stack: M2Symbol[]): void {
+    const key = keyOf(sym);
+    const at = stack.findIndex(s => keyOf(s) === key);
+    if (at >= 0) {
+      const members = [...stack.slice(at).map(s => keyOf(s)), key].sort();
+      const canon = members.join('|');
+      if (!reportedCycles.has(canon)) {
+        reportedCycles.add(canon);
+        const root = stack[at];
+        issues.push({
+          line: root.nameRange.startLine, ch: root.nameRange.startCh,
+          endLine: root.nameRange.endLine, endCh: root.nameRange.endCh,
+          message: root.kind === 'constant'
+            ? `Circular dependency in constant "${root.name}" (type cannot be determined)`
+            : `Circular type dependency involving "${root.name}"`,
+          severity: 'error',
+        });
+      }
+      return;
+    }
+    if (grounded.has(key)) return;
+    stack.push(sym);
+    const line = sym.nameRange.startLine;
+    const ch = sym.nameRange.startCh;
+    for (const ref of edges(sym)) {
+      const target = follow(line, ch, ref.qualifier, ref.name);
+      if (target) visit(target, stack);
+    }
+    stack.pop();
+    grounded.add(key);
+  }
+
+  for (const { sym } of flattenUnit(unit)) {
+    if ((sym.kind === 'type' || sym.kind === 'constant') && sym.name) {
+      visit(sym, []);
+    }
+  }
+  return issues;
+}
+
+/** Positions of formal names in `PROCEDURE (...)` type expressions.
+ *
+ *  Only triggers on the PROCEDURE keyword, so calls and case labels are
+ *  unaffected: an undeclared `gren:` case label is still reported.
+ */
+function procTypeFormalPositions(tokens: Token[]): string[] {
+  const out: string[] = [];
+  for (let k = 0; k < tokens.length; k++) {
+    const t = tokens[k];
+    if (t.kind !== 'keyword' || t.text !== 'PROCEDURE') continue;
+    const open = tokens[k + 1];
+    if (!open || open.kind !== 'symbol' || open.text !== '(') continue;
+    let depth = 0;
+    for (let j = k + 1; j < tokens.length; j++) {
+      const u = tokens[j];
+      if (u.kind === 'symbol' && u.text === '(') depth++;
+      else if (u.kind === 'symbol' && u.text === ')') {
+        depth--;
+        if (depth === 0) break;
+      } else if (u.kind === 'ident' && depth >= 1) {
+        const nx = tokens[j + 1];
+        if (nx?.kind === 'symbol' && (nx.text === ':' || nx.text === ',')) {
+          out.push(`${u.line}:${u.ch}`);
+        }
+      }
+    }
+  }
+  return out;
+}
+
 /** Offset spans of `WITH ... DO ... END` blocks (conservative bracket matching). */
 function withRegionsOf(tokens: Token[]): Array<[number, number]> {
   const regions: Array<[number, number]> = [];

+ 25 - 4
extensions/modula2-language/src/m2/types.ts

@@ -38,7 +38,7 @@ export type M2Type =
   | { kind: 'pointer', target: M2Type, name: string | null }
   | { kind: 'enum', name: string }
   | { kind: 'subrange', base: M2Type }
-  | { kind: 'proc' }
+  | { kind: 'proc', params?: M2Type[], result?: M2Type | null }
   | { kind: 'nil' };
 
 export const UNKNOWN: M2Type = { kind: 'unknown' };
@@ -196,9 +196,30 @@ class TypeParser {
 
   private parseProcType(): M2Type {
     this.next(); // PROCEDURE
-    if (this.atSym('(')) this.skipBalancedRest('(', ')');
-    if (this.atSym(':')) { this.next(); this.parseType(); }
-    return { kind: 'proc' };
+    const params: M2Type[] = [];
+    if (this.atSym('(')) {
+      this.next(); // (
+      for (;;) {
+        if (this.atSym(')') || this.pos >= this.tokens.length) break;
+        if (this.atKw('VAR')) this.next();
+        // Skip parameter names up to ':' (or the group/section end).
+        while (this.peek()?.kind === 'ident' || this.atSym(',')) this.next();
+        if (this.atSym(':')) {
+          this.next();
+          const t = this.parseType();
+          params.push(t ?? UNKNOWN);
+        }
+        if (this.atSym(';')) { this.next(); continue; }
+        if (!this.atSym(')')) this.next(); // recovery
+      }
+      if (this.atSym(')')) this.next();
+    }
+    let result: M2Type | null = null;
+    if (this.atSym(':')) {
+      this.next();
+      result = this.parseType() ?? UNKNOWN;
+    }
+    return { kind: 'proc', params, result };
   }
 
   private parseEnum(): M2Type {