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:
(* ... *) comments;; after the module header;END <ModuleName>.;END;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.
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:
WITH regions, unverifiable FROM-imports, predefined
identifiers, closing END Name;(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.
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:
REAL↔LONGREAL,
CHAR/string rules with ARRAY OF CHAR length checks, sets, pointers,
enumerations, NIL;BOOLEAN conditions (IF, WHILE, ELSIF, UNTIL), ordinal CASE
selectors and FOR variables/bounds/BY steps;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.
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:
.def (FileIO.mod + FileIO.def);FORWARD procedure headings (not duplicates of the later definition);^/[] (f^.x, a[i].x) and flattened multi-index
(a[i, j] over nested arrays);0C is CHAR, 177B/0FFH whole);'\' is one character);SIZE(T), VAL(T, x), MAX(T),
SYSTEM.TSIZE(T)) and typed set constructors (BITSET{...});TYPE File;), local-module EXPORT visibility,
procedure variables (calls and P := Q assignment), the pervasive
LENGTH/ORDL, arithmetic that preserves CARDINAL (c + 1).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:
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;__BUILTIN__ procedure attributes (PROCEDURE __BUILTIN__
sin ...) parse;ARRAY OF SYSTEM.LOC, which
the parameter parser used to record as SYSTEM LOC);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;tm behind
ptrToTM = POINTER TO tm).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.
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 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.
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.