DIAGNOSTICS.md 9.4 KB

Modula-2 diagnostics — step 1

This first diagnostics layer is deliberately lightweight. It does not replace Coco/R. It provides immediate editor feedback for a small set of structural errors while the real parser/LSP is being prepared.

It currently checks:

  • unterminated (* ... *) comments;
  • unterminated string literals;
  • presence of the module header;
  • ; after the module header;
  • presence of the final END <ModuleName>.;
  • module-name consistency between the header and final END;
  • the final period after END <ModuleName>.

The diagnostics are published through the VS Code-compatible DiagnosticCollection, which Theia displays directly in the editor.

The next stage will replace the structural checks with diagnostics generated by the Coco/R parser, while keeping the same editor diagnostic mechanism.

Server-side semantic layer (src/m2/analyse.ts, source modula2)

In parallel, the language server runs its own checks on every open document — always on, merged with Coco/R output when a validator is configured:

  • duplicate declarations in one scope (error, with first-declared line);
  • unknown identifiers (error), with conservative skips: qualified names, import clauses, WITH regions, unverifiable FROM-imports, predefined identifiers, closing END Name;
  • enumeration literals (A, B, ...) are treated as declared constants, so they resolve, complete, and appear in the outline.

Not covered (needs statement/expression parsing): assignment type compatibility, call arity, use-before-declaration order, FROM-list membership against the defining .def.

Statement checker (src/m2/checker.ts + src/m2/types.ts, source modula2)

Bodies (BEGIN..END, recorded by the parser) are walked with a recursive statement/expression analyser. Operator and compatibility rules were calibrated case-by-case against gm2:

  • assignment compatibility, including whole-number family, REAL↔LONGREAL, CHAR/string rules with ARRAY OF CHAR length checks, sets, pointers, enumerations, NIL;
  • call arity (GNU optional parameters supported) and argument types, including open-array formals; ignored function results and void procedures used as values;
  • BOOLEAN conditions (IF, WHILE, ELSIF, UNTIL), ordinal CASE selectors and FOR variables/bounds/BY steps;
  • illegal targets (CONST, procedure, type, module), illegal operands (NOT/AND/OR, mixed arithmetic, DIV/MOD on reals, indexing, dereferencing, comparisons of arrays/records, CARDINAL vs INTEGER comparisons);
  • RETURN without a value, value-returning RETURNs, RETURN outside procedures and values returned from void procedures.

Still out of scope: use-before-declaration order, range checking.

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).

Definition search paths

Go-to-definition (and hover, completion, references) used to look for .def files only next to the open file, silently failing for every other import. Modules are now searched in order: the importing file's directory, the project's definitionDirectories/sourceDirectories/ libraryDirectories from the nearest modula2.json, then gm2's own library sources (m2iso first, matching the default -fiso build; a -fpim/-flog/-fmin compiler flag promotes its dialect). Widening the search exposed and fixed, all probed against gm2:

  • definition-level EXPORT lists are captured, so re-exported foreign functions (EXPORT UNQUALIFIED in FOR "C" libraries) and gm2's own SYSTEM.def list count as exported;
  • DEFINITION MODULE FOR "C" name (name after the language tag) parses;
  • lone __BUILTIN__ procedure attributes (PROCEDURE __BUILTIN__ sin ...) parse;
  • qualified formal types keep their dot (ARRAY OF SYSTEM.LOC, which the parameter parser used to record as SYSTEM LOC);
  • an open ARRAY OF LOC/BYTE/WORD is a raw-memory formal taking any actual (RawIO.Read into a CHAR);
  • ADDRESS takes NIL and mixes with pointers both ways, including = NIL comparisons;
  • field lookup falls back to the stamped declaration identity, reaching types that are not imported under their own name (tm behind ptrToTM = POINTER TO tm).

Record-field types

Record declarations capture each field's type, so r.f, r.a.b, p^.f, a[i].f and F().f resolve to real types instead of unknown: field reads, writes and comparisons are checked, and hover/go-to-definition land on the field declaration. Genuine errors are reported too: selecting a field of a known non-record (f.x on a POINTER — Modula-2 does not implicitly dereference) and unknown fields (r.nope). Anything unresolvable stays silent.

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.

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.

WITH-block internals

WITH bodies used to be skipped wholesale; they are now fully checked with the guard record's fields in scope. The guard must denote a record variable or field (gm2 rejects anything else, including an undereferenced pointer and even a record-valued function result, which is a syntax error there); field names shadow outer scope, nesting works innermost-first, and bodies see real field types, so assignments, calls and expressions inside are checked exactly as qualified accesses would be. Genuinely unknown names are reported (WITH rec DO nope := 1 and WITH nope DO both error, matching gm2). A 22-case differential suite passes with no mismatches, including shadowing, nesting, two-level access (inner.y), field guards (WITH o.inner DO), and fields named like builtins (ORD).

Hover, go-to-definition, references and rename work through the same scopes (resolveNameWith): bare field names — and qualified heads like inner in inner.y — resolve to the guard record's declarations, innermost guard first, with nested guards resolving through outer blocks. Unknown names and unknowable guards stay silent, and reference search matches field declarations by position (they never resolve by name) without letting shadowing declarations leak across symbols.

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.