plan-two-phase-parse.md 9.1 KB

Two-phase parse (design) — eliminating the forward mechanism

Goal

Delete SymTab.FwdVarRef/FwdVarName/FwdVarResolve, Check.NoteForward/ FlushForward and the Design forward branch, plus the Op=1 marker — by binding names before they are checked.

Why post-parse checking is not enough

Design defers a name it cannot find inside a procedure because it may be a module-level declaration that appears later in the file. The resolve/check pass happens after the parse, so it cannot help with binding: binding happens during the parse, and the declaration simply has not been read yet. The only fix is to read the declarations before the bodies — a two-phase (or two-pass) parse.

Design A (recommended): module-level body deferral + re-scan

The grammar/parser stays almost entirely as-is; only the order in which the parser visits text changes.

  1. Skip module-level procedure bodies. When ProcDecl sees a module-level PROCEDURE, record the body's source span and consume its tokens without parsing them, then carry on with the remaining declarations. Result: every module-level name is in SymTab once the module's declarations are done.
  2. Re-scan and parse the bodies. At the end of the module, for each recorded span: seek the scanner to its start and parse the body with the existing Block production. Every module-level name now resolves, so no forward reference is ever created.

Consequences:

  • Design's forward branch never fires → the whole FwdVar* path is dead and removable.
  • The body productions are reused unchanged (both the skip and the re-parse reuse existing machinery), unlike Design B below.

Pieces

  1. Scanner seek (scanner.frm, and expose it in M2S.def's template): PROCEDURE SeekTo (p: INT32) — set bp0/bp/nextPos to p and recompute line/col (scan from offset 0 counting CHR(10); CharAt already indexes the in-memory buffer, so this is O(p)).
  2. Token-level body skipper (grammar helper): starting at the PROCEDURE keyword, consume tokens counting block openers {PROCEDURE, IF, CASE, WHILE, FOR, LOOP, WITH, RECORD}; stop when the count returns to 0 — that END closes the procedure. BEGIN is not an opener (a block's BEGIN…END shares the declaration's END), and REPEAT…UNTIL/EXIT/RETURN are not counted. Verify afterwards that the token is END and the following identifier equals the procedure name (guards a mis-skip).
  3. Deferral in ProcDecl (module level only): skip + record the span; the declaration node is a placeholder NkProcDecl.
  4. Body re-parse: a new grammar entry (reusing Block) driven at the end of Unit, walking the recorded spans.
  5. Flag: all of this behind -twophase (default off) until the suite and the fixpoint are byte-identical, then flip.

Design B (rejected): syntax-only bodies + binding walk

Make every expression/statement action resolution-free and bind/type/ check in Check, walking with scopes mirrored from the AST. This is the "true" two-phase and is much larger: it touches every body action, and parse decisions that need types (typed brace literals, method calls, L.x qualified names, HIGH/LEN) would have to be deferred too.

Risks / open questions

  • Skipper exactness. A mis-count mis-skips to the wrong END. The post-skip check (END + name) turns this into a clear error rather than silent corruption.
  • Scanner state after a seek (line, col, lookahead) and error positions inside re-parsed bodies.
  • AST/Lower ordering. Bodies are parsed after the declarations, so node allocation order changes. Lower walks by structure (order- independent), but any parse-time ordinal that assumes file order must be re-checked.
  • Nested procedures are inside the deferred span, so they are re-parsed with the same mechanism (their own locals are declared before their statements, so they need no deferral themselves).

Milestones (each with the fixpoint green)

  1. SeekTo + the skipper behind the flag; inert.
  2. Module-level deferral + body re-parse; byte-compare against the default across the suite.
  3. Flip the default; delete FwdVarRef/FwdVarName/FwdVarResolve and Check.NoteForward/FlushForward, the Design forward branch, and the Op=1 marker.

Cost

Pieces 1–2 are a few hundred lines across the scanner frame, the grammar and the driver, and are the largest single change in the resolve/check thread — the reason FwdVar* could not be deleted by moving checks alone.

Progress

  • 2026-10-15, milestone 1 (probe behind -twophase, inert). Each outermost procedure/method body is shadow-skipped at the token level and the skip verified before the body parses normally:
    • scanner.frm: PushPos/PopPos save and restore the full scanner state (cursor + all published positions + errors, so a re-scanned bad character cannot double-count). 1-deep use, 8-deep stack.
    • M2.atg: SetTwoPhase switch, procNest counter (hooked once in Block, so ProcDecl and method bodies are both covered), TokIs (compares token text, immune to token renumbering) and ProbeBody (opener counting + END <name> check, silent on success, twophase probe mismatch to StdOut otherwise — never into the listing).
    • compiler.frm + parser.frm: -twophase flag plumbed to M2P.SetTwoPhase at both driver Parse sites.
    • Openers counted: MODULE (nested modules), PROCEDURE, CLASS (incl. CLASS IMPLEMENTATION), RECORD (incl. variant CASE), IF, CASE, WHILE, FOR, LOOP, WITH. BEGIN is not an opener; REPEAT…UNTIL needs none.
    • run_tests.sh gains a twophase_same gate (3 procedure-heavy files byte-compared flag-off vs flag-on).
    • Verified: suite 196/196 (193 + 3 probe gates); all 142 single-file tests plus session spot-checks (incl. Library1) and the compiler's own sources produce byte-identical .ssa with the flag on and zero mismatches; fixpoint OK (3,517,333 bytes).
  • Finding (V3 name resolution). The first cut called the frame's Get from the new atg helper. gm2 accepts it (whole-scope), but the self-hosted V3 compiler resolves single-pass: the bare Get bound the imported M2S.Get(VAR sym) and stage 2 failed with 233s (plus knock-on 201s at END M2P). Fix: the helper advances via Adv (M2S.Get(sym); INC(errDist) — exactly the frame's Get body for a pragma-free grammar, where the scanner never returns a token above maxT). Rule going forward: atg helpers must never call a later-declared frame procedure by a name that collides with an import.
  • Next: milestone 2 — record body spans during the probe, add the body-entry production, and re-parse bodies after the declarations (driver-level restart), byte-compared against the default.
  • 2026-10-16, milestone 2a (spans + SeekTo, still inert).
    • scanner.frm: SeekTo(p) — replays NextCh from offset 0 over the in-memory buffer (O(p), no file I/O), so curLine/lineStart/ lastCh match a fresh parse exactly. Published pos/len still describe the previous token afterwards — caller must Get immediately (same contract as the priming Get in Parse).
    • M2.atg: span table (nSpans/spanName/spanStart, per-parse, 1024 entries) recorded in ProbeBody; a SeekTo(start)+Adv round-trip check per body (sym/nextPos equality) whose side effects the trailing PopPos discards — mismatch prints twophase seek mismatch, silent otherwise.
    • Verified: 142-file sweep, zero probe/seek mismatches, zero .ssa diffs; suite 196/196 (gate now greps both mismatch kinds); fixpoint OK (3,584,690 bytes). Positive controls: temp print showed the check firing per body with the flag on (reverted), and a truncated file reports probe mismatch.
    • Finding (sharpened V3 rule). The first 2a cut called NoteSpan (defined after ProbeBody, not in M2P.def) and stage 2 failed 233 + END M2P knock-ons. Bisected to the rule: V3 has no forward procedure tolerance at all — even P; with P declared two lines later is 233 (+201 at EOF). The existing sources self-compile only because every .mod is paired with its .def, which the driver parses first (so e.g. SemError is already declared when NoteLabel's body compiles), plus strict declaration-before-use inside modules. Fix was ordering (declare NoteSpan before its caller), same as the CopyNm precedent. The FwdVar tolerance covers module-level variables only.
  • Next: milestone 2b — SymTab save/re-enter-body-scope primitive (no re-push exists today; all resumes allocate fresh scopes — mapping in subagent record ses_ebc94df66ffeTZpjrrFnZS1GsF) plus the closer-split (phase-1 skip must replicate CloseProc/EndFunc/ ScopeLeave effects without a body); then 2c deferral wiring (skip + placeholder + end-of-unit re-parse worklist, nested spans append during phase 2), byte-compared; then 2d flip + delete.