Преглед изворни кода

Builtin-procedure signatures for Modula-2 bodies

Arity, argument and result-type checking for 20 pervasive builtins,
every rule probed against real gm2 16.0.1 first:

- INC/DEC (1-2 args, variable ordinal target, ordinal step),
  INCL/EXCL (exactly 2 args despite the '1 or 2' message; set
  variable plus CARDINAL-family element, INTEGER variables rejected);
- ABS (result keeps the argument type), CAP (CHAR result),
  CHR/ORD/ORDL (CARDINAL/LONGCARD results), ODD (BOOLEAN result),
  FLOAT/LFLOAT (REAL/LONGREAL), TRUNC (INTEGER result);
- VAL (type plus scalar source, result is the named type),
  SIZE (any single argument, CARDINAL constant),
  MAX/MIN (never a record; sets and pointers yield CARDINAL,
  arrays their element type),
  ADR (variable, procedure or string constant; ADDRESS result).

Function builtins used as statements and void builtins used as
values flow through the existing ignored-result/void paths.
HALT/NEW/DISPOSE accept any argument list; murky LENGTH/TSIZE/
CODE/COPY/FILL stay unchecked; uncertain cases stay silent.

Validation: 178-case differential suite against gm2 (accept => zero
diagnostics, reject => at least one) with zero mismatches, including
five invalid programs that segfault or ICE gm2 itself; 21 real files
(m2compiler-V3, m2-sunrise, hello) stay at zero errors; live LSP
round-trip publishes the new diagnostics.
Eric Streit пре 6 дана
родитељ
комит
777f62f701
2 измењених фајлова са 310 додато и 12 уклоњено
  1. 30 2
      extensions/modula2-language/DIAGNOSTICS.md
  2. 280 10
      extensions/modula2-language/src/m2/checker.ts

+ 30 - 2
extensions/modula2-language/DIAGNOSTICS.md

@@ -59,7 +59,7 @@ calibrated case-by-case against `gm2`:
   procedures and values returned from void procedures.
 
 Still out of scope: use-before-declaration order, range checking,
-builtin-procedure signatures, record-field types, `WITH`-block internals.
+`WITH`-block internals.
 
 ## Real-file validation
 
@@ -94,8 +94,36 @@ Nominal comparison is by declaration identity, not spelling: `R` and
 `M.R` naming the same `.def` declaration are the same type, while two
 same-shaped records from different declarations are not.
 
-## Circular dependencies (no use-before-declaration)
+## Builtin-procedure signatures
+
+Pervasive builtins are arity- and argument-checked against rules probed
+case by case against real gm2 (16.0.1), which is loose in surprising
+places (`ORD`/`CHR`/`FLOAT` accept `BITSET`; `CAP` accepts `INTEGER` but
+not enumerations; `INCL`'s message claims "1 or 2 parameters" but every
+one-argument form is rejected) and even crashes on some invalid calls
+(`ODD(1, 2)`, `ODD(r)`, `ABS` of a record) — the checker reports proper
+errors there instead. Uncertain cases stay silent: `HALT`/`NEW`/`DISPOSE`
+take any argument list, and the murky `LENGTH`/`TSIZE`/`CODE`/`COPY`/
+`FILL` (undefined unqualified in this gm2) are unchecked, as before.
+
+Checked: `INC`/`DEC` (1–2 args, variable ordinal target, ordinal step),
+`INCL`/`EXCL` (exactly 2 args, set variable plus `CARDINAL`-family
+element — an `INTEGER` variable is rejected), `ABS` (numeric/set source,
+result keeps the argument type), `CAP` (`CHAR`/whole source, `CHAR`
+result), `CHR`/`ORD`/`ORDL` (scalar/set source; `CHAR`/`CARDINAL`/
+`LONGCARD` results), `ODD` (whole-number-or-enum source, `BOOLEAN`
+result), `FLOAT`/`LFLOAT` (scalar/set source, `REAL`/`LONGREAL` results),
+`TRUNC` (real source, `INTEGER` result), `VAL` (type plus scalar source,
+result is the named type), `SIZE` (any single argument, `CARDINAL`
+constant), `MAX`/`MIN` (one type or value, never a record; sets and
+pointers yield `CARDINAL`, arrays their element type), `ADR` (variable,
+procedure or string constant; `ADDRESS` result). Function builtins used
+as statements and void builtins used as values are reported through the
+existing ignored-result/void paths. A 178-case differential suite
+(`gm2` accept ⇒ zero diagnostics; `gm2` reject ⇒ at least one) passes
+with no mismatches, and the real-file corpora stay at zero issues.
 
+## 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`,

+ 280 - 10
extensions/modula2-language/src/m2/checker.ts

@@ -530,6 +530,13 @@ class Walker {
           this.issue(tok, `"${tok.text}" is a type and cannot be used as a value`);
         }
         type = UNKNOWN;
+      } else if (info.kind === 'builtinProc') {
+        // A bare builtin name used as a value (`i := INC;`): check it as a
+        // call with no arguments so arity is reported; void builtins report
+        // through the generic "does not return a value" path below.
+        const checked = this.checkBuiltinCall(nameOf(info, tok), [], tok);
+        if (checked.isVoid) this.issue(tok, `Procedure "${tok.text}" does not return a value`);
+        type = checked.type;
       } else if (info.kind === 'module') {
         this.issue(tok, `"${tok.text}" is a module and cannot be used as a value`);
         type = UNKNOWN;
@@ -540,6 +547,8 @@ class Walker {
       if (info.kind === 'proc' && !this.atSym(':=')) {
         const checked = this.checkCall(info, [], tok);
         if (!checked.isVoid) this.issue(tok, `Return value of function "${info.sym.name}" is ignored`);
+      } else if (info.kind === 'builtinProc' && !this.atSym(':=')) {
+        this.checkBuiltinCall(info.name, [], tok);
       }
     }
     return { type, info, tok };
@@ -606,7 +615,7 @@ class Walker {
     for (;;) {
       if (this.atSym('[')) {
         const open = this.next()!;
-        const idxTypes = this.parseExprList();
+        const idxTypes = this.parseExprList().map(v => v.type);
         if (this.atSym(']')) this.next();
         // Consume one dimension per index: gm2 flattens `a[i, j]` over
         // nested arrays (`symSet[0, i]` means `symSet[0][i]`).
@@ -659,23 +668,25 @@ class Walker {
   }
 
   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.
+    // Builtin procedures (`SIZE`, `VAL`, `MAX`, ...): arguments are parsed
+    // in lenient mode (type names allowed) and checked against the
+    // gm2-calibrated signatures in `checkBuiltinCall`.
     if (info.kind === 'builtinProc') {
       this.next(); // (
       this.builtinArgs++;
+      let args: Value[];
       try {
-        this.parseExprList();
+        args = this.parseExprList();
         if (this.atSym(')')) this.next();
       } finally {
         this.builtinArgs--;
       }
-      return { type: UNKNOWN, isVoid: false, returnsValue: false };
+      return this.checkBuiltinCall(info.name, args, tok);
     }
     this.next(); // (
-    const args = this.parseExprList();
+    const argValues = this.parseExprList();
     if (this.atSym(')')) this.next();
-    if (info.kind === 'proc') return this.checkCall(info, args, tok);
+    if (info.kind === 'proc') return this.checkCall(info, argValues.map(a => a.type), tok);
     // Calls through procedure variables (`handler(x)`): signatures are
     // untracked, so such calls are unchecked but legal.
     if (info.kind === 'var' || info.kind === 'const') {
@@ -725,6 +736,200 @@ class Walker {
     );
   }
 
+  /** Arity and argument checking for pervasive builtin procedures.
+   *
+   *  Every rule below was probed against real gm2 (16.0.1); uncertain
+   *  cases stay silent. `HALT`/`NEW`/`DISPOSE` accept any argument list
+   *  (halt codes and `ALLOCATE` variant tags are unknowable), and the
+   *  murky `LENGTH`/`TSIZE`/`CODE`/`COPY`/`FILL` (undefined unqualified
+   *  in this gm2; `TSIZE` even accepts what `SIZE` rejects) stay fully
+   *  unchecked, exactly as before.
+   */
+  private checkBuiltinCall(
+    name: string, args: Value[], tok: Token,
+  ): { type: M2Type; isVoid: boolean; returnsValue: boolean } {
+    const fun = (type: M2Type) => ({ type, isVoid: false, returnsValue: true });
+    const proc = () => ({ type: UNKNOWN, isVoid: true, returnsValue: false });
+    const silent = () => ({ type: UNKNOWN, isVoid: false, returnsValue: false });
+    const want = (min: number, max: number): boolean => {
+      if (args.length < min || args.length > max) {
+        const n = min === max
+          ? `${min} argument${min === 1 ? '' : 's'}`
+          : `${min}..${max} arguments`;
+        this.issue(tok, `Procedure "${name}" expects ${n} but ${args.length} given`);
+        return false;
+      }
+      return true;
+    };
+    const expectKind = (arg: Value, n: number, ok: boolean, what: string): void => {
+      if (!ok) {
+        this.issue(arg.tok, `Argument ${n} of "${name}": expected ${what}, found ${displayType(arg.type)}`);
+      }
+    };
+    switch (name) {
+      case 'INC':
+      case 'DEC': {
+        if (!want(1, 2)) return proc();
+        if (!isVariableArg(args[0])) {
+          this.issue(args[0].tok, `Argument 1 of "${name}" must be a variable`);
+        } else {
+          expectKind(args[0], 1, isOrdinal(args[0].type), 'an ordinal value');
+        }
+        if (args.length > 1) {
+          const t = args[1].type;
+          const strOk = t.kind === 'stringLit' && t.length <= 1;
+          expectKind(args[1], 2, t.kind === 'unknown' || strOk || isOrdinal(t), 'an ordinal value');
+        }
+        return proc();
+      }
+      case 'INCL':
+      case 'EXCL': {
+        // gm2's message claims "1 or 2 parameters" but every one-argument
+        // form probed (`INCL(s)`, `INCL(3)`, `INCL(BITSET{1,2})`) is
+        // rejected: exactly two arguments are required.
+        if (!want(2, 2)) return proc();
+        if (!isVariableArg(args[0])) {
+          this.issue(args[0].tok, `Argument 1 of "${name}" must be a variable`);
+        } else {
+          expectKind(args[0], 1, isSetish(args[0].type), 'a set');
+        }
+        expectKind(args[1], 2, isBitNumber(args[1].type), 'a CARDINAL value');
+        return proc();
+      }
+      case 'HALT':
+      case 'NEW':
+      case 'DISPOSE':
+        return proc();
+      case 'LENGTH':
+      case 'TSIZE':
+      case 'CODE':
+      case 'COPY':
+      case 'FILL':
+        return silent();
+      case 'ABS': {
+        if (!want(1, 1)) return fun(UNKNOWN);
+        // gm2 evaluates almost anything but rejects (or crashes on)
+        // composite arguments: only scalars and sets pass through.
+        expectKind(args[0], 1, isScalarOrSet(args[0].type), 'a scalar value');
+        return fun(args[0].type);
+      }
+      case 'CAP': {
+        if (!want(1, 1)) return fun({ kind: 'char' });
+        const t = args[0].type;
+        const b = deepBase(t);
+        const strOk = t.kind === 'stringLit' && t.length <= 1;
+        expectKind(args[0], 1, t.kind === 'unknown' || strOk || b.kind === 'char' || b.kind === 'whole',
+          'a CHAR or whole-number value');
+        return fun({ kind: 'char' });
+      }
+      case 'CHR': {
+        if (!want(1, 1)) return fun({ kind: 'char' });
+        expectKind(args[0], 1, isScalarOrSet(args[0].type), 'a scalar value');
+        return fun({ kind: 'char' });
+      }
+      case 'ORD': {
+        if (!want(1, 1)) return fun({ kind: 'whole', id: 'CARDINAL', literal: false });
+        expectKind(args[0], 1, isScalarOrSet(args[0].type), 'a scalar value');
+        return fun({ kind: 'whole', id: 'CARDINAL', literal: false });
+      }
+      case 'ORDL': {
+        if (!want(1, 1)) return fun({ kind: 'whole', id: 'LONGCARD', literal: false });
+        expectKind(args[0], 1, isScalarOrSet(args[0].type), 'a scalar value');
+        return fun({ kind: 'whole', id: 'LONGCARD', literal: false });
+      }
+      case 'ODD': {
+        if (!want(1, 1)) return fun({ kind: 'bool' });
+        const b = deepBase(args[0].type);
+        expectKind(args[0], 1,
+          b.kind === 'unknown' || b.kind === 'whole' || b.kind === 'enum',
+          'a whole number');
+        return fun({ kind: 'bool' });
+      }
+      case 'FLOAT': {
+        if (!want(1, 1)) return fun({ kind: 'real', id: 'REAL', literal: false });
+        expectKind(args[0], 1, isScalarOrSet(args[0].type), 'a scalar value');
+        return fun({ kind: 'real', id: 'REAL', literal: false });
+      }
+      case 'LFLOAT': {
+        if (!want(1, 1)) return fun({ kind: 'real', id: 'LONGREAL', literal: false });
+        expectKind(args[0], 1, isScalarOrSet(args[0].type), 'a scalar value');
+        return fun({ kind: 'real', id: 'LONGREAL', literal: false });
+      }
+      case 'TRUNC': {
+        if (!want(1, 1)) return fun({ kind: 'whole', id: 'INTEGER', literal: false });
+        expectKind(args[0], 1,
+          args[0].type.kind === 'unknown' || deepBase(args[0].type).kind === 'real',
+          'a real number');
+        return fun({ kind: 'whole', id: 'INTEGER', literal: false });
+      }
+      case 'VAL': {
+        if (!want(2, 2)) return fun(UNKNOWN);
+        const target = args[0].info.kind === 'type' || args[0].info.kind === 'builtinType'
+          ? this.namedArgType(args[0])
+          : UNKNOWN;
+        if (args[0].info.kind !== 'type' && args[0].info.kind !== 'builtinType') {
+          this.issue(args[0].tok, `Argument 1 of "VAL" must be a type`);
+        }
+        expectKind(args[1], 2, isScalarOrSet(args[1].type), 'a scalar value');
+        return fun(target);
+      }
+      case 'SIZE': {
+        if (!want(1, 1)) return fun({ kind: 'whole', id: 'CARDINAL', literal: true });
+        // gm2 accepts any type or value (even records): only arity.
+        return fun({ kind: 'whole', id: 'CARDINAL', literal: true });
+      }
+      case 'HIGH': {
+        if (!want(1, 1)) return fun({ kind: 'whole', id: 'CARDINAL', literal: false });
+        // Arrays, strings, `CHAR` and (oddly) sets; every scalar,
+        // record and pointer probed is rejected.
+        const b = deepBase(args[0].type);
+        expectKind(args[0], 1,
+          b.kind === 'unknown' || b.kind === 'array' || b.kind === 'stringLit' ||
+          b.kind === 'char' || b.kind === 'bitset' || b.kind === 'setNamed' ||
+          b.kind === 'setLit',
+          'an array, string, CHAR or set value');
+        return fun({ kind: 'whole', id: 'CARDINAL', literal: false });
+      }
+      case 'MAX':
+      case 'MIN': {
+        if (!want(1, 1)) return fun(UNKNOWN);
+        const a = args[0];
+        const at = a.info.kind === 'type' || a.info.kind === 'builtinType'
+          ? this.namedArgType(a) : a.type;
+        const b = deepBase(at);
+        if (b.kind === 'record') {
+          this.issue(a.tok, `Cannot apply "${name}" to ${displayType(at)}`);
+          return fun(UNKNOWN);
+        }
+        return fun(maxResultType(at));
+      }
+      case 'ADR': {
+        if (!want(1, 1)) return fun({ kind: 'whole', id: 'ADDRESS', literal: false });
+        const a = args[0];
+        const stringArg = a.type.kind === 'stringLit';
+        const ok = a.info.kind === 'var' || a.info.kind === 'field' || a.info.kind === 'proc' ||
+          (a.info.kind === 'unknown' && (a.tok.kind === 'ident' || stringArg)) ||
+          (a.info.kind === 'const' && stringArg);
+        if (!ok) {
+          this.issue(a.tok, `Argument 1 of "ADR" must be a variable, a procedure or a string constant`);
+        }
+        return fun({ kind: 'whole', id: 'ADDRESS', literal: false });
+      }
+      default:
+        return silent();
+    }
+  }
+
+  /** Resolve a type-name argument (`VAL(T, x)`, `MAX(T)`) to a concrete
+   *  type. Unresolvable spellings (including qualified `M.T`, whose
+   *  qualifier is unavailable here) fall back to unknown: silent. */
+  private namedArgType(a: Value): M2Type {
+    return resolveType(
+      parseTypeText(a.tok.text),
+      this.ctx.unit, this.ctx.docDir, a.tok.line, a.tok.ch,
+    );
+  }
+
   private checkAssignment(d: Value, op: Token): void {
     const info = d.info;
     // Assignments to procedure variables accept bare procedure designators.
@@ -766,15 +971,15 @@ class Walker {
     }
   }
 
-  private parseExprList(): M2Type[] {
-    const out: M2Type[] = [];
+  private parseExprList(): Value[] {
+    const out: Value[] = [];
     if (this.atSym(')') || this.atSym(']')) return out;
     for (;;) {
       const t = this.peek();
       // NOT can open an expression; any other keyword ends the list.
       if (!t || t.kind === 'eof' || (t.kind === 'keyword' && t.text !== 'NOT') ||
           (t.kind === 'symbol' && (t.text === ')' || t.text === ']' || t.text === ';'))) break;
-      out.push(this.parseExpr(0).type);
+      out.push(this.parseExpr(0));
       if (this.atSym(',')) { this.next(); continue; }
       break;
     }
@@ -979,3 +1184,68 @@ function nameOf(info: HeadInfo, tok: Token): string {
   if (info.kind === 'proc') return info.sym.name;
   return tok.text;
 }
+
+/** Base type through any nesting of subranges. */
+function deepBase(t: M2Type): M2Type {
+  let b = t;
+  while (b.kind === 'subrange') b = b.base;
+  return b;
+}
+
+/** Usable where gm2 demands a variable (`INC`/`INCL` first argument,
+ *  `ADR`): variables, record fields, and unresolvable idents (silent —
+ *  the analyser reports undeclared names separately). Parenthesised
+ *  variables keep their `var`/`field` info, so `INC((i))` is accepted
+ *  exactly as gm2 accepts it; literals and constructors are not. */
+function isVariableArg(v: Value): boolean {
+  if (v.info.kind === 'var' || v.info.kind === 'field') return true;
+  return v.info.kind === 'unknown' && v.tok.kind === 'ident';
+}
+
+/** Whole, real, char, boolean, enum, set or string argument (or
+ *  unknown): the domain gm2 accepts for `CHR`/`ORD`/`FLOAT`/`LFLOAT`
+ *  and `VAL` sources. Arrays, records, pointers and procedures are
+ *  rejected by gm2 ("aggregate/pointer used where ... expected"). */
+function isScalarOrSet(t: M2Type): boolean {
+  const b = deepBase(t);
+  return b.kind === 'unknown' || b.kind === 'whole' || b.kind === 'real' ||
+    b.kind === 'char' || b.kind === 'bool' || b.kind === 'enum' ||
+    b.kind === 'bitset' || b.kind === 'setNamed' || b.kind === 'setLit' ||
+    b.kind === 'stringLit';
+}
+
+/** BITSET or set-of: gm2 accepts any set as an `INCL`/`EXCL` target. */
+function isSetish(t: M2Type): boolean {
+  const b = deepBase(t);
+  return b.kind === 'unknown' || b.kind === 'bitset' ||
+    b.kind === 'setNamed' || b.kind === 'setLit';
+}
+
+/** `INCL`/`EXCL` element: a `CARDINAL`-family value, a subrange, a
+ *  constant or unknown. gm2 rejects `INTEGER` variables, `CHAR`,
+ *  enumerations and everything else ("BITNUM ... incompatible"). */
+function isBitNumber(t: M2Type): boolean {
+  if (t.kind === 'subrange') {
+    const b = deepBase(t);
+    return b.kind === 'whole' || b.kind === 'unknown';
+  }
+  const b = deepBase(t);
+  if (b.kind === 'unknown') return true;
+  if (b.kind === 'whole') {
+    if (b.literal) return true;
+    return b.id !== 'INTEGER' && b.id !== 'LONGINT' && b.id !== 'SHORTINT';
+  }
+  return false;
+}
+
+/** `MAX`/`MIN` result: the argument type, except sets and pointers (a
+ *  `CARDINAL` constant) and arrays (the element type) — all probed. */
+function maxResultType(t: M2Type): M2Type {
+  const b = deepBase(t);
+  if (b.kind === 'bitset' || b.kind === 'setNamed' || b.kind === 'setLit' ||
+      b.kind === 'pointer') {
+    return { kind: 'whole', id: 'CARDINAL', literal: true };
+  }
+  if (b.kind === 'array') return b.element;
+  return t;
+}