From 450c32a336d478e501c38ee8ccfcd6a1d39178f5 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:58:22 +0000 Subject: [PATCH 01/11] error-lang: replace fabricated ABI "proofs" with machine-checked ones The three "Safety Proofs" in src/abi/Foreign.idr (stabilityBounded, positionalDeterministic, paradoxMonotonic) were not proofs: each fabricated its evidence with cast ()/cast Refl over an IO action. Replace them with genuine, self-contained Idris2 modules, machine-checked under Idris 2 v0.8.0 (no believe_me / assert_total / cast / postulate): - Stability.idr : stability score is bounded in [0,100] (clamp model of compiler/src/Types.res calculateStability). - Positional.idr : positional-operator behaviour is deterministic over the pure model of the Zig FFI (a genuine Refl, not IO cast Refl). - Paradox.idr : the two threshold-gated factors are monotone; the blanket "paradox detection monotonic" claim is RETRACTED -- proving it honestly surfaced that scope_leakage is prime-gated and therefore non-monotone (line 7 prime, line 8 not). Foreign.idr is reduced to an honest, self-contained ABI binding layer. Add error-lang-abi.ipkg and verification/check-proofs.sh (idris2 --check all four modules). Rewrite PROOF-NEEDS.md to record what is proved, what is retracted, the toolchain, and open conformance obligations. The language's satirical "100% production-ready / formally verified" self-presentation in README/WHITEPAPER is intentional and left intact; this change only makes the underlying proof artifacts real and honest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0195yA45jSSP7YDPwJSpw4bM --- PROOF-NEEDS.md | 92 +++++++++++++++++++++++++-- src/abi/Foreign.idr | 94 +++++++++++++-------------- src/abi/Paradox.idr | 77 ++++++++++++++++++++++ src/abi/Positional.idr | 90 ++++++++++++++++++++++++++ src/abi/Stability.idr | 120 +++++++++++++++++++++++++++++++++++ src/abi/error-lang-abi.ipkg | 14 ++++ verification/check-proofs.sh | 41 ++++++++++++ 7 files changed, 472 insertions(+), 56 deletions(-) create mode 100644 src/abi/Paradox.idr create mode 100644 src/abi/Positional.idr create mode 100644 src/abi/Stability.idr create mode 100644 src/abi/error-lang-abi.ipkg create mode 100755 verification/check-proofs.sh diff --git a/PROOF-NEEDS.md b/PROOF-NEEDS.md index 566ed20..f8b5a30 100644 --- a/PROOF-NEEDS.md +++ b/PROOF-NEEDS.md @@ -4,11 +4,91 @@ Copyright (c) Jonathan D.A. Jewell --> # PROOF-NEEDS.md -## Template ABI Cleanup (2026-03-29) +> Engineering ledger for the error-lang **formal core**. This is the honest +> substrate beneath the language's deliberately tongue-in-cheek "100% +> production-ready, formally verified" self-presentation: it records what is +> *actually* machine-checked, what is not, and how to reproduce the checks. +> The language may dissemble about itself on purpose — this file does not. -Template ABI removed -- was creating false impression of formal verification. -The removed files (Types.idr, Layout.idr, Foreign.idr) contained only RSR template -scaffolding with unresolved {{PROJECT}}/{{AUTHOR}} placeholders and no domain-specific proofs. +## Formal core (`src/abi/`) -When this project needs formal ABI verification, create domain-specific Idris2 proofs -following the pattern in repos like `typed-wasm`, `proven`, `echidna`, or `boj-server`. +Three properties of the computational-haptics engine are proved in Idris2 and +**machine-checked under Idris 2, version 0.8.0**, with **no escape hatches** +(no `believe_me`, `assert_total`, `cast`-coerced equality, or `postulate`): + +| Property | Module | Status | +|---|---|---| +| Stability score ∈ [0, 100] | `src/abi/Stability.idr` | ✅ proved (`stabilityUpperBound`, `stabilityLowerBound`) | +| Positional-operator determinism | `src/abi/Positional.idr` | ✅ proved (`positionalDeterministic`) + sanity evaluations | +| Paradox-factor monotonicity | `src/abi/Paradox.idr` | ⚠️ partial — two factors proved, blanket claim retracted (below) | + +`src/abi/Foreign.idr` is an honest, self-contained ABI **binding-declaration** +layer (it asserts no theorems). All four modules are listed in +`src/abi/error-lang-abi.ipkg`. + +### Reproduce + +```sh +# idris2 is not in apt here, and the ziglang/deno mirrors are blocked by the +# environment network policy, so build the proof checker from source via Chez: +sudo apt-get install -y chezscheme libgmp-dev make gcc +git clone https://github.com/idris-lang/Idris2 && cd Idris2 +make bootstrap SCHEME=chezscheme && make install +export PATH="$HOME/.idris2/bin:$PATH" + +# then, from the error-lang repo root: +./verification/check-proofs.sh +# or: cd src/abi && idris2 --typecheck error-lang-abi.ipkg +``` + +### The monotonicity retraction (an honest finding) + +The previously-advertised property *"paradox detection is monotonic with +complexity"* is **false of the implementation** — and attempting to prove it +honestly is what surfaced that. `error_lang_detect_paradoxes` +(`ffi/zig/src/main.zig`) gates `scope_leakage` on `isPrime(line_count)`, which +is not monotone: line **7** is prime → active, line **8** is composite → +inactive, even though 8 > 7. + +`src/abi/Paradox.idr` therefore proves the part that *is* true — the two +threshold-gated factors are monotone in their driving metric +(`superpositionMonotone` for `var_count > 10`; `temporalMonotone` for +`depth > 5`) — and retracts the blanket claim, recording the scope-leakage +obstruction explicitly. Non-monotone scope leakage is intentional; it is the +pedagogical point of the paradox. The difference now is that the proof says so +out loud, instead of hiding it behind `cast Refl`. + +## What was removed (2026-06-23) + +`src/abi/Foreign.idr` previously carried three "Safety Proofs" — +`stabilityBounded`, `positionalDeterministic`, `paradoxMonotonic` — that were +**not proofs**. Each manufactured its evidence with `cast ()` / `cast Refl` +over an `IO` action (e.g. calling an FFI function twice and coercing +`Refl : x = x` onto the two distinct results, with a comment that it "should +hold in practice"). An earlier note in this file claimed these files had been +removed; in fact `Foreign.idr` was still present and still exported the fakes. + +They are now deleted and replaced by the genuine, machine-checked modules above. + +## Open obligations + +1. **CI gate.** Add an Idris2 `--typecheck error-lang-abi.ipkg` job so the core + is checked on every push. (The dev image has no idris2 by default; it was + built from source for this change.) +2. **Implementation conformance.** The proofs are stated over abstract models + that mirror `ffi/zig/src/main.zig` and `compiler/src/Types.res`. Two of those + implementations **disagree**: positional behaviour is `column % 2` (two-way) + in the Zig FFI but `(line*31 + column) mod 4` (four-way) in `Stability.res`. + Reconcile them, then bind the proofs to the chosen implementation by + extraction or conformance tests rather than parallel models. +3. **Zig weighted-average path.** `error_lang_calculate_stability` is a convex + combination (weights sum to 1) of per-factor scores in [0,100]; its [0,100] + bound holds for a *different* reason than the `Stability.res` clamp proved + here. Prove that path too. +4. **Programs not executed in this environment.** Under the current network + policy the Deno runtime's JSR std deps (`jsr.io`) and Zig 0.13.0 + (`ziglang.org`) are unreachable, and the ReScript compiler does not currently + build (`return` is not valid ReScript — `VM.res:407`; `dict` + applies the one-argument `dict` constructor to two arguments — + `Types.res:233`). These were **not** run or fixed as part of this change and + are tracked as separate work — they are not claimed to pass. diff --git a/src/abi/Foreign.idr b/src/abi/Foreign.idr index f3158df..1bfa14a 100644 --- a/src/abi/Foreign.idr +++ b/src/abi/Foreign.idr @@ -9,13 +9,39 @@ ||| All functions have type signatures and safety guarantees proven at ||| compile-time through dependent types. -module ErrorLang.ABI.Foreign - -import ErrorLang.ABI.Types -import ErrorLang.ABI.Layout +module Foreign %default total +-------------------------------------------------------------------------------- +-- Minimal ABI value types (inlined so this binding module is self-contained +-- and independently checkable: `idris2 --check Foreign.idr` from src/abi). +-- The previous external `ErrorLang.ABI.Types` / `ErrorLang.ABI.Layout` modules +-- were removed. The fabricated "Safety Proofs" that lived here -- which used +-- `cast ()` / `cast Refl` over IO actions to manufacture evidence -- have been +-- replaced by genuine, machine-checked proofs in the sibling modules +-- Stability.idr, Positional.idr and Paradox.idr. +-- +-- This file is a BINDING-DECLARATION layer only: it declares the C ABI of the +-- Zig haptics library (ffi/zig). It asserts no theorems. +-------------------------------------------------------------------------------- + +||| Result codes (must match the Zig `Result` enum in ffi/zig/src/main.zig). +public export +data Result = Ok | Error | InvalidParam | OutOfMemory | NullPointer + +||| Opaque handle to a library instance (wraps the C pointer as Bits64). +public export +record Handle where + constructor MkHandle + handlePtr : Bits64 + +||| Build a handle from a raw pointer; a null (0) pointer yields Nothing. +export +createHandle : Bits64 -> Maybe Handle +createHandle 0 = Nothing +createHandle p = Just (MkHandle p) + -------------------------------------------------------------------------------- -- Library Lifecycle -------------------------------------------------------------------------------- @@ -234,50 +260,18 @@ isInitialized h = do pure (result /= 0) -------------------------------------------------------------------------------- --- Safety Proofs +-- Safety properties -------------------------------------------------------------------------------- - -||| Theorem: Stability scores are always bounded [0, 100] -||| -||| Proof: The Zig implementation validates all score inputs in -||| error_lang_set_stability_factor, rejecting any value < 0 or > 100. -||| The weighted average in error_lang_calculate_stability preserves -||| this bound through convex combination. -export -stabilityBounded : (h : Handle) -> (factor : Bits8) -> - IO (Either Result (score : Double ** (0.0 <= score, score <= 100.0))) -stabilityBounded h factor = do - score <- getStabilityFactor h factor - -- Runtime check (could be proven statically with refinement types) - if score >= 0.0 && score <= 100.0 - then pure (Right (score ** (cast (), cast ()))) - else pure (Left Error) - -||| Theorem: Positional operator behavior is deterministic -||| -||| Proof: For any given (line, column, operatorType) triple, the Zig -||| implementation always returns the same behavior. The calculation is -||| purely functional (column % n) with no hidden state. -export -positionalDeterministic : (h : Handle) -> (line, column : Bits32) -> (op : Bits8) -> - IO (b1 : Bits8 ** (b2 : Bits8 ** (b1 = b2))) -positionalDeterministic h line column op = do - b1 <- positionalOperator h line column op - b2 <- positionalOperator h line column op - -- In practice, should always be equal - pure (b1 ** (b2 ** cast Refl)) - -||| Theorem: Paradox detection is monotonic with respect to code complexity -||| -||| As lineCount, varCount, or depth increase, the set of detected paradoxes -||| (represented as a bitmask) cannot decrease. -export -paradoxMonotonic : (h : Handle) -> - (lc1, lc2, vc1, vc2, d1, d2 : Bits32) -> - (lc1 <= lc2) -> (vc1 <= vc2) -> (d1 <= d2) -> - IO (p1 : Bits32 ** (p2 : Bits32 ** ((p1 .&. p2) = p1))) -paradoxMonotonic h lc1 lc2 vc1 vc2 d1 d2 _ _ _ = do - p1 <- detectParadoxes h lc1 vc1 d1 - p2 <- detectParadoxes h lc2 vc2 d2 - -- Monotonicity should hold in practice (bitwise subset) - pure (p1 ** (p2 ** cast Refl)) +-- The properties this ABI relies on are proved -- genuinely, with no escape +-- hatch and machine-checked under Idris2 0.8.0 -- in the sibling modules, NOT +-- here: +-- * stability score in [0,100] -> Stability.idr (stabilityUpperBound) +-- * positional operator determinism -> Positional.idr (positionalDeterministic) +-- * paradox-factor monotonicity -> Paradox.idr (superpositionMonotone, +-- temporalMonotone) +-- +-- The earlier `stabilityBounded` / `positionalDeterministic` / `paradoxMonotonic` +-- definitions here were unsound: they used `cast ()` / `cast Refl` over `IO` +-- actions to fabricate evidence. Removed 2026-06-23. Proving the third one +-- honestly also revealed that the *global* monotonicity claim is false of the +-- implementation -- scope leakage is prime-gated; see Paradox.idr. diff --git a/src/abi/Paradox.idr b/src/abi/Paradox.idr new file mode 100644 index 0000000..331c1ff --- /dev/null +++ b/src/abi/Paradox.idr @@ -0,0 +1,77 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +||| Paradox-detection monotonicity (error-lang formal core, property 3 of 3). +||| +||| Mirrors the Zig FFI `error_lang_detect_paradoxes` +||| (`ffi/zig/src/main.zig`, lines 280-315), which sets: +||| * type_superposition when var_count > 10 (threshold; monotone) +||| * scope_leakage when isPrime(line) (prime-gated; NOT monotone) +||| * temporal_corruption when depth > 5 (threshold; monotone) +||| +||| HONEST FINDING. The previously-claimed blanket theorem "paradox detection +||| is monotonic with complexity" (README, WHITEPAPER §7, COMPLETION report) +||| is FALSE of the implementation: scope_leakage is gated on the PRIMALITY of +||| the line number, which is not monotone (line 7 is prime -> active; line 8 +||| is composite -> inactive, although 8 > 7). The original +||| `Foreign.idr :: paradoxMonotonic` hid this with `cast Refl`; attempting the +||| proof honestly surfaces that the blanket claim cannot hold. +||| +||| What IS true, and is proved here: the two THRESHOLD-gated factors are +||| monotone in their driving metric. The blanket claim is therefore retracted +||| in favour of these two component lemmas (see PROOF-NEEDS.md). Non-monotone +||| scope leakage is intentional -- it is the pedagogical point of the paradox. +||| +||| Self-contained; no escape hatches. Machine-check is a CI obligation. +module Paradox + +import Data.Nat + +%default total + +||| Transitivity of <= (self-contained; the standard definition). +lteTrans : LTE a b -> LTE b c -> LTE a c +lteTrans LTEZero _ = LTEZero +lteTrans (LTESucc p) (LTESucc q) = LTESucc (lteTrans p q) + +-- ─────────────────────────────────────────────────────────────────────── +-- Threshold-gated factors (monotone) +-- ─────────────────────────────────────────────────────────────────────── + +||| type_superposition fires when var_count exceeds 10 (11 <= var_count). +public export +SuperpositionActive : (varCount : Nat) -> Type +SuperpositionActive varCount = LTE 11 varCount + +||| temporal_corruption fires when depth exceeds 5 (6 <= depth). +public export +TemporalActive : (depth : Nat) -> Type +TemporalActive depth = LTE 6 depth + +||| THEOREM: type_superposition is monotone in var_count -- growing the +||| variable count never deactivates it. +public export +superpositionMonotone : (v1, v2 : Nat) -> LTE v1 v2 -> + SuperpositionActive v1 -> SuperpositionActive v2 +superpositionMonotone _ _ le active = lteTrans active le + +||| THEOREM: temporal_corruption is monotone in depth. +public export +temporalMonotone : (d1, d2 : Nat) -> LTE d1 d2 -> + TemporalActive d1 -> TemporalActive d2 +temporalMonotone _ _ le active = lteTrans active le + +-- ─────────────────────────────────────────────────────────────────────── +-- Scope leakage is NOT monotone (the retraction, made precise) +-- ─────────────────────────────────────────────────────────────────────── + +||| scope_leakage fires on prime line numbers. The obstruction to global +||| monotonicity, stated abstractly: for ANY predicate `p` with `p a = True` +||| and `p b = False` at `a <= b`, the detected set decreases as the metric +||| grows. Primality is such a `p` (witness a = 7, b = 8). Hence no global +||| monotonicity theorem exists -- and that is by design. +public export +scopeLeakObstruction : (p : Nat -> Bool) -> (a, b : Nat) -> LTE a b -> + p a = True -> p b = False -> + (p a = True, p b = False) +scopeLeakObstruction _ _ _ _ activeAtA inactiveAtB = (activeAtA, inactiveAtB) diff --git a/src/abi/Positional.idr b/src/abi/Positional.idr new file mode 100644 index 0000000..d175825 --- /dev/null +++ b/src/abi/Positional.idr @@ -0,0 +1,90 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +||| Positional-operator determinism (error-lang formal core, property 2 of 3). +||| +||| Mirrors the Zig FFI `error_lang_positional_operator` +||| (`ffi/zig/src/main.zig`, lines 222-272): for `+` the behaviour is +||| `addition` when the column is even and `concatenation` when odd; for `*` +||| it is `multiplication` when the column is a multiple of 3 and +||| `exponentiation` otherwise. +||| +||| THEOREM. Operator behaviour is a deterministic (pure, total) function of +||| the operator and the column: `behavior op col = behavior op col`. +||| +||| This is `Refl` -- *because* the model is a pure total function. That is +||| exactly the point: the previous `Foreign.idr :: positionalDeterministic` +||| stated the same property over an `IO Bits8` FFI action and could only +||| "close" it with `cast Refl`, coercing `Refl : x = x` onto two distinct IO +||| results -- a non-proof. Modelling the operation as the pure function it +||| actually is makes determinism genuine. +||| +||| Self-contained; no escape hatches. Machine-check is a CI obligation. +||| +||| NOTE (conformance). `compiler/src/Stability.res` uses a *different* rule +||| (`(line*31+column) mod 4`, four-way). The Zig FFI and the ReScript path +||| therefore disagree; reconciling the two implementations is an open +||| obligation recorded in PROOF-NEEDS.md. +module Positional + +%default total + +||| Operators that carry positional behaviour. +public export +data Op = Plus | Star | OtherOp + +||| Resolved operator behaviours (mirrors Zig `OperatorBehavior`). +public export +data Behavior + = Addition + | Concatenation + | Multiplication + | Exponentiation + +||| Column parity (total, structural). +public export +isEven : Nat -> Bool +isEven Z = True +isEven (S Z) = False +isEven (S (S k)) = isEven k + +||| Divisibility by three (total, structural). +public export +multipleOfThree : Nat -> Bool +multipleOfThree Z = True +multipleOfThree (S Z) = False +multipleOfThree (S (S Z)) = False +multipleOfThree (S (S (S k))) = multipleOfThree k + +||| The positional behaviour function (pure model of the Zig FFI). +public export +behavior : Op -> Nat -> Behavior +behavior Plus col = if isEven col then Addition else Concatenation +behavior Star col = if multipleOfThree col then Multiplication else Exponentiation +behavior OtherOp _ = Addition + +||| THEOREM: behaviour is deterministic -- a genuine `Refl` over a pure +||| total function (contrast the original IO-based `cast Refl`). +public export +positionalDeterministic : (op : Op) -> (col : Nat) -> behavior op col = behavior op col +positionalDeterministic _ _ = Refl + +-- ─────────────────────────────────────────────────────────────────────── +-- Sanity evaluations (match the Zig integration tests + README example) +-- ─────────────────────────────────────────────────────────────────────── + +||| Column 12 (even): `+` is addition. (Zig test "positional semantics".) +exEvenAddition : behavior Plus 12 = Addition +exEvenAddition = Refl + +||| Column 13 (odd): `+` is concatenation. +exOddConcatenation : behavior Plus 13 = Concatenation +exOddConcatenation = Refl + +||| Column 9 (multiple of 3): `*` is multiplication. +exStarMultiplication : behavior Star 9 = Multiplication +exStarMultiplication = Refl + +||| Column 10 (not a multiple of 3): `*` is exponentiation. +exStarExponentiation : behavior Star 10 = Exponentiation +exStarExponentiation = Refl diff --git a/src/abi/Stability.idr b/src/abi/Stability.idr new file mode 100644 index 0000000..7ce3617 --- /dev/null +++ b/src/abi/Stability.idr @@ -0,0 +1,120 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +||| Stability-score bound (error-lang formal core, property 1 of 3). +||| +||| Mirrors the stability calculation in `compiler/src/Types.res` +||| (`stabilityImpact` / `calculateStability`, lines 258-274). +||| +||| THEOREM. The stability score is always within [0, 100]. +||| +||| The ReScript `calculateStability` computes `Int.max(0, 100 + penalties)` +||| where every penalty is non-positive — i.e. `max(0, 100 - totalPenalty)`. +||| We model the clamp with Nat truncated subtraction (`minus`), which is +||| exactly `max(0, .)`: `minus 100 p` is 0 once `p >= 100`. The upper bound +||| (`<= 100`) is then a property of truncated subtraction; the lower bound +||| (`>= 0`) is inhabited by the Nat type itself. +||| +||| This module is self-contained (only `Data.Nat`) and uses NO escape hatch +||| (`believe_me` / `assert_total` / `cast`-coerced equality / `postulate`). +||| It replaces the previous `Foreign.idr :: stabilityBounded`, which faked +||| the bound with `cast ()` over an `IO` action. +||| +||| Status: written to typecheck under Idris2 (>= 0.7.0). Machine-check is a +||| CI obligation (no idris2 in the current dev image). See PROOF-NEEDS.md. +module Stability + +import Data.Nat + +%default total + +-- ─────────────────────────────────────────────────────────────────────── +-- Self-contained Nat <= lemmas (no reliance on stdlib lemma names) +-- ─────────────────────────────────────────────────────────────────────── + +||| Reflexivity of <=. +lteRefl' : (n : Nat) -> LTE n n +lteRefl' Z = LTEZero +lteRefl' (S k) = LTESucc (lteRefl' k) + +||| Weakening on the right: m <= n => m <= S n. +lteSuccR : LTE m n -> LTE m (S n) +lteSuccR LTEZero = LTEZero +lteSuccR (LTESucc p) = LTESucc (lteSuccR p) + +||| Truncated subtraction never exceeds the minuend: (n - m) <= n. +subLTE : (n, m : Nat) -> LTE (minus n m) n +subLTE Z _ = LTEZero +subLTE (S k) Z = lteRefl' (S k) +subLTE (S k) (S j) = lteSuccR (subLTE k j) + +-- ─────────────────────────────────────────────────────────────────────── +-- Faithful model of compiler/src/Types.res stability factors +-- ─────────────────────────────────────────────────────────────────────── + +||| A consequence factor and its magnitude inputs (mirrors `stabilityFactor`). +public export +data Factor + = MutableState Nat Nat -- mutations, readers + | TypeInstability Nat -- reassignments + | NullPropagation Nat -- depth + | GlobalState Nat Nat -- mutations, dependencies + | UnhandledError Nat -- failure paths + | AlgorithmComplexity Nat -- amplified time units + | MemoryLeak Nat -- kilobytes + | RaceCondition Nat -- conflicts + +||| Penalty magnitude of a factor (mirrors `stabilityImpact`, expressed as a +||| non-negative cost that is subtracted from the base of 100). +public export +factorCost : Factor -> Nat +factorCost (MutableState m r) = 10 * m + 5 * r +factorCost (TypeInstability r) = 15 * r +factorCost (NullPropagation d) = 20 * d +factorCost (GlobalState m d) = 30 * m + 5 * d +factorCost (UnhandledError p) = 25 * p +factorCost (AlgorithmComplexity t) = t +factorCost (MemoryLeak kb) = 10 * kb +factorCost (RaceCondition c) = 40 * c + +||| Total penalty across all active factors. +public export +totalCost : List Factor -> Nat +totalCost [] = 0 +totalCost (f :: fs) = factorCost f + totalCost fs + +||| Stability score = base 100 minus total penalty, clamped at 0. +||| (Nat `minus` is truncated, modelling `Int.max(0, 100 + penalties)`.) +public export +stabilityScore : List Factor -> Nat +stabilityScore fs = minus 100 (totalCost fs) + +-- ─────────────────────────────────────────────────────────────────────── +-- THEOREM: 0 <= stabilityScore fs <= 100 +-- ─────────────────────────────────────────────────────────────────────── + +||| Upper bound: the score never exceeds 100. +public export +stabilityUpperBound : (fs : List Factor) -> LTE (stabilityScore fs) 100 +stabilityUpperBound fs = subLTE 100 (totalCost fs) + +||| Lower bound: the score is never negative (inhabited by the Nat type). +public export +stabilityLowerBound : (fs : List Factor) -> LTE 0 (stabilityScore fs) +stabilityLowerBound _ = LTEZero + +-- ─────────────────────────────────────────────────────────────────────── +-- Sanity evaluations (closed terms; reduce by computation) +-- ─────────────────────────────────────────────────────────────────────── + +||| No factors: full stability. +sanityFull : stabilityScore [] = 100 +sanityFull = Refl + +||| One mutation with two readers: 100 - (10*1 + 5*2) = 80. +sanityOneMutation : stabilityScore [MutableState 1 2] = 80 +sanityOneMutation = Refl + +||| Penalties exceeding 100 clamp to 0 (never negative): 40*3 = 120 -> 0. +sanityClamp : stabilityScore [RaceCondition 3] = 0 +sanityClamp = Refl diff --git a/src/abi/error-lang-abi.ipkg b/src/abi/error-lang-abi.ipkg new file mode 100644 index 0000000..97a8168 --- /dev/null +++ b/src/abi/error-lang-abi.ipkg @@ -0,0 +1,14 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell +-- +-- The error-lang formal core + ABI binding layer. +-- Build/typecheck: idris2 --typecheck error-lang-abi.ipkg (from src/abi) +-- or per-module: idris2 --check Stability.idr (from src/abi) +package error-lang-abi +version = 0.1.0 +authors = "Jonathan D.A. Jewell" +sourcedir = "." +modules = Stability + , Positional + , Paradox + , Foreign diff --git a/verification/check-proofs.sh b/verification/check-proofs.sh new file mode 100755 index 0000000..ba97369 --- /dev/null +++ b/verification/check-proofs.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell +# +# Machine-check the error-lang formal core (src/abi/*.idr) with Idris2. +# +# Requires idris2 >= 0.8.0 on PATH. Each module is self-contained (no local +# cross-imports), so they are checked independently from within src/abi (the +# bare module names match the file names). NO module uses an escape hatch +# (believe_me / assert_total / cast-coerced equality / postulate) -- the point +# of the core is that the proofs are genuine. +set -euo pipefail + +ABI_DIR="$(cd "$(dirname "$0")/../src/abi" && pwd)" + +if ! command -v idris2 >/dev/null 2>&1; then + echo "error: idris2 not found on PATH (need >= 0.8.0)." >&2 + echo "build it from source via Chez Scheme (see PROOF-NEEDS.md), then re-run." >&2 + exit 127 +fi + +echo "idris2: $(idris2 --version)" +cd "$ABI_DIR" +status=0 +for m in Stability Positional Paradox Foreign; do + printf 'checking %-12s ... ' "$m" + if idris2 --check "$m.idr" >/dev/null 2>&1; then + echo ok + else + echo FAIL + idris2 --check "$m.idr" || true + status=1 + fi +done + +if [ "$status" -eq 0 ]; then + echo "all proofs check." +else + echo "one or more proofs failed to check." >&2 +fi +exit "$status" From 52f57e0b8834c45311b5c12c659f7e1bca689262 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:35:21 +0000 Subject: [PATCH 02/11] docs: design Error-Lang as a Trope IR front end (trope-particularity integration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds docs/Trope-Particularity-Integration.adoc — a design (not yet implemented) for lowering Error-Lang's Echo operations and stability factors to the language-neutral Trope IR (hyperpolymath/trope-checker, v0.1 prevent profile) and consuming the verified verdict + witness. - Object/effect/grade correspondence: Echo <-> Trope[Phi], EchoR <-> FloatingQuality, echo/echo_to_residue/echo_input/echo_output <-> preserve/ detach/project. echo_to_residue IS detach (bond=Severed, irrecoverable), matching the [Stab-Erase] debit and "decomposition must be visible". - stabilityFactor -> grade mapping; the silent instabilities (GlobalState, RaceCondition) land on the deceptive Conflated bottom -> a lowering fault under the prevent profile. - Verdict mapping: scalar calculateStability -> use-model floor + p-sufficient/ p-insufficient + witness edge (the invariant-path argmin Stability.idr already reasons about). - Architecture (reference, never vendor; schema is the trust boundary), the per-front-end O2 lowering-correctness obligations (L-Echo/L-Grade/L-Silent/ L-Floor), and a 4-phase plan. Builds on docs/Echo-Decomposition.adoc; references echo-types, trope-checker and trope-particularity-workbench by URL only. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0195yA45jSSP7YDPwJSpw4bM --- docs/Trope-Particularity-Integration.adoc | 246 ++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 docs/Trope-Particularity-Integration.adoc diff --git a/docs/Trope-Particularity-Integration.adoc b/docs/Trope-Particularity-Integration.adoc new file mode 100644 index 0000000..017125d --- /dev/null +++ b/docs/Trope-Particularity-Integration.adoc @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell += Trope-Particularity Integration: Error-Lang as a Trope IR Front End +:toc: +:sectnums: +:source-highlighter: rouge + +[abstract] +This is a *design* (not yet implemented). It proposes that Error-Lang become a +second *front end* to the portable trope-checker +(https://github.com/hyperpolymath/trope-checker[`hyperpolymath/trope-checker`]), +lowering its Echo operations and its stability factors to the language-neutral +*Trope IR* (v0.1, `prevent` profile) and consuming the checker's verified verdict +and witness. It defines the object/effect/grade correspondence, the verdict +mapping, the architecture (reference, never vendor), and — most importantly — the +per-front-end *lowering-correctness obligations* the trope-checker does **not** +discharge for us. + +Both systems already sit on the same substrate: the +https://github.com/hyperpolymath/echo-types[echo-types] graded-loss line, cited +verbatim by `docs/Echo-Decomposition.adoc` and by the trope calculus +(`trope-checker/spec/calculus.adoc`, "Provenance of the ideas"). This document +makes that shared lineage *operational*. + +== Motivation: a stability score is a scalar; loss is not + +Error-Lang grades instability with `calculateStability` (`compiler/src/Types.res` +lines 270–274): + +[source] +---- +calculateStability(factors) = max(0, 100 + Σ stabilityImpact(factor)) +---- + +Every `stabilityFactor` is mapped to a single negative integer and the integers +are summed. That is a *scalar collapse* of structured loss. The trope-particularity +calculus exists to reject exactly this move — its load-bearing thesis +(`calculus.adoc` §3) is: + +[quote] +A grade is *not a scalar*: two operations can lose the same _amount_ yet differ +in _kind_ and in _honesty_. + +So the calculus's three-coordinate grade is a principled *upgrade* of Error-Lang's +stability model. It lets us keep _which_ particularity degraded, whether the loss +is _recoverable_, and whether it is _honest_ — and it yields a use-relative +*verdict* with a *witness edge* ("the operation to repair") in place of an opaque +`0–100` number. For a language whose entire identity is the *visible decomposition* +of structure (`Echo-Decomposition.adoc`, "Decomposition must be visible"), this is +a capability upgrade, not ornament. + +Two further alignments make the fit unusually tight: + +* **The Echo operations already _are_ trope effects.** `echo`, `echo_to_residue`, + `echo_input`, `echo_output` implement witness-retention and irreversible erasure + — which the calculus names `preserve`, `detach`, and field `project` (§5). +* **The verification toolchains already overlap.** The trope-checker's core is an + Agda reference plus an Idris2 implementation (`trope-checker/src/idris2/Main.idr`); + Error-Lang already carries an Idris2 proof of the *scalar* bound + (`src/abi/Stability.idr`). The integration generalises that proof's target from + a clamped integer to a checked grade. + +== The correspondence + +=== Objects: Echo ↔ trope, EchoR ↔ FloatingQuality + +A trope is a property-instance over the field set +`Φ = {quality, bearer, context, record}` (`calculus.adoc` §2). Error-Lang's +`Echo` — a retained witness `x : A` with the visible output `y : B` it +reached — populates Φ as: + +[cols="1,2,3",options="header"] +|=== +| Φ field | Echo content | Justification + +| `bearer` | the witness `x : A` | the *particular* entity the result is a result _of_ +| `quality` | the reached output `y : B` (and `f x ≡ y`) | the situated property borne by `x` +| `context` | the function `f` / evaluation site | what individuates this fibre element +| `record` | the runtime pairing `VEcho{input,output}` | honest provenance of how `y` arose +|=== + +[cols="1,2",options="header"] +|=== +| Echo form | Trope IR node + +| `Echo` | `type: "Trope"`, `present: ["quality","bearer","context","record"]` +| `EchoR` | `type: "FloatingQuality"`, `present: ["quality"]` (no `bearer` — the severance is structurally visible) +|=== + +The type change `Echo → EchoR` is exactly the type change `Trope → FloatingQuality`, +and the reason `echo_input` is *illegal on a residue* (`Echo-Decomposition.adoc` +Plane 3) is, in IR terms, that a `FloatingQuality` node has *no bearer field to +project*. The same fact, two vocabularies. + +=== Echo operations → writable effects + +[cols="2,2,3",options="header"] +|=== +| Echo op | Effect | Grade (per `calculus.adoc` §5) + +| `echo(x,y)` | `preserve` | `ε` — all fields `Present`, `bond=Intact`, `merge=Single` +| `echo_output(e)` | `project[quality]` | drop `bearer,context,record`; quality survives; `bond=Withheld` +| `echo_input(e)` | `project[bearer]` | legal only while `bearer ∈ S`; *undefined on `FloatingQuality`* +| `echo_to_residue(e)`| `detach` | `sever`: `fate(quality)=Present`, others `Dropped`, `bond=Severed`, `merge=Single` +|=== + +The `[Stab-Erase]` rule (`spec/type-system.md` §7) debits stability *exactly once*, +on `echo_to_residue`, and never on projection. That is precisely the calculus's +accounting: `detach` carries the `Severed` (irrecoverable) loss; `project` carries +only a recoverable `Withheld`. The educational invariant "`echo_to_residue` must +**not** become a silent cast" is the calculus's refusal of untagged/deceptive +collapse. + +.Worked Trope IR — `echo_to_residue` as a `detach` (illustrative, schema-shaped) +[source,json] +---- +{ + "version": "0.1", "profile": "prevent", + "nodes": [ + { "id": "e", "type": "Trope", "present": ["quality","bearer","context","record"] }, + { "id": "res", "type": "FloatingQuality", "present": ["quality"] } + ], + "edges": [ + { "id": "erase", "effect": "detach", "inputs": ["e"], "output": "res", + "grade": { + "fate": { "quality": {"k":"Present"}, "bearer": {"k":"Dropped"}, + "context": {"k":"Dropped"}, "record": {"k":"Dropped"} }, + "bond": { "k": "Severed" }, "merge": { "k": "Single" } }, + "note": "echo_to_residue: witness erased, output reachable" } + ], + "use_model": { "output": "res", "floor": { "bond": { "k": "Withheld" } } } +} +---- + +Here the floor demands `bond ⊒ Withheld` (the use needs a _recoverable_ bearer); +since `detach` delivered `Severed`, the verdict is `p-insufficient`, witness = +`erase`. A use that only reads `echo_output` would declare a quality-only floor and +pass. The score becomes a *reason*. + +=== stabilityFactor → grade + +Each `stabilityFactor` becomes a grade, with its `stabilityImpact` magnitude +feeding the fidelity element `δ`. Crucially, the two *silent* instabilities land +on the deceptive `Conflated` bottom — an untagged merge of particulars — which, +under the `prevent` profile, is a *lowering fault* the validator rejects by name. +Error-Lang's worst bugs are the calculus's moral-core violation. + +[cols="2,3,2",options="header"] +|=== +| Factor | Grade (faithful lowering) | Honesty + +| `TypeInstability{reassignments}` | `fate(quality)=Attenuated(15·r)` | faithful +| `NullPropagation{depth}` | `fate(quality)=Attenuated(20·d)`, `Dropped` at the leaf | faithful +| `UnhandledError{paths}` | `fate=Dropped` on the unguarded error fields | faithful (visible gap) +| `AlgorithmComplexity{time_ms}` | `fate=Attenuated(δ)`, `δ=⊤` when unbounded (matches `fix`→`⊤`, §7) | faithful +| `MutableState{mutations,readers}` | `fate(quality)=Attenuated(10·m+5·r)`; `Fused(τ=write-site)` if writes blend | faithful if tracked +| `MemoryLeak{bytes}` | `detach`: `bond=Severed` (owner unreachable, irrecoverable) | faithful +| `GlobalState{mutations,deps}` | `Fused(τ=global@site)` if threaded; **`Conflated` (fault)** if silent | *deceptive when silent* +| `RaceCondition{conflicts}` | `Fused(τ=lock)` if serialised; **`Conflated` (fault)** if unsynchronised | *deceptive when silent* +|=== + +=== Verdict and witness + +[cols="1,2",options="header"] +|=== +| Error-Lang today | Trope-checker + +| `score = max(0,100+Σ)` | `p-sufficient ⟺ floor(U) ⊑ acc(output)` +| (no locus) | `p-insufficient` + *witness edge* = first edge whose accumulated grade drops below the floor +| `breakdown : dict` | per-coordinate retention at each node +| `recommendStabilization(factor)` | human advice *attached to the witnessed edge* (now principled, not heuristic) +| `Stability.idr`: `score ∈ [0,100]` | grade soundness (`calculus.adoc` §8): declared grade never over-claims retention +|=== + +The witness is, by the calculus's own statement (§6.2), "the trope-particularity +analogue of the invariant-path argmin" — the same argmin shape `Stability.idr` +already reasons about. The verdict thus _subsumes_ the current score: the score is +recoverable as a projection, but the verdict additionally names the edge to repair. + +== Architecture: reference, never vendor + +[cols="1,3",options="header"] +|=== +| Concern | Decision + +| Trust boundary | Pin `trope-checker/schemas/trope-ir.schema.json` at `version 0.1`, `profile prevent`. The schema is the contract (mirrors the IR spec's "schema is the trust boundary"). +| Dependency | Depend on the `trope-checker` *binary* (a pure `IR → verdict` function) and the *IR schema* by URL. Do **not** vendor the calculus, the checker, or `haec`. +| New backend | Add a `trope` lowering target beside the existing codegen backends: Error-Lang AST/VM ops → Trope IR DAG (schema-validated) → `trope-checker` → verdict object → surfaced as Error-Lang diagnostics. +| Precedent | The same trust-tagged "fold external prover output back into our report" pattern panic-attack uses in `src/aggregate/`. +| Multi-producer | Sanctioned by `trope-ir.adoc`: "a static analyser for an existing language MAY emit Trope IR for code it did not author." Error-Lang's analyzer is exactly such a producer. +|=== + +== O2 — lowering-correctness obligations (ours to discharge) + +The trope-checker proves the *composition* of grades is sound. It explicitly does +**not** prove that an Error-Lang construct lowered to effect `X` _is_ an `X` +(`calculus.adoc` §8, firewall 2; §10-O2). Those are our proof obligations: + +[cols="1,4",options="header"] +|=== +| ID | Obligation + +| *L-Echo* | `OpEchoToResidue` (`VM.res`) semantically _is_ `detach`: the witness becomes unreachable ⇒ `bond=Severed`; output reachability survives ⇒ `fate(quality)=Present`. **Open decision:** is residue's quality `Present`, or `Attenuated(δ)` (only "reachability", not full `y`)? This must be fixed before Phase 1 freezes the lowering. +| *L-Grade* | Each `stabilityFactor`'s grade is a *faithful over-approximation* of the real loss (grade-soundness direction: never claim more retention than occurs). The current `stabilityImpact` magnitudes are heuristic; for soundness `δ` must be a conservative loss bound. +| *L-Silent* | The `Conflated` lowering of `GlobalState`/`RaceCondition` is correct only when the merge is genuinely untagged. If a provenance tag is recoverable, we MUST emit `Fused(τ)` instead; emitting `Conflated` for a tractable merge is a false positive. +| *L-Floor* | The `use_model` floor Error-Lang emits faithfully encodes the program's declared stability requirement (per-function loss signatures, §7 "Declared signatures at the boundaries"). +|=== + +These mirror, in Error-Lang's setting, the open obligations the calculus states for +itself (O1–O4) — and they are checkable with the *same* Idris2 discipline already +used in `src/abi/`. + +== Phasing + +[cols="1,3",options="header"] +|=== +| Phase | Work + +| *0 (now)* | Shape the AffineScript Echo types (during the ReScript→AffineScript port) so `Echo`/`EchoR`/`echo_to_residue` lower cleanly to `Trope`/`FloatingQuality`/`detach`. This document + cross-references. *No new runtime coupling.* +| *1* | Implement the `trope` lowering backend for the Echo operations only (the tightest correspondence). Emit schema-valid IR; conformance-test against `trope-checker/tests/conformance/fixtures/`. +| *2* | Lower `stabilityFactor` → grade and emit a `use_model`; surface the verdict + witness as diagnostics alongside (then in place of) the scalar score. +| *3* | Discharge L-Echo / L-Grade / L-Silent / L-Floor as Idris2/Agda proofs; CI-gate the lowering. +|=== + +== Open questions + +* *Profile.* Adopt `prevent` (silent merges rejected at validation — strongest, and + on-message for "decomposition must be visible") or `detect` (representable, caught + at the verdict)? This design assumes `prevent`. +* *Residue fidelity* (L-Echo): `Present` vs `Attenuated(δ)` for `echo_to_residue`. +* *Floor authorship.* Where do use-models come from — a whole-program default, or + per-function loss-signature annotations the learner writes? +* *Coverage* (calculus O1): are all eight `stabilityFactor`s expressible with the + six writable effects? The table above is a well-chosen mapping, not yet a theorem. + +== See also + +* `docs/Echo-Decomposition.adoc` — the three decomposition planes this builds on. +* `docs/Design-Philosophy.adoc` — consequence amplification and the stability score. +* `spec/type-system.md` §7 — typing rules and the `[Stab-Erase]` stability debit. +* `src/abi/Stability.idr` — the existing Idris2 bound proof (verdict-soundness anchor). +* External (referenced, not vendored): + https://github.com/hyperpolymath/trope-checker[trope-checker] (`spec/calculus.adoc`, + `spec/trope-ir.adoc`, `schemas/trope-ir.schema.json`), + https://github.com/hyperpolymath/trope-particularity-workbench[trope-particularity-workbench] + (the nine effects), https://github.com/hyperpolymath/echo-types[echo-types] (shared substrate). From a3c0df8ba6c0f447bee723a5c26e11b5d5c9a60b Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:55:59 +0000 Subject: [PATCH 03/11] docs: qualify external repo paths to clear structural_drift (SD022) Hypatia structural_drift flagged `src/idris2/` and `src/aggregate/` in the trope-integration design as dangling references "surviving a directory rename". Both are deliberately *external*: the trope-checker repo's Idris2 core and the panic-attack repo's aggregate module. Reword as unambiguously external (drop the bare `src//` form) so the heuristic no longer reads them as internal error-lang tree paths. No substantive content change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0195yA45jSSP7YDPwJSpw4bM --- docs/Trope-Particularity-Integration.adoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/Trope-Particularity-Integration.adoc b/docs/Trope-Particularity-Integration.adoc index 017125d..ea4ad12 100644 --- a/docs/Trope-Particularity-Integration.adoc +++ b/docs/Trope-Particularity-Integration.adoc @@ -55,7 +55,7 @@ Two further alignments make the fit unusually tight: `echo_input`, `echo_output` implement witness-retention and irreversible erasure — which the calculus names `preserve`, `detach`, and field `project` (§5). * **The verification toolchains already overlap.** The trope-checker's core is an - Agda reference plus an Idris2 implementation (`trope-checker/src/idris2/Main.idr`); + Agda reference plus an Idris2 implementation (the `trope-checker` repo's Idris2 core, `Main.idr`); Error-Lang already carries an Idris2 proof of the *scalar* bound (`src/abi/Stability.idr`). The integration generalises that proof's target from a clamped integer to a checked grade. @@ -186,7 +186,7 @@ recoverable as a projection, but the verdict additionally names the edge to repa | Trust boundary | Pin `trope-checker/schemas/trope-ir.schema.json` at `version 0.1`, `profile prevent`. The schema is the contract (mirrors the IR spec's "schema is the trust boundary"). | Dependency | Depend on the `trope-checker` *binary* (a pure `IR → verdict` function) and the *IR schema* by URL. Do **not** vendor the calculus, the checker, or `haec`. | New backend | Add a `trope` lowering target beside the existing codegen backends: Error-Lang AST/VM ops → Trope IR DAG (schema-validated) → `trope-checker` → verdict object → surfaced as Error-Lang diagnostics. -| Precedent | The same trust-tagged "fold external prover output back into our report" pattern panic-attack uses in `src/aggregate/`. +| Precedent | The same trust-tagged "fold external prover output back into our report" pattern the panic-attack repo uses in its `aggregate/` module. | Multi-producer | Sanctioned by `trope-ir.adoc`: "a static analyser for an existing language MAY emit Trope IR for code it did not author." Error-Lang's analyzer is exactly such a producer. |=== From 27571a32c4147ffe850a1fb413dfa6ec249a782f Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:20:45 +0000 Subject: [PATCH 04/11] =?UTF-8?q?compiler:=20begin=20ReScript->AffineScrip?= =?UTF-8?q?t=20migration=20=E2=80=94=20port=20Types.res?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First module of the compiler's migration to AffineScript (the Hyperpolymath language policy bans ReScript). Adds compiler/src/Types.affine, a faithful port of compiler/src/Types.res, verified green with `affinescript check`: - all token / AST / error / stability types (structs + enums + match) - ReScript inline-record variants lowered to positional constructor args - token variants Float/String renamed FloatTok/StringTok (reserved type keywords in AffineScript) - Echo types (TyEcho / TyEchoResidue) shaped Trope-IR-ready per docs/Trope-Particularity-Integration.adoc (Phase 0) - make_default_state, stability_impact, calculate_stability, error_code_to_string Toolchain, so the .affine sources are reproducibly CI-verifiable: - scripts/install-affinescript-toolchain.sh — builds the AffineScript compiler from distro OCaml packages (independent of opam.ocaml.org) + installs the binary and stdlib under a discoverable share/ path - verification/check-affinescript.sh — typechecks all compiler/src/*.affine Types.res is retained until its dependents migrate; format_diagnostic is deferred pending the string / affine-borrow pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0195yA45jSSP7YDPwJSpw4bM --- compiler/src/Types.affine | 235 ++++++++++++++++++++++ scripts/install-affinescript-toolchain.sh | 45 +++++ verification/check-affinescript.sh | 41 ++++ 3 files changed, 321 insertions(+) create mode 100644 compiler/src/Types.affine create mode 100755 scripts/install-affinescript-toolchain.sh create mode 100755 verification/check-affinescript.sh diff --git a/compiler/src/Types.affine b/compiler/src/Types.affine new file mode 100644 index 0000000..e9fd954 --- /dev/null +++ b/compiler/src/Types.affine @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: MPL-2.0 +// Types.affine — core type definitions for Error-Lang +// Ported from compiler/src/Types.res. Port conventions: +// * ReScript inline-record variants -> positional constructor args. +// * array -> [T]; option -> Option; dict -> Dict; tuples kept. +// * Token variants `Float`/`String` renamed `FloatTok`/`StringTok` +// (`Float`/`String` are reserved type keywords in AffineScript). + +use prelude::*; + +struct Position { + line: Int, + column: Int, + offset: Int +} + +struct Location { + start: Position, + end_: Position, + file: String +} + +enum TokenType { + // Keywords + Main, End, Let, Mutable, Function, Struct, If, Elseif, Else, While, For, In, + Break, Continue, Return, And, Or, Not, True, False, Nil, Gutter, Fn, + // Types + TInt, TFloat, TString, TBool, TArray, TEcho, TEchoR, + // Literals + Integer(Int), + FloatTok(Float), // ReScript Float(float) + StringTok(String), // ReScript String(string) + Identifier(String), + // Operators + Plus, Minus, Star, Slash, Percent, EqualEqual, BangEqual, Less, Greater, + LessEqual, GreaterEqual, Ampersand, Pipe, Caret, Tilde, LessLess, GreaterGreater, + Equal, Arrow, Question, Colon, + // Delimiters + LParen, RParen, LBracket, RBracket, LBrace, RBrace, Comma, Dot, + // Special + Newline, EOF, Error(String) +} + +struct Token { + type_: TokenType, + lexeme: String, + loc: Location +} + +// ============================================ +// AST +// ============================================ + +enum Expr { + IntLit(Int, Location), + FloatLit(Float, Location), + StringLit(String, Location), + BoolLit(Bool, Location), + NilLit(Location), + Ident(String, Location), + Array([Expr], Location), + Binary(Expr, BinaryOp, Expr, Location), + Unary(UnaryOp, Expr, Location), + Call(Expr, [Expr], Location), + Index(Expr, Expr, Location), + Member(Expr, String, Location), + Ternary(Expr, Expr, Expr, Location), + Lambda([Param], Option, LambdaBody, Location) +} + +enum BinaryOp { + Add, Sub, Mul, Div, Mod, + Eq, Neq, Lt, Gt, Lte, Gte, + BAnd, BOr, BXor, Shl, Shr, + LAnd, LOr +} + +enum UnaryOp { Neg, LNot, BNot } + +struct Param { + name: String, + type_: Option, + loc: Location +} + +enum TypeExpr { + TyInt, + TyFloat, + TyString, + TyBool, + TyArray(TypeExpr), + // Echo types (Trope-IR-ready, see docs/Trope-Particularity-Integration.adoc): + // TyEcho ~ Trope[Phi] (retained witness) + // TyEchoResidue ~ FloatingQuality (witness severed) + TyEcho(Option, Option), + TyEchoResidue(Option, Option), + TyIdent(String) +} + +enum LambdaBody { + LambdaExpr(Expr), + LambdaBlock([Stmt]) +} + +enum Stmt { + // inline records -> positional: (mutable_, name, type_, value, loc) + LetStmt(Bool, String, Option, Expr, Location), + // (target, value, loc) + AssignStmt(Expr, Expr, Location), + // (cond, then_, elseifs, else_, loc) + IfStmt(Expr, [Stmt], [(Expr, [Stmt])], Option<[Stmt]>, Location), + // (cond, body, loc) + WhileStmt(Expr, [Stmt], Location), + // (var, iter, body, loc) + ForStmt(String, Expr, [Stmt], Location), + // (value, loc) + ReturnStmt(Option, Location), + BreakStmt(Location), + ContinueStmt(Location), + // (println, args, loc) + PrintStmt(Bool, [Expr], Location), + // (tokens, recovered, loc) + GutterBlock([Token], Bool, Location), + ExprStmt(Expr) +} + +enum Decl { + // (name, params, returnType, body, loc) + FunctionDecl(String, [Param], Option, [Stmt], Location), + // (name, fields, loc) + StructDecl(String, [(String, TypeExpr)], Location), + // (body, loc) + MainBlock([Stmt], Location), + StmtDecl(Stmt) +} + +struct Program { + declarations: [Decl], + loc: Location +} + +// ============================================ +// Errors +// ============================================ + +enum ErrorCode { + E0001, E0002, E0003, E0004, E0005, E0006, E0007, E0008, E0009, E0010 +} + +struct Diagnostic { + code: ErrorCode, + message: String, + loc: Location, + runNumber: Int, + hint: Option +} + +// ============================================ +// Runtime state & stability +// ============================================ + +enum StabilityFactor { + MutableState(Int, Int), // mutations, readers + TypeInstability(Int), // reassignments + NullPropagation(Int), // depth + GlobalState(Int, Int), // mutations, dependencies + UnhandledError(Int), // paths + AlgorithmComplexity(Float), // time_ms + MemoryLeak(Int), // bytes + RaceCondition(Int) // conflicts +} + +struct StabilityReport { + score: Int, + factors: [StabilityFactor], + breakdown: Dict, + recommendations: [String] +} + +struct RuntimeState { + runCounter: Int, + stabilityScore: Int, + lastError: Option, + seed: Int, + stabilityFactors: [StabilityFactor], + discoveredRules: [String], + historicalRuns: [Int] +} + +fn make_default_state() -> RuntimeState { + #{ + runCounter: 0, + stabilityScore: 100, + lastError: None, + seed: 0, + stabilityFactors: [], + discoveredRules: [], + historicalRuns: [] + } +} + +// Stability impact (non-positive), mirrors Types.res `stabilityImpact`. +fn stability_impact(factor: StabilityFactor) -> Int { + match factor { + MutableState(mutations, readers) => -(10 * mutations + 5 * readers), + TypeInstability(reassignments) => -(15 * reassignments), + NullPropagation(depth) => -(20 * depth), + GlobalState(mutations, dependencies) => -(30 * mutations + 5 * dependencies), + UnhandledError(paths) => -(25 * paths), + AlgorithmComplexity(time_ms) => -trunc(time_ms / 10.0), + MemoryLeak(bytes) => -(10 * (bytes / 1024)), + RaceCondition(conflicts) => -(40 * conflicts) + } +} + +// mirrors Types.res `calculateStability`: max(0, 100 + sum(impacts)) +fn calculate_stability(factors: [StabilityFactor]) -> Int { + let penalties = fold(factors, 0, |acc, x| acc + stability_impact(x)); + max(0, 100 + penalties) +} + +fn error_code_to_string(code: ErrorCode) -> String { + match code { + E0001 => "E0001", + E0002 => "E0002", + E0003 => "E0003", + E0004 => "E0004", + E0005 => "E0005", + E0006 => "E0006", + E0007 => "E0007", + E0008 => "E0008", + E0009 => "E0009", + E0010 => "E0010" + } +} diff --git a/scripts/install-affinescript-toolchain.sh b/scripts/install-affinescript-toolchain.sh new file mode 100755 index 0000000..6c3a863 --- /dev/null +++ b/scripts/install-affinescript-toolchain.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# install-affinescript-toolchain.sh — build & install the AffineScript compiler +# (hyperpolymath/affinescript, the OCaml/dune compiler) so error-lang's `.affine` +# sources — which replace the legacy ReScript per the Hyperpolymath language +# policy — can be typechecked and compiled. +# +# Why build from distro OCaml packages instead of opam: some CI/network policies +# block opam.ocaml.org. Every dependency below is available from the Debian/Ubuntu +# archive at a version satisfying affinescript's dune-project constraints +# (notably ocaml-dune 3.14 == `(lang dune 3.14)`). +# +# Network note: this clones github.com/hyperpolymath/affinescript directly; run it +# where GitHub is reachable (a normal CI runner or dev box). +set -euo pipefail + +AFFINE_REPO="${AFFINE_REPO:-https://github.com/hyperpolymath/affinescript}" +AFFINE_SRC="${AFFINE_SRC:-${TMPDIR:-/tmp}/affinescript}" +PREFIX="${PREFIX:-/usr/local}" +SUDO="$(command -v sudo || true)" + +# 1. OCaml toolchain + AffineScript build dependencies. +$SUDO apt-get update +$SUDO apt-get install -y \ + ocaml-dune menhir libmenhir-ocaml-dev libsedlex-ocaml-dev \ + libppx-deriving-ocaml-dev libppx-sexp-conv-ocaml-dev libsexplib0-ocaml-dev \ + libfmt-ocaml-dev libcmdliner-ocaml-dev libyojson-ocaml-dev \ + libppxlib-ocaml-dev libjs-of-ocaml-dev + +# 2. Fetch + build the compiler binary. +[ -d "$AFFINE_SRC/.git" ] || git clone --depth 1 "$AFFINE_REPO" "$AFFINE_SRC" +( cd "$AFFINE_SRC" && dune build bin/main.exe ) + +# 3. Install the binary + stdlib. The module loader discovers the stdlib at +# /../share/affinescript/stdlib, so this needs no env var. +$SUDO install -m755 "$AFFINE_SRC/_build/default/bin/main.exe" "$PREFIX/bin/affinescript" +$SUDO mkdir -p "$PREFIX/share/affinescript" +$SUDO rm -rf "$PREFIX/share/affinescript/stdlib" +$SUDO cp -r "$AFFINE_SRC/stdlib" "$PREFIX/share/affinescript/stdlib" + +echo "Installed: $(command -v affinescript)" +affinescript check "$AFFINE_SRC/examples/hello.affine" || true +echo "AffineScript toolchain installed under $PREFIX." diff --git a/verification/check-affinescript.sh b/verification/check-affinescript.sh new file mode 100755 index 0000000..7644980 --- /dev/null +++ b/verification/check-affinescript.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# check-affinescript.sh — typecheck every ported `.affine` compiler source with +# the AffineScript compiler. Companion to verification/check-proofs.sh. +# Requires `affinescript` on PATH (see scripts/install-affinescript-toolchain.sh). +set -euo pipefail + +cd "$(dirname "$0")/.." + +if ! command -v affinescript >/dev/null 2>&1; then + echo "affinescript not found — run scripts/install-affinescript-toolchain.sh" >&2 + exit 127 +fi + +shopt -s nullglob +sources=(compiler/src/*.affine) +if [ ${#sources[@]} -eq 0 ]; then + echo "no .affine sources yet (ReScript->AffineScript migration in progress)." + exit 0 +fi + +fail=0 +for f in "${sources[@]}"; do + printf 'checking %-28s ... ' "$(basename "$f")" + if affinescript check "$f" >/tmp/as_check.out 2>&1; then + echo ok + else + echo FAIL + cat /tmp/as_check.out + fail=1 + fi +done + +if [ "$fail" -eq 0 ]; then + echo "all .affine sources check." +else + echo "affinescript check failures." >&2 + exit 1 +fi From 8691fc691a85fc4e8eb98695c32e9a46eccc55bd Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:15:31 +0000 Subject: [PATCH 05/11] compiler: fix AffineScript module system to unblock the per-module port The .res -> .affine port needs sibling compiler modules to import the shared AST (Location/Token/Position/...) from Types. AffineScript's module resolver exported imported enum constructors but dropped imported struct/alias type definitions, so cross-module struct field access failed ("Field not found"). Per the chosen approach (fix the resolver, not single-file/accessors): - patches/affinescript-module-struct-fields.patch: threads imported modules' type_env + constructor_env into the importing module's typecheck context (typecheck.ml check_program gains ?import_type_env/?import_constructor_env; resolve.ml import_type_defs copies them across all three import forms; bin/main.ml passes them at the check/compile/eval entry points). Documented in patches/README.adoc; pending upstream to hyperpolymath/affinescript. - compiler/src/Types.affine: now a proper `module Types;` with `pub` exports. - verification/check-affinescript.sh: checks from compiler/src so `use Types::{...}` resolves via the loader's current-dir search. - scripts/install-affinescript-toolchain.sh: applies the patch after cloning, before building (idempotent). Verified: a module importing Types' structs with nested field access, struct construction, and enum-field matching type-checks; affinescript's own stdlib cross-module imports (http_fetch/option/io) still pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0195yA45jSSP7YDPwJSpw4bM --- compiler/src/Types.affine | 50 +++--- patches/README.adoc | 44 ++++++ .../affinescript-module-struct-fields.patch | 142 ++++++++++++++++++ scripts/install-affinescript-toolchain.sh | 14 +- verification/check-affinescript.sh | 8 +- 5 files changed, 232 insertions(+), 26 deletions(-) create mode 100644 patches/README.adoc create mode 100644 patches/affinescript-module-struct-fields.patch diff --git a/compiler/src/Types.affine b/compiler/src/Types.affine index e9fd954..75f41c2 100644 --- a/compiler/src/Types.affine +++ b/compiler/src/Types.affine @@ -5,22 +5,28 @@ // * array -> [T]; option -> Option; dict -> Dict; tuples kept. // * Token variants `Float`/`String` renamed `FloatTok`/`StringTok` // (`Float`/`String` are reserved type keywords in AffineScript). +// +// This is the `Types` module; sibling compiler modules `use Types::{...}`. +// Cross-module struct field access requires the affinescript module-resolver +// fix in patches/affinescript-module-struct-fields.patch. + +module Types; use prelude::*; -struct Position { +pub struct Position { line: Int, column: Int, offset: Int } -struct Location { +pub struct Location { start: Position, end_: Position, file: String } -enum TokenType { +pub enum TokenType { // Keywords Main, End, Let, Mutable, Function, Struct, If, Elseif, Else, While, For, In, Break, Continue, Return, And, Or, Not, True, False, Nil, Gutter, Fn, @@ -41,7 +47,7 @@ enum TokenType { Newline, EOF, Error(String) } -struct Token { +pub struct Token { type_: TokenType, lexeme: String, loc: Location @@ -51,7 +57,7 @@ struct Token { // AST // ============================================ -enum Expr { +pub enum Expr { IntLit(Int, Location), FloatLit(Float, Location), StringLit(String, Location), @@ -68,22 +74,22 @@ enum Expr { Lambda([Param], Option, LambdaBody, Location) } -enum BinaryOp { +pub enum BinaryOp { Add, Sub, Mul, Div, Mod, Eq, Neq, Lt, Gt, Lte, Gte, BAnd, BOr, BXor, Shl, Shr, LAnd, LOr } -enum UnaryOp { Neg, LNot, BNot } +pub enum UnaryOp { Neg, LNot, BNot } -struct Param { +pub struct Param { name: String, type_: Option, loc: Location } -enum TypeExpr { +pub enum TypeExpr { TyInt, TyFloat, TyString, @@ -97,12 +103,12 @@ enum TypeExpr { TyIdent(String) } -enum LambdaBody { +pub enum LambdaBody { LambdaExpr(Expr), LambdaBlock([Stmt]) } -enum Stmt { +pub enum Stmt { // inline records -> positional: (mutable_, name, type_, value, loc) LetStmt(Bool, String, Option, Expr, Location), // (target, value, loc) @@ -124,7 +130,7 @@ enum Stmt { ExprStmt(Expr) } -enum Decl { +pub enum Decl { // (name, params, returnType, body, loc) FunctionDecl(String, [Param], Option, [Stmt], Location), // (name, fields, loc) @@ -134,7 +140,7 @@ enum Decl { StmtDecl(Stmt) } -struct Program { +pub struct Program { declarations: [Decl], loc: Location } @@ -143,11 +149,11 @@ struct Program { // Errors // ============================================ -enum ErrorCode { +pub enum ErrorCode { E0001, E0002, E0003, E0004, E0005, E0006, E0007, E0008, E0009, E0010 } -struct Diagnostic { +pub struct Diagnostic { code: ErrorCode, message: String, loc: Location, @@ -159,7 +165,7 @@ struct Diagnostic { // Runtime state & stability // ============================================ -enum StabilityFactor { +pub enum StabilityFactor { MutableState(Int, Int), // mutations, readers TypeInstability(Int), // reassignments NullPropagation(Int), // depth @@ -170,14 +176,14 @@ enum StabilityFactor { RaceCondition(Int) // conflicts } -struct StabilityReport { +pub struct StabilityReport { score: Int, factors: [StabilityFactor], breakdown: Dict, recommendations: [String] } -struct RuntimeState { +pub struct RuntimeState { runCounter: Int, stabilityScore: Int, lastError: Option, @@ -187,7 +193,7 @@ struct RuntimeState { historicalRuns: [Int] } -fn make_default_state() -> RuntimeState { +pub fn make_default_state() -> RuntimeState { #{ runCounter: 0, stabilityScore: 100, @@ -200,7 +206,7 @@ fn make_default_state() -> RuntimeState { } // Stability impact (non-positive), mirrors Types.res `stabilityImpact`. -fn stability_impact(factor: StabilityFactor) -> Int { +pub fn stability_impact(factor: StabilityFactor) -> Int { match factor { MutableState(mutations, readers) => -(10 * mutations + 5 * readers), TypeInstability(reassignments) => -(15 * reassignments), @@ -214,12 +220,12 @@ fn stability_impact(factor: StabilityFactor) -> Int { } // mirrors Types.res `calculateStability`: max(0, 100 + sum(impacts)) -fn calculate_stability(factors: [StabilityFactor]) -> Int { +pub fn calculate_stability(factors: [StabilityFactor]) -> Int { let penalties = fold(factors, 0, |acc, x| acc + stability_impact(x)); max(0, 100 + penalties) } -fn error_code_to_string(code: ErrorCode) -> String { +pub fn error_code_to_string(code: ErrorCode) -> String { match code { E0001 => "E0001", E0002 => "E0002", diff --git a/patches/README.adoc b/patches/README.adoc new file mode 100644 index 0000000..f5711c1 --- /dev/null +++ b/patches/README.adoc @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell += AffineScript toolchain patches +:toc: + +Patches applied to a fresh `hyperpolymath/affinescript` checkout by +`scripts/install-affinescript-toolchain.sh` before building the compiler, to +support the ReScript -> AffineScript migration. Each is a stop-gap pending +upstream; drop it from the install script once upstreamed. + +== affinescript-module-struct-fields.patch + +*Problem.* AffineScript's module resolver exported imported *enum* constructors +but dropped imported *struct/alias* type definitions: `lib/resolve.ml` registered +`TyEnum` variant constructors for imported modules but had a bare +`| TyAlias _ | TyStruct _ | TyExtern -> ()`. Field access on an imported struct +therefore failed with `Field 'x' not found in type T` — the named type stayed an +opaque `TCon` and never expanded to its `TRecord`. + +This blocked a per-module `.res -> .affine` port: the shared AST in +`compiler/src/Types.affine` (`Location`, `Token`, `Position`, ...) is +field-accessed by every other compiler module. + +*Fix.* Thread imported modules' `type_env` (struct/alias/enum type definitions) +and `constructor_env` into the importing module's type-check context, mirroring +the existing `name_types` (scheme) threading that already made enums cross: + +* `lib/typecheck.ml` — `check_program` gains `?import_type_env` / + `?import_constructor_env`, seeding `ctx.type_env` / `ctx.constructor_env`. +* `lib/resolve.ml` — a new `import_type_defs` copies a resolved module's + `type_env` / `constructor_env` into the destination context, called from all + three import forms (`use M;`, `use M::{...}`, `use M::*`); the imported-module + check site passes the new params. +* `bin/main.ml` — the `check` / `compile` / `eval` entry points pass the threaded + `type_env` / `constructor_env` to `check_program`. + +*Verification.* A module importing a struct and accessing its (nested) fields, +constructing it, and matching on an imported enum field now type-checks; existing +stdlib cross-module imports (`http_fetch`, `option`, `io`) still pass. + +*Upstream.* This belongs in `hyperpolymath/affinescript` — it fixes any multi-file +AffineScript program, not only error-lang. It could not be pushed from the +migration session (affinescript was outside the session's repo scope); upstream +when convenient, then delete this patch and its hook in the install script. diff --git a/patches/affinescript-module-struct-fields.patch b/patches/affinescript-module-struct-fields.patch new file mode 100644 index 0000000..54aae35 --- /dev/null +++ b/patches/affinescript-module-struct-fields.patch @@ -0,0 +1,142 @@ +diff --git a/bin/main.ml b/bin/main.ml +index 8230ab2..b772a3c 100644 +--- a/bin/main.ml ++++ b/bin/main.ml +@@ -195,6 +195,8 @@ let check_file face json path = + resolve_refs := List.rev resolve_ctx.references; + (match Affinescript.Typecheck.check_program + ~import_types:type_ctx.Affinescript.Typecheck.name_types ++ ~import_type_env:type_ctx.Affinescript.Typecheck.type_env ++ ~import_constructor_env:type_ctx.Affinescript.Typecheck.constructor_env + resolve_ctx.symbols prog with + | Error e -> + add (Affinescript.Json_output.of_type_error e) +@@ -242,6 +244,8 @@ let check_file face json path = + | Ok (resolve_ctx, type_ctx) -> + (match Affinescript.Typecheck.check_program + ~import_types:type_ctx.Affinescript.Typecheck.name_types ++ ~import_type_env:type_ctx.Affinescript.Typecheck.type_env ++ ~import_constructor_env:type_ctx.Affinescript.Typecheck.constructor_env + resolve_ctx.symbols prog with + | Error e -> + Format.eprintf "@[%s@]@." +@@ -505,6 +509,8 @@ let compile_file face json wasm_gc vscode_ext vscode_adapter vscode_no_lc + | Ok (resolve_ctx, import_type_ctx) -> + (match Affinescript.Typecheck.check_program + ~import_types:import_type_ctx.Affinescript.Typecheck.name_types ++ ~import_type_env:import_type_ctx.Affinescript.Typecheck.type_env ++ ~import_constructor_env:import_type_ctx.Affinescript.Typecheck.constructor_env + resolve_ctx.symbols prog with + | Error e -> + add (Affinescript.Json_output.of_type_error e) +@@ -732,6 +738,8 @@ let compile_file face json wasm_gc vscode_ext vscode_adapter vscode_no_lc + | Ok (resolve_ctx, import_type_ctx) -> + (match Affinescript.Typecheck.check_program + ~import_types:import_type_ctx.Affinescript.Typecheck.name_types ++ ~import_type_env:import_type_ctx.Affinescript.Typecheck.type_env ++ ~import_constructor_env:import_type_ctx.Affinescript.Typecheck.constructor_env + resolve_ctx.symbols prog with + | Error e -> + Format.eprintf "@[%s@]@." +diff --git a/lib/resolve.ml b/lib/resolve.ml +index 65a6b48..65a9206 100644 +--- a/lib/resolve.ml ++++ b/lib/resolve.ml +@@ -654,6 +654,20 @@ let import_specific_items + Error (UndefinedVariable item.ii_name, item.ii_name.span) + ) (Ok ()) items + ++(** Thread imported struct/alias/enum type definitions and value constructors ++ from a resolved source module into the destination type-check context, so ++ that field access on an imported struct resolves (companion to the scheme ++ imports above — a struct needs its TRecord definition, not just a ++ name_types scheme). *) ++let import_type_defs ++ (dest : Typecheck.context) (source : Typecheck.context) : unit = ++ Hashtbl.iter (fun name ty -> ++ Hashtbl.replace dest.Typecheck.type_env name ty ++ ) source.Typecheck.type_env; ++ Hashtbl.iter (fun name ty -> ++ Hashtbl.replace dest.Typecheck.constructor_env name ty ++ ) source.Typecheck.constructor_env ++ + (** Resolve imports in a program using module loader *) + let rec resolve_and_typecheck_module + (loader : Module_loader.t) +@@ -698,7 +712,10 @@ let rec resolve_and_typecheck_module + imported modules must check the same way top-level programs do.) *) + match + Typecheck.check_program +- ~import_types:type_ctx.Typecheck.name_types symbols prog ++ ~import_types:type_ctx.Typecheck.name_types ++ ~import_type_env:type_ctx.Typecheck.type_env ++ ~import_constructor_env:type_ctx.Typecheck.constructor_env ++ symbols prog + with + | Ok final_ctx -> Ok (symbols, final_ctx) + | Error type_err -> +@@ -729,6 +746,7 @@ and resolve_imports_with_loader + mod_type_ctx.Typecheck.var_types + mod_type_ctx.Typecheck.name_types + alias_str; ++ import_type_defs type_ctx mod_type_ctx; + Ok () + | Error e -> Error e + end +@@ -747,13 +765,15 @@ and resolve_imports_with_loader + (* Resolve and type-check the module *) + begin match resolve_and_typecheck_module loader loaded_mod with + | Ok (mod_symbols, mod_type_ctx) -> +- import_specific_items ctx.symbols ++ let* () = import_specific_items ctx.symbols + type_ctx.Typecheck.var_types + type_ctx.Typecheck.name_types + mod_symbols + mod_type_ctx.Typecheck.var_types + mod_type_ctx.Typecheck.name_types +- items ++ items in ++ import_type_defs type_ctx mod_type_ctx; ++ Ok () + | Error e -> Error e + end + | Error (Module_loader.ModuleNotFound _) -> +@@ -785,6 +805,7 @@ and resolve_imports_with_loader + sym) + | _ -> () + ) mod_symbols.all_symbols; ++ import_type_defs type_ctx mod_type_ctx; + Ok () + | Error e -> Error e + end +diff --git a/lib/typecheck.ml b/lib/typecheck.ml +index e38e302..4390c71 100644 +--- a/lib/typecheck.ml ++++ b/lib/typecheck.ml +@@ -2401,6 +2401,8 @@ let populate_call_effects (ctx : context) (prog : Ast.program) : unit = + Effect_sites.set_async_by_ord async_tbl + + let check_program ?(import_types : (string, scheme) Hashtbl.t option) ++ ?(import_type_env : (string, ty) Hashtbl.t option) ++ ?(import_constructor_env : (string, ty) Hashtbl.t option) + (symbols : Symbol.t) (prog : Ast.program) + : (context, type_error) Result.t = + try +@@ -2423,6 +2425,17 @@ let check_program ?(import_types : (string, scheme) Hashtbl.t option) + Option.iter (fun tbl -> + Hashtbl.iter (fun name sc -> Hashtbl.replace ctx.name_types name sc) tbl + ) import_types; ++ (* Thread imported struct/alias/enum type definitions (type_env) and value ++ constructors (constructor_env) so field access on an imported struct ++ resolves: enums already cross via name_types schemes, but a struct's ++ TRecord definition must be present for a named param type to expand to ++ its fields (otherwise it stays an opaque TCon -> FieldNotFound). *) ++ Option.iter (fun tbl -> ++ Hashtbl.iter (fun name ty -> Hashtbl.replace ctx.type_env name ty) tbl ++ ) import_type_env; ++ Option.iter (fun tbl -> ++ Hashtbl.iter (fun name ty -> Hashtbl.replace ctx.constructor_env name ty) tbl ++ ) import_constructor_env; + (* Forward pass: register all types, effects, traits, impls, and + function signatures so that mutually recursive declarations resolve. *) + let* () = List.fold_left (fun acc decl -> diff --git a/scripts/install-affinescript-toolchain.sh b/scripts/install-affinescript-toolchain.sh index 6c3a863..3b4e694 100755 --- a/scripts/install-affinescript-toolchain.sh +++ b/scripts/install-affinescript-toolchain.sh @@ -29,8 +29,20 @@ $SUDO apt-get install -y \ libfmt-ocaml-dev libcmdliner-ocaml-dev libyojson-ocaml-dev \ libppxlib-ocaml-dev libjs-of-ocaml-dev -# 2. Fetch + build the compiler binary. +# 2. Fetch the compiler. [ -d "$AFFINE_SRC/.git" ] || git clone --depth 1 "$AFFINE_REPO" "$AFFINE_SRC" + +# 2a. Apply the module-resolver fix (export imported struct field definitions so +# cross-module struct field access type-checks) until it is upstreamed to +# affinescript. See patches/README.adoc. Idempotent: skipped if already applied. +PATCH="$(cd "$(dirname "$0")/.." && pwd)/patches/affinescript-module-struct-fields.patch" +if [ -f "$PATCH" ] && git -C "$AFFINE_SRC" apply --check "$PATCH" 2>/dev/null; then + git -C "$AFFINE_SRC" apply "$PATCH" && echo "applied $PATCH" +else + echo "module-struct-fields patch: already applied or not applicable — continuing" +fi + +# 3. Build the compiler binary. ( cd "$AFFINE_SRC" && dune build bin/main.exe ) # 3. Install the binary + stdlib. The module loader discovers the stdlib at diff --git a/verification/check-affinescript.sh b/verification/check-affinescript.sh index 7644980..549602e 100755 --- a/verification/check-affinescript.sh +++ b/verification/check-affinescript.sh @@ -7,7 +7,9 @@ # Requires `affinescript` on PATH (see scripts/install-affinescript-toolchain.sh). set -euo pipefail -cd "$(dirname "$0")/.." +# Check from compiler/src so sibling-module imports (`use Types::{...}`) resolve +# via the loader's current-dir search. +cd "$(dirname "$0")/../compiler/src" if ! command -v affinescript >/dev/null 2>&1; then echo "affinescript not found — run scripts/install-affinescript-toolchain.sh" >&2 @@ -15,7 +17,7 @@ if ! command -v affinescript >/dev/null 2>&1; then fi shopt -s nullglob -sources=(compiler/src/*.affine) +sources=(*.affine) if [ ${#sources[@]} -eq 0 ]; then echo "no .affine sources yet (ReScript->AffineScript migration in progress)." exit 0 @@ -23,7 +25,7 @@ fi fail=0 for f in "${sources[@]}"; do - printf 'checking %-28s ... ' "$(basename "$f")" + printf 'checking %-28s ... ' "$f" if affinescript check "$f" >/tmp/as_check.out 2>&1; then echo ok else From 15480f5e19dd13e2ccd47d66fc81ef22bffba23a Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:41:33 +0000 Subject: [PATCH 06/11] compiler: port Lexer.res -> Lexer.affine Second compiler module. Lexer.affine imports Types (`use Types::*;`) and ports the tokenizer in AffineScript's functional style: with no mutable struct fields or record-spread, state-mutating helpers take a LexerState and return a new one, and the scanners + driver loop a `let mut` local. Single chars are Char (char_at / char_to_int); lexemes and the escape buffer use substring; numeric literals use the parse_int / parse_float builtins; the keyword Dict becomes a string-equality lookup. Verified green with `affinescript check`. Faithful for the core path (identifiers/keywords, decimal int + float + exponent, strings with escapes, all operators/delimiters, comments, newline + EOF, error recovery + diagnostics E0001-E0004). Documented parity gaps for a follow-up pass: hex/binary integer prefixes, triple-quoted strings + \0 escape, smart-quote E0007. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0195yA45jSSP7YDPwJSpw4bM --- compiler/src/Lexer.affine | 363 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 compiler/src/Lexer.affine diff --git a/compiler/src/Lexer.affine b/compiler/src/Lexer.affine new file mode 100644 index 0000000..78f3ea1 --- /dev/null +++ b/compiler/src/Lexer.affine @@ -0,0 +1,363 @@ +// SPDX-License-Identifier: MPL-2.0 +// Lexer.affine — tokenizer for Error-Lang (ported from compiler/src/Lexer.res). +// +// AffineScript has no mutable struct fields or record-spread, so the imperative +// ReScript lexer is rendered functionally: state-mutating helpers take a +// LexerState and return a new one (full-record reconstruction); the scanners and +// driver loop a `let mut` local. Single chars are Char (char_at / char_to_int); +// the escape buffer is built with single-char substrings. +// +// Known parity gaps vs Lexer.res (decimal/core path is faithful; these are +// tracked for a follow-up parity pass): +// - hex (0x) / binary (0b) integer prefixes +// - triple-quoted strings and the \0 null escape +// - smart-quote (U+201C / U+201D) detection (diagnostic E0007) + +module Lexer; + +use prelude::*; +use string::{char_at, length, substring, is_digit, is_alpha, is_alphanumeric}; +use Types::*; + +pub struct LexerState { + source: String, + file: String, + pos: Int, + line: Int, + column: Int, + tokens: [Token], + diagnostics: [Diagnostic], + runNumber: Int +} + +pub fn make(source: String, file: String, run_number: Int) -> LexerState { + #{ source: source, file: file, pos: 0, line: 1, column: 1, + tokens: [], diagnostics: [], runNumber: run_number } +} + +// ---- peeking (read copyable fields only; never consume the state) ---- + +fn peek_at(source: String, pos: Int, offset: Int) -> Option { + char_at(source, pos + offset) +} + +// code point of the char at pos+offset, or -1 at end of input +fn code_at(source: String, pos: Int, offset: Int) -> Int { + match char_at(source, pos + offset) { + Some(c) => char_to_int(c), + None => -1 + } +} + +fn mk_pos(line: Int, column: Int, offset: Int) -> Position { + #{ line: line, column: column, offset: offset } +} + +fn is_hex_digit(c: Char) -> Bool { + let k = char_to_int(c); + (k >= 48 && k <= 57) || (k >= 97 && k <= 102) || (k >= 65 && k <= 70) +} + +// ---- keyword table (ReScript Dict -> match on string equality) ---- + +fn keyword_lookup(w: String) -> Option { + if w == "main" { Some(Main) } + else if w == "end" { Some(End) } + else if w == "let" { Some(Let) } + else if w == "mutable" { Some(Mutable) } + else if w == "function" { Some(Function) } + else if w == "struct" { Some(Struct) } + else if w == "if" { Some(If) } + else if w == "elseif" { Some(Elseif) } + else if w == "else" { Some(Else) } + else if w == "while" { Some(While) } + else if w == "for" { Some(For) } + else if w == "in" { Some(In) } + else if w == "break" { Some(Break) } + else if w == "continue" { Some(Continue) } + else if w == "return" { Some(Return) } + else if w == "and" { Some(And) } + else if w == "or" { Some(Or) } + else if w == "not" { Some(Not) } + else if w == "true" { Some(True) } + else if w == "false" { Some(False) } + else if w == "nil" { Some(Nil) } + else if w == "gutter" { Some(Gutter) } + else if w == "fn" { Some(Fn) } + else if w == "Int" { Some(TInt) } + else if w == "Float" { Some(TFloat) } + else if w == "String" { Some(TString) } + else if w == "Bool" { Some(TBool) } + else if w == "Array" { Some(TArray) } + else if w == "Echo" { Some(TEcho) } + else if w == "EchoR" { Some(TEchoR) } + else if w == "print" { Some(Identifier("print")) } + else if w == "println" { Some(Identifier("println")) } + else { None } +} + +// ---- state transitions (consume + rebuild) ---- + +fn advance(s: LexerState) -> LexerState { + let is_nl = code_at(s.source, s.pos, 0) == 10; + let new_line = if is_nl { s.line + 1 } else { s.line }; + let new_col = if is_nl { 1 } else { s.column + 1 }; + #{ source: s.source, file: s.file, pos: s.pos + 1, + line: new_line, column: new_col, + tokens: s.tokens, diagnostics: s.diagnostics, runNumber: s.runNumber } +} + +fn add_token(s: LexerState, type_: TokenType, lexeme: String, loc: Location) -> LexerState { + let tok = #{ type_: type_, lexeme: lexeme, loc: loc }; + #{ source: s.source, file: s.file, pos: s.pos, line: s.line, column: s.column, + tokens: s.tokens ++ [tok], diagnostics: s.diagnostics, runNumber: s.runNumber } +} + +fn add_diagnostic(s: LexerState, code: ErrorCode, message: String, loc: Location) -> LexerState { + let diag = #{ code: code, message: message, loc: loc, + runNumber: s.runNumber, hint: None }; + #{ source: s.source, file: s.file, pos: s.pos, line: s.line, column: s.column, + tokens: s.tokens, diagnostics: s.diagnostics ++ [diag], runNumber: s.runNumber } +} + +// Advance once, emitting a token whose location spans the single consumed char. +fn single_tok(s: LexerState, t: TokenType, lex: String) -> LexerState { + let start = mk_pos(s.line, s.column, s.pos); + let s2 = advance(s); + let loc = #{ start: start, end_: mk_pos(s2.line, s2.column, s2.pos), file: s2.file }; + add_token(s2, t, lex, loc) +} + +// ---- scanners ---- + +fn scan_comment(s: LexerState) -> LexerState { + let mut st = s; + while (code_at(st.source, st.pos, 0) != 10) && (st.pos < length(st.source)) { + st = advance(st); + } + st +} + +fn scan_identifier(s: LexerState) -> LexerState { + let start = mk_pos(s.line, s.column, s.pos); + let start_pos = s.pos; + let mut st = s; + while (match char_at(st.source, st.pos) { Some(c) => is_alphanumeric(c), None => false }) { + st = advance(st); + } + let lexeme = substring(st.source, start_pos, st.pos); + let loc = #{ start: start, end_: mk_pos(st.line, st.column, st.pos), file: st.file }; + let type_ = match keyword_lookup(lexeme) { + Some(kw) => kw, + None => Identifier(lexeme) + }; + add_token(st, type_, lexeme, loc) +} + +fn scan_number(s: LexerState) -> LexerState { + let start = mk_pos(s.line, s.column, s.pos); + let start_pos = s.pos; + let mut st = s; + + // integer part + while (match char_at(st.source, st.pos) { Some(c) => is_digit(c), None => false }) { + st = advance(st); + } + + // optional fractional part: '.' followed by a digit + let mut is_float = false; + if (code_at(st.source, st.pos, 0) == 46) && (match char_at(st.source, st.pos + 1) { Some(c) => is_digit(c), None => false }) { + is_float = true; + st = advance(st); // '.' + while (match char_at(st.source, st.pos) { Some(c) => is_digit(c), None => false }) { + st = advance(st); + } + } + + // optional exponent + let ec = code_at(st.source, st.pos, 0); + if ec == 101 || ec == 69 { // e / E + is_float = true; + st = advance(st); + let sgn = code_at(st.source, st.pos, 0); + if sgn == 43 || sgn == 45 { st = advance(st); } // + / - + while (match char_at(st.source, st.pos) { Some(c) => is_digit(c), None => false }) { + st = advance(st); + } + } + + let lexeme = substring(st.source, start_pos, st.pos); + let loc = #{ start: start, end_: mk_pos(st.line, st.column, st.pos), file: st.file }; + if is_float { + match parse_float(lexeme) { + Some(f) => add_token(st, FloatTok(f), lexeme, loc), + None => add_token(st, Error("Invalid float"), lexeme, loc) + } + } else { + match parse_int(lexeme) { + Some(n) => add_token(st, Integer(n), lexeme, loc), + None => add_token(st, Error("Invalid integer"), lexeme, loc) + } + } +} + +// String literal (single-quote-aware double-quoted; escapes; no triple-quote — +// simplified from the .res triple-quote handling, noted for the parity pass). +fn scan_string(s: LexerState) -> LexerState { + let start = mk_pos(s.line, s.column, s.pos); + let mut st = advance(s); // opening quote + let mut buf = ""; + let mut done = false; + let mut result = st; + + while !done { + let c = code_at(st.source, st.pos, 0); + if c == -1 { + let loc = #{ start: start, end_: mk_pos(st.line, st.column, st.pos), file: st.file }; + let st2 = add_diagnostic(st, E0002, "Unterminated string literal", loc); + result = add_token(st2, Error("Unterminated string"), buf, loc); + done = true; + } else if c == 34 { // closing " + st = advance(st); + let loc = #{ start: start, end_: mk_pos(st.line, st.column, st.pos), file: st.file }; + let quoted = "\"" ++ buf ++ "\""; + result = add_token(st, StringTok(buf), quoted, loc); + done = true; + } else if c == 92 { // backslash escape + st = advance(st); + let e = code_at(st.source, st.pos, 0); + if e == 110 { buf = buf ++ "\n"; st = advance(st); } + else if e == 114 { buf = buf ++ "\r"; st = advance(st); } + else if e == 116 { buf = buf ++ "\t"; st = advance(st); } + else if e == 92 { buf = buf ++ "\\"; st = advance(st); } + else if e == 34 { buf = buf ++ "\""; st = advance(st); } + else if e == -1 { done = true; result = st; } + else { + let loc = #{ start: start, end_: mk_pos(st.line, st.column, st.pos), file: st.file }; + st = add_diagnostic(st, E0003, "Invalid escape sequence", loc); + st = advance(st); + } + } else if c == 10 { // newline in string + let loc = #{ start: start, end_: mk_pos(st.line, st.column, st.pos), file: st.file }; + let st2 = add_diagnostic(st, E0002, "Unterminated string literal (newline in string)", loc); + result = add_token(st2, Error("Unterminated string"), buf, loc); + done = true; + } else { + buf = buf ++ substring(st.source, st.pos, st.pos + 1); + st = advance(st); + } + } + result +} + +// ---- main dispatch + driver ---- + +fn step(s: LexerState) -> LexerState { + let c = code_at(s.source, s.pos, 0); + if c == 32 || c == 9 || c == 13 { + advance(s) + } else if c == 10 { // newline token + let start = mk_pos(s.line, s.column, s.pos); + let s2 = advance(s); + let loc = #{ start: start, end_: mk_pos(s2.line, s2.column, s2.pos), file: s2.file }; + add_token(s2, Newline, "\\n", loc) + } else if c == 35 { // # + scan_comment(s) + } else if c == 43 { single_tok(s, Plus, "+") } + else if c == 45 { // - or -> + if code_at(s.source, s.pos, 1) == 62 { + let start = mk_pos(s.line, s.column, s.pos); + let s2 = advance(advance(s)); + let loc = #{ start: start, end_: mk_pos(s2.line, s2.column, s2.pos), file: s2.file }; + add_token(s2, Arrow, "->", loc) + } else { single_tok(s, Minus, "-") } + } + else if c == 42 { single_tok(s, Star, "*") } + else if c == 47 { single_tok(s, Slash, "/") } + else if c == 37 { single_tok(s, Percent, "%") } + else if c == 61 { // = or == + if code_at(s.source, s.pos, 1) == 61 { + let start = mk_pos(s.line, s.column, s.pos); + let s2 = advance(advance(s)); + let loc = #{ start: start, end_: mk_pos(s2.line, s2.column, s2.pos), file: s2.file }; + add_token(s2, EqualEqual, "==", loc) + } else { single_tok(s, Equal, "=") } + } + else if c == 33 { // ! -> != or error + if code_at(s.source, s.pos, 1) == 61 { + let start = mk_pos(s.line, s.column, s.pos); + let s2 = advance(advance(s)); + let loc = #{ start: start, end_: mk_pos(s2.line, s2.column, s2.pos), file: s2.file }; + add_token(s2, BangEqual, "!=", loc) + } else { + let start = mk_pos(s.line, s.column, s.pos); + let s2 = advance(s); + let loc = #{ start: start, end_: mk_pos(s2.line, s2.column, s2.pos), file: s2.file }; + add_diagnostic(s2, E0001, "Unexpected character '!'", loc) + } + } + else if c == 60 { // < <= << + let n = code_at(s.source, s.pos, 1); + if n == 61 { two_char(s, LessEqual, "<=") } + else if n == 60 { two_char(s, LessLess, "<<") } + else { single_tok(s, Less, "<") } + } + else if c == 62 { // > >= >> + let n = code_at(s.source, s.pos, 1); + if n == 61 { two_char(s, GreaterEqual, ">=") } + else if n == 62 { two_char(s, GreaterGreater, ">>") } + else { single_tok(s, Greater, ">") } + } + else if c == 38 { single_tok(s, Ampersand, "&") } + else if c == 124 { single_tok(s, Pipe, "|") } + else if c == 94 { single_tok(s, Caret, "^") } + else if c == 126 { single_tok(s, Tilde, "~") } + else if c == 63 { single_tok(s, Question, "?") } + else if c == 58 { single_tok(s, Colon, ":") } + else if c == 40 { single_tok(s, LParen, "(") } + else if c == 41 { single_tok(s, RParen, ")") } + else if c == 91 { single_tok(s, LBracket, "[") } + else if c == 93 { single_tok(s, RBracket, "]") } + else if c == 123 { single_tok(s, LBrace, "{") } + else if c == 125 { single_tok(s, RBrace, "}") } + else if c == 44 { single_tok(s, Comma, ",") } + else if c == 46 { single_tok(s, Dot, ".") } + else if c == 34 { scan_string(s) } // " + else { + match char_at(s.source, s.pos) { + Some(ch) => + if is_digit(ch) { scan_number(s) } + else if is_alpha(ch) { scan_identifier(s) } + else { + let start = mk_pos(s.line, s.column, s.pos); + let s2 = advance(s); + let loc = #{ start: start, end_: mk_pos(s2.line, s2.column, s2.pos), file: s2.file }; + let lex = substring(s2.source, start.offset, s2.pos); + add_token(add_diagnostic(s2, E0004, "Illegal character", loc), Error("Illegal"), lex, loc) + }, + None => s + } + } +} + +fn two_char(s: LexerState, t: TokenType, lex: String) -> LexerState { + let start = mk_pos(s.line, s.column, s.pos); + let s2 = advance(advance(s)); + let loc = #{ start: start, end_: mk_pos(s2.line, s2.column, s2.pos), file: s2.file }; + add_token(s2, t, lex, loc) +} + +pub fn tokenize(s0: LexerState) -> LexerState { + let mut s = s0; + while s.pos < length(s.source) { + s = step(s); + } + let eof = mk_pos(s.line, s.column, s.pos); + let loc = #{ start: eof, end_: mk_pos(s.line, s.column, s.pos), file: s.file }; + add_token(s, EOF, "", loc) +} + +pub fn lex(source: String, file: String, run_number: Int) -> ([Token], [Diagnostic]) { + let s = tokenize(make(source, file, run_number)); + (s.tokens, s.diagnostics) +} From 5adb0fb9345e9a36cf9133566a347e692532a4c9 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 24 Jun 2026 06:48:48 +0000 Subject: [PATCH 07/11] compiler: port Cst.res -> Cst.affine (concrete syntax tree + trivia) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third module. Trivia-preserving CST with source round-trip. Functional port: classify_trivia loops a `let mut` local; lex_with_trivia folds the raw tokens carrying the last token in hand, so newline-trailing-trivia and trailing source attach without array-index mutation. Selective `Types` import (Cst's node kinds collide with Types' Decl/Stmt constructors). Verified with `affinescript check`. Omitted, documented in-file: - node_at: returning a deepest *subtree* needs a node used both to recurse into AND to return — not expressible under affine ownership without a shared/clone type. Auxiliary (IDE cursor lookup); deferred. - run_tests: the Console-based test harness is not compiler code. Encountered an affinescript resolver bug along the way: a local `let len = ...` shadows the builtin `len` *globally* (so a later `len(xs)` typed as Int). Worked around by renaming the local; worth an upstream fix in affinescript. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0195yA45jSSP7YDPwJSpw4bM --- compiler/src/Cst.affine | 228 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 compiler/src/Cst.affine diff --git a/compiler/src/Cst.affine b/compiler/src/Cst.affine new file mode 100644 index 0000000..6f37a7c --- /dev/null +++ b/compiler/src/Cst.affine @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: MPL-2.0 +// Cst.affine — Concrete Syntax Tree for Error-Lang (ported from compiler/src/Cst.res). +// +// A CST preserves all source text (whitespace, comments, exact tokens) so source +// round-trips. Ported to AffineScript's functional style (no mut struct fields / +// record spread): stateful classify_trivia loops a `let mut` local; lex_with_trivia +// folds raw tokens carrying the last token in hand (so newline-trailing-trivia and +// trailing source are attached without array-index mutation). +// +// Selective `Types` import: Cst's node kinds (MainBlock/FunctionDecl/LetStmt/...) +// would collide with Types' Decl/Stmt constructors under a glob import, so only the +// token/location types (+ the Newline constructor for matching) are imported. +// +// Omitted vs Cst.res (documented): +// - node_at: returning a deepest *subtree* needs a node used both to recurse into +// and to return — not expressible under affine ownership without a shared/clone +// type. Auxiliary (IDE cursor lookup); deferred. +// - run_tests: the test harness (Console-based) is not part of the compiler. + +module Cst; + +use prelude::*; +use string::{length, substring, join, char_at}; +use collections::{flat_map}; +use Types::{TokenType, Position, Location, Token, Newline}; +use Lexer::{lex}; + +// ---- trivia ---- + +enum TriviaKind { TkWhitespace, TkLineComment, TkNewline } + +struct Trivia { + kind: TriviaKind, + text: String, + loc: Location +} + +struct CstToken { + tokenKind: TokenType, + text: String, + leadingTrivia: [Trivia], + trailingTrivia: [Trivia], + loc: Location +} + +enum CstNodeKind { + SourceFile, MainBlock, FunctionDecl, StructDecl, LetStmt, IfStmt, WhileStmt, + ForStmt, ReturnStmt, BreakStmt, ContinueStmt, PrintStmt, GutterBlock, ExprStmt, + BinaryExpr, UnaryExpr, CallExpr, IndexExpr, MemberExpr, TernaryExpr, LambdaExpr, + ArrayLitExpr, ParamList, ArgList, ErrorNode +} + +enum CstNode { + NodeToken(CstToken), + NodeTree(CstTree) +} + +struct CstTree { + kind: CstNodeKind, + children: [CstNode], + loc: Location +} + +// ---- location helpers ---- + +fn mk_pos(line: Int, column: Int, offset: Int) -> Position { + #{ line: line, column: column, offset: offset } +} + +fn mk_loc(start_off: Int, end_off: Int, file: String) -> Location { + #{ start: mk_pos(0, 0, start_off), end_: mk_pos(0, 0, end_off), file: file } +} + +fn code_at_str(s: String, i: Int) -> Int { + match char_at(s, i) { + Some(c) => char_to_int(c), + None => -1 + } +} + +fn is_ws_code(c: Int) -> Bool { c == 32 || c == 9 || c == 13 } + +// ---- source reconstruction (round-trip) ---- + +fn trivia_text(t: Trivia) -> String { t.text } + +pub fn to_source(node: CstNode) -> String { + match node { + NodeToken(tok) => { + let leading = join(map(tok.leadingTrivia, trivia_text), ""); + let trailing = join(map(tok.trailingTrivia, trivia_text), ""); + leading ++ tok.text ++ trailing + }, + NodeTree(tree) => tree_to_source(tree) + } +} + +pub fn tree_to_source(tree: CstTree) -> String { + join(map(tree.children, to_source), "") +} + +// ---- token collection (document order) ---- + +fn node_tokens(node: CstNode) -> [CstToken] { + match node { + NodeToken(tok) => [tok], + NodeTree(subtree) => tokens(subtree) + } +} + +pub fn tokens(tree: CstTree) -> [CstToken] { + flat_map(node_tokens, tree.children) +} + +// ---- trivia classification (gap of source -> trivia items) ---- + +pub fn classify_trivia(gap: String, file: String, base_offset: Int) -> [Trivia] { + let mut result = []; + let mut i = 0; + let glen = length(gap); + while i < glen { + let c = code_at_str(gap, i); + if c == 35 { // '#': line comment to end of line + let start = i; + let mut j = i; + while (j < glen) && (code_at_str(gap, j) != 10) { j = j + 1; } + let loc = mk_loc(base_offset + start, base_offset + j, file); + result = result ++ [#{ kind: TkLineComment, text: substring(gap, start, j), loc: loc }]; + i = j; + } else if c == 10 { // newline + let loc = mk_loc(base_offset + i, base_offset + i + 1, file); + result = result ++ [#{ kind: TkNewline, text: "\n", loc: loc }]; + i = i + 1; + } else if is_ws_code(c) { // contiguous whitespace + let start = i; + let mut j = i; + while (j < glen) && is_ws_code(code_at_str(gap, j)) { j = j + 1; } + let loc = mk_loc(base_offset + start, base_offset + j, file); + result = result ++ [#{ kind: TkWhitespace, text: substring(gap, start, j), loc: loc }]; + i = j; + } else { // anything else (shouldn't occur in gaps) + let loc = mk_loc(base_offset + i, base_offset + i + 1, file); + result = result ++ [#{ kind: TkWhitespace, text: substring(gap, i, i + 1), loc: loc }]; + i = i + 1; + } + } + result +} + +// ---- trivia-aware lexing ---- + +struct LexAcc { + committed: [CstToken], + last: Option, + prev_end: Int +} + +fn set_trailing(t: CstToken, trailing: [Trivia]) -> CstToken { + #{ tokenKind: t.tokenKind, text: t.text, leadingTrivia: t.leadingTrivia, + trailingTrivia: trailing, loc: t.loc } +} + +pub fn lex_with_trivia(source: String, file: String, run_number: Int) -> [CstToken] { + let (raw_tokens, _diags) = lex(source, file, run_number); + + let final = fold(raw_tokens, #{ committed: [], last: None, prev_end: 0 }, |acc, tok| { + let tok_start = tok.loc.start.offset; + let tok_end = tok.loc.end_.offset; + let leading = if tok_start > acc.prev_end { + classify_trivia(substring(source, acc.prev_end, tok_start), file, acc.prev_end) + } else { + [] + }; + match tok.type_ { + Newline => { + let nl = #{ kind: TkNewline, text: tok.lexeme, loc: tok.loc }; + match acc.last { + None => { + let synth = #{ tokenKind: tok.type_, text: tok.lexeme, leadingTrivia: leading, + trailingTrivia: [], loc: tok.loc }; + #{ committed: acc.committed, last: Some(synth), prev_end: tok_end } + }, + Some(lt) => { + let updated = set_trailing(lt, lt.trailingTrivia ++ leading ++ [nl]); + #{ committed: acc.committed, last: Some(updated), prev_end: tok_end } + } + } + }, + _ => { + let new_committed = match acc.last { + Some(lt) => acc.committed ++ [lt], + None => acc.committed + }; + let new_tok = #{ tokenKind: tok.type_, text: tok.lexeme, leadingTrivia: leading, + trailingTrivia: [], loc: tok.loc }; + #{ committed: new_committed, last: Some(new_tok), prev_end: tok_end } + } + } + }); + + // attach trailing source after the last token, then commit the last token + let total_len = length(source); + match final.last { + None => final.committed, + Some(lt) => { + let lt2 = if final.prev_end < total_len { + set_trailing(lt, lt.trailingTrivia ++ classify_trivia(substring(source, final.prev_end, total_len), file, final.prev_end)) + } else { + lt + }; + final.committed ++ [lt2] + } + } +} + +// ---- public API ---- + +pub fn parse_to_cst(source: String, file: String, run_number: Int) -> CstTree { + let cst_tokens = lex_with_trivia(source, file, run_number); + let children = map(cst_tokens, |tok| NodeToken(tok)); + let n = len(cst_tokens); + let loc = if n == 0 { + mk_loc(0, 0, file) + } else { + #{ start: cst_tokens[0].loc.start, end_: cst_tokens[n - 1].loc.end_, file: file } + }; + #{ kind: SourceFile, children: children, loc: loc } +} From 0f89d0d13dd8fe3c150b4cb5525dca7639ad5a6d Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 24 Jun 2026 07:11:12 +0000 Subject: [PATCH 08/11] compiler: port Parser.res -> Parser.affine (recursive-descent parser) Fourth and largest module (1150 lines). Complete error-tolerant recursive-descent parser, faithful grammar and precedence. Functional state-threading: ParserState is immutable; producers return (Option, ParserState) and callers thread via `let (x, st) = f(st)`; ReScript `ref` accumulators became `let mut` locals with (acc, state) while-loops. Full coverage: the expression ladder (ternary -> logical-or/and -> equality -> comparison -> term -> factor -> unary -> postfix -> primary), array/lambda/grouped primaries, call/index/member postfix chains; type expressions incl. Echo / EchoR and the >>-split close-angle; the full statement set incl. gutter-block error recovery; function/struct/main declarations; and the `parse` driver returning (Program, [Diagnostic]). Verified green with `affinescript check` and the full harness (Types/Lexer/Cst/Parser all ok). Documented faithful deviations: the >>-split token rewrite reproduced via array rebuild (tokens are immutable); the pre-existing node-`start` quirk reproduced exactly rather than corrected; minor error-recovery loop hardening to guarantee progress on a stray token. AffineScript checker bug found + worked around: an if-statement immediately followed by a tuple literal in tail position fails with E0104 "Expected a function, got Unit" (the if's () is applied to the tuple). Used explicit `return (...)`. Third affinescript bug this migration (after the module-resolver struct-field gap and the global `len` builtin-shadow); all worth upstreaming. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0195yA45jSSP7YDPwJSpw4bM --- compiler/src/Parser.affine | 1150 ++++++++++++++++++++++++++++++++++++ 1 file changed, 1150 insertions(+) create mode 100644 compiler/src/Parser.affine diff --git a/compiler/src/Parser.affine b/compiler/src/Parser.affine new file mode 100644 index 0000000..bde513e --- /dev/null +++ b/compiler/src/Parser.affine @@ -0,0 +1,1150 @@ +// SPDX-License-Identifier: MPL-2.0 +// Parser.affine — error-tolerant recursive-descent parser for Error-Lang +// (ported from compiler/src/Parser.res). +// +// AffineScript has no mutable struct fields and no record-spread, so the +// imperative ReScript parser (which mutates `state.pos`/`state.diagnostics` +// in place and uses `ref` cells for `left`/`args`/...) is rendered +// functionally: +// * ParserState is an immutable struct; every consuming helper TAKES a +// ParserState and RETURNS a new one (full-record reconstruction). +// * Token/expression producers return a tuple `(Option, ParserState)` +// — the result paired with the threaded state — and callers destructure +// `let (x, st) = f(st);`. +// * ReScript `ref` accumulators become `let mut` locals; loops that both +// accumulate and advance the cursor carry a `(acc, state)` (or +// `(left_opt, state)`) tuple through a `while`. +// +// Token/AST constructors come from the `Types` module. Note the two +// reserved-keyword renames carried over from Types.affine: the literal token +// variants are `Integer`/`FloatTok`/`StringTok` (not `Float`/`String`), and +// inline-record AST variants are positional (e.g. `LetStmt(mutable_, name, +// type_, value, loc)`). +// +// ── Faithfulness notes: documented gaps / simplifications vs Parser.res ── +// * expectCloseAngle's in-place token rewrite: when the lexer fuses a +// closing `>>` (GreaterGreater) that must close two nested type-arg +// lists (e.g. `Echo>`), Parser.res MUTATES tokens[pos] to +// leave a `Greater` behind. With an immutable token array we reproduce +// this faithfully by REBUILDING the array (`set_token_at`) with the +// split `Greater` token at `pos`; behaviour is identical, cost is O(n) +// per split (type-arg lists are short, so this is negligible). +// * The `start` position of binary/call/index/member nodes: Parser.res +// computes it with `switch l { | IntLit(_, loc) => loc.start | _ => +// currentLoc(state).start }` — i.e. it only recovers a precise start +// when the left operand is an integer literal (the Call site also peeks +// `Ident`), otherwise it falls back to the *current* token. This is a +// pre-existing quirk of Parser.res; it is reproduced EXACTLY here (see +// `start_or_current` / `start_or_current_call`) rather than "fixed", to +// keep node construction byte-for-byte faithful. +// * No omitted grammar: every production in Parser.res (ternary → +// logical-or → … → unary → postfix → primary, the full statement set +// incl. gutter-block error recovery, type-expression parsing, function/ +// struct/main declarations, and the top-level `parse` driver) is ported. +// * `runTests`/Console code: none exists in Parser.res, so nothing to omit. + +module Parser; + +use prelude::*; +use Types::*; + +// ============================================ +// Parser state +// ============================================ + +pub struct ParserState { + tokens: [Token], + pos: Int, + diagnostics: [Diagnostic], + runNumber: Int, + file: String +} + +pub fn make(tokens: [Token], file: String, run_number: Int) -> ParserState { + #{ tokens: tokens, pos: 0, diagnostics: [], runNumber: run_number, file: file } +} + +// Rebuild state advancing the cursor by one (bounded by token count). +fn with_pos(s: ParserState, new_pos: Int) -> ParserState { + #{ tokens: s.tokens, pos: new_pos, diagnostics: s.diagnostics, + runNumber: s.runNumber, file: s.file } +} + +// Replace tokens[idx] with `v`, returning a new state (used by the +// GreaterGreater split — see header note). +fn set_token_at(s: ParserState, idx: Int, v: Token) -> ParserState { + let mut out = []; + let mut i = 0; + let n = len(s.tokens); + while i < n { + if i == idx { out = out ++ [v]; } else { out = out ++ [s.tokens[i]]; } + i = i + 1; + } + #{ tokens: out, pos: s.pos, diagnostics: s.diagnostics, + runNumber: s.runNumber, file: s.file } +} + +// ============================================ +// Cursor primitives (read-only: never rebuild state) +// ============================================ + +fn peek(s: ParserState) -> Option { + if s.pos < len(s.tokens) { Some(s.tokens[s.pos]) } else { None } +} + +fn peek_ahead(s: ParserState, offset: Int) -> Option { + let idx = s.pos + offset; + if idx < len(s.tokens) { Some(s.tokens[idx]) } else { None } +} + +// The token just consumed (Parser.res reads `state.tokens[state.pos - 1]`). +fn prev_token(s: ParserState) -> Option { + let idx = s.pos - 1; + if idx >= 0 && idx < len(s.tokens) { Some(s.tokens[idx]) } else { None } +} + +// Advance one token (returns just the new state; the consumed token, when +// needed, is read via prev_token afterwards — mirrors ReScript `advance`). +fn advance(s: ParserState) -> ParserState { + if s.pos < len(s.tokens) { with_pos(s, s.pos + 1) } else { s } +} + +fn is_at_end(s: ParserState) -> Bool { + match peek(s) { + Some(tok) => match tok.type_ { EOF => true, _ => false }, + None => true + } +} + +fn check(s: ParserState, t: TokenType) -> Bool { + match peek(s) { + Some(tok) => tok.type_ == t, + None => false + } +} + +// Consume if the current token is any of `ts` (ReScript `match_`/Array.some). +fn match_any(s: ParserState, ts: [TokenType]) -> (Bool, ParserState) { + let mut st = s; + let mut matched = false; + let mut i = 0; + let n = len(ts); + while i < n && !matched { + if check(st, ts[i]) { + st = advance(st); + matched = true; + } + i = i + 1; + } + (matched, st) +} + +fn skip_newlines(s: ParserState) -> ParserState { + let mut st = s; + while check(st, Newline) { + st = advance(st); + } + st +} + +fn add_diagnostic(s: ParserState, code: ErrorCode, message: String, loc: Location) -> ParserState { + let diag = #{ code: code, message: message, loc: loc, + runNumber: s.runNumber, hint: None }; + #{ tokens: s.tokens, pos: s.pos, + diagnostics: s.diagnostics ++ [diag], + runNumber: s.runNumber, file: s.file } +} + +fn zero_pos() -> Position { #{ line: 0, column: 0, offset: 0 } } + +fn zero_loc(file: String) -> Location { + #{ start: zero_pos(), end_: zero_pos(), file: file } +} + +fn current_loc(s: ParserState) -> Location { + match peek(s) { + Some(tok) => tok.loc, + None => zero_loc(s.file) + } +} + +// Expect a token type; on success consume and return Ok(token); on failure +// emit E0001 and return Err. (ReScript returns `result` but +// every caller `->ignore`s it, so callers here just take the state.) +fn expect(s: ParserState, t: TokenType, message: String) -> (Result, ParserState) { + match peek(s) { + Some(tok) => + if tok.type_ == t { + (Ok(tok), advance(s)) + } else { + (Err(()), add_diagnostic(s, E0001, message, tok.loc)) + }, + None => (Err(()), add_diagnostic(s, E0001, message, zero_loc(s.file))) + } +} + +// Discard the Result, keep the state (the common `expect(...)->ignore` use). +fn expect_(s: ParserState, t: TokenType, message: String) -> ParserState { + let (_r, st) = expect(s, t, message); + st +} + +// ============================================ +// Location helpers (mirror Parser.res start-position quirk; see header) +// ============================================ + +fn mk_loc(start: Position, end_: Position, file: String) -> Location { + #{ start: start, end_: end_, file: file } +} + +// `switch l { | IntLit(_, loc) => loc.start | _ => current.start }` +fn start_or_current(l: Expr, s: ParserState) -> Position { + match l { + IntLit(_n, loc) => loc.start, + _ => current_loc(s).start + } +} + +// Call site additionally peeks `Ident`: +// `| IntLit(_, loc) => loc.start | Ident(_, loc) => loc.start | _ => ...` +fn start_or_current_call(l: Expr, s: ParserState) -> Position { + match l { + IntLit(_n, loc) => loc.start, + Ident(_nm, loc) => loc.start, + _ => current_loc(s).start + } +} + +// ============================================ +// Expression parsing +// parseExpression → parseTernary → parseLogicalOr → parseLogicalAnd +// → parseEquality → parseComparison → parseTerm → parseFactor +// → parseUnary → parsePostfix → parsePrimary +// Each returns (Option, ParserState). +// ============================================ + +fn parse_expression(s: ParserState) -> (Option, ParserState) { + parse_ternary(s) +} + +fn parse_ternary(s: ParserState) -> (Option, ParserState) { + let (left, s1) = parse_logical_or(s); + match left { + Some(cond) => + if check(s1, Question) { + let start_loc = current_loc(s1); + let s2 = advance(s1); // ? + let (then_opt, s3) = parse_expression(s2); + match then_opt { + Some(then_) => { + let (got_colon, s4) = match_any(s3, [Colon]); + if got_colon { + let (else_opt, s5) = parse_expression(s4); + match else_opt { + Some(else_) => { + let loc = mk_loc(start_loc.start, current_loc(s5).end_, s5.file); + (Some(Ternary(cond, then_, else_, loc)), s5) + }, + None => (Some(cond), s5) + } + } else { + (Some(cond), s4) + } + }, + None => (Some(cond), s3) + } + } else { + (Some(cond), s1) + }, + None => (left, s1) + } +} + +// Shared left-associative binary loop: given an already-parsed `left`, while +// the current token is one of `ops`, consume it, parse the rhs with `next`, +// and fold into a Binary node whose op is `op_of(prevTokenType)`. +fn binary_loop( + left0: Option, + s0: ParserState, + ops: [TokenType], + op_of: TokenType -> BinaryOp, + next: ParserState -> (Option, ParserState) +) -> (Option, ParserState) { + let mut left = left0; + let mut st = s0; + let mut go = true; + while go { + let has_left = match left { Some(_e) => true, None => false }; + if !has_left { go = false; } + else { + let (matched, s_after) = match_any(st, ops); + if !matched { + st = s_after; + go = false; + } else { + let op = match prev_token(s_after) { + Some(t) => op_of(t.type_), + None => op_of(EOF) + }; + let (right, s_rhs) = next(s_after); + match (left, right) { + (Some(l), Some(r)) => { + let loc = mk_loc(start_or_current(l, s_rhs), current_loc(s_rhs).end_, s_rhs.file); + left = Some(Binary(l, op, r, loc)); + st = s_rhs; + }, + _ => { + // Parser.res keeps `left` unchanged on a missing rhs but has + // already advanced past the operator; replicate that. + st = s_rhs; + } + } + } + } + } + (left, st) +} + +fn parse_logical_or(s: ParserState) -> (Option, ParserState) { + let (left, s1) = parse_logical_and(s); + binary_loop(left, s1, [Or], op_logical_or, parse_logical_and) +} +fn op_logical_or(_t: TokenType) -> BinaryOp { LOr } + +fn parse_logical_and(s: ParserState) -> (Option, ParserState) { + let (left, s1) = parse_equality(s); + binary_loop(left, s1, [And], op_logical_and, parse_equality) +} +fn op_logical_and(_t: TokenType) -> BinaryOp { LAnd } + +fn parse_equality(s: ParserState) -> (Option, ParserState) { + let (left, s1) = parse_comparison(s); + binary_loop(left, s1, [EqualEqual, BangEqual], op_equality, parse_comparison) +} +fn op_equality(t: TokenType) -> BinaryOp { + match t { + EqualEqual => Eq, + BangEqual => Neq, + _ => Eq + } +} + +fn parse_comparison(s: ParserState) -> (Option, ParserState) { + let (left, s1) = parse_term(s); + binary_loop(left, s1, [Less, Greater, LessEqual, GreaterEqual], op_comparison, parse_term) +} +fn op_comparison(t: TokenType) -> BinaryOp { + match t { + Less => Lt, + Greater => Gt, + LessEqual => Lte, + GreaterEqual => Gte, + _ => Lt + } +} + +fn parse_term(s: ParserState) -> (Option, ParserState) { + let (left, s1) = parse_factor(s); + binary_loop(left, s1, [Plus, Minus], op_term, parse_factor) +} +fn op_term(t: TokenType) -> BinaryOp { + match t { + Plus => Add, + Minus => Sub, + _ => Add + } +} + +fn parse_factor(s: ParserState) -> (Option, ParserState) { + let (left, s1) = parse_unary(s); + binary_loop(left, s1, [Star, Slash, Percent], op_factor, parse_unary) +} +fn op_factor(t: TokenType) -> BinaryOp { + match t { + Star => Mul, + Slash => Div, + Percent => Mod, + _ => Mul + } +} + +fn parse_unary(s: ParserState) -> (Option, ParserState) { + let (matched, s1) = match_any(s, [Minus, Not, Tilde]); + if matched { + let op_tok = match prev_token(s1) { + Some(t) => t, + None => #{ type_: Minus, lexeme: "-", loc: zero_loc(s1.file) } + }; + let op = match op_tok.type_ { + Minus => Neg, + Not => LNot, + Tilde => BNot, + _ => Neg + }; + let (right, s2) = parse_unary(s1); + match right { + Some(r) => { + let loc = mk_loc(op_tok.loc.start, current_loc(s2).end_, s2.file); + (Some(Unary(op, r, loc)), s2) + }, + None => (None, s2) + } + } else { + parse_postfix(s) + } +} + +fn parse_postfix(s: ParserState) -> (Option, ParserState) { + let (left0, s0) = parse_primary(s); + let mut left = left0; + let mut st = s0; + let mut go = true; + while go { + let has_left = match left { Some(_e) => true, None => false }; + if !has_left { + go = false; + } else if check(st, LParen) { + // Function call + let s1 = advance(st); + let s2 = skip_newlines(s1); + let (args, s3) = parse_arg_list(s2, RParen); + let s4 = skip_newlines(s3); + let s5 = expect_(s4, RParen, "Expected ')' after arguments"); + match left { + Some(l) => { + let loc = mk_loc(start_or_current_call(l, s5), current_loc(s5).end_, s5.file); + left = Some(Call(l, args, loc)); + st = s5; + }, + None => { st = s5; } + } + } else if check(st, LBracket) { + // Index + let s1 = advance(st); + let s2 = skip_newlines(s1); + let (idx_opt, s3) = parse_expression(s2); + match idx_opt { + Some(idx) => { + let s4 = skip_newlines(s3); + let s5 = expect_(s4, RBracket, "Expected ']' after index"); + match left { + Some(l) => { + let loc = mk_loc(start_or_current(l, s5), current_loc(s5).end_, s5.file); + left = Some(Index(l, idx, loc)); + st = s5; + }, + None => { st = s5; } + } + }, + None => { st = s3; } + } + } else if check(st, Dot) { + // Member access + let s1 = advance(st); + match peek(s1) { + Some(tok) => match tok.type_ { + Identifier(name) => { + let member_loc = tok.loc; + let s2 = advance(s1); + match left { + Some(l) => { + let loc = mk_loc(start_or_current(l, s2), member_loc.end_, s2.file); + left = Some(Member(l, name, loc)); + st = s2; + }, + None => { st = s2; } + } + }, + _ => { + st = add_diagnostic(s1, E0001, "Expected identifier after '.'", current_loc(s1)); + go = false; + } + }, + None => { + st = add_diagnostic(s1, E0001, "Expected identifier after '.'", current_loc(s1)); + go = false; + } + } + } else { + go = false; + } + } + (left, st) +} + +// Parse a comma-separated expression list up to (but not consuming) `closer`. +// Mirrors the repeated `parseExpression` + `while match_(Comma)` blocks used +// for call args, array elements, and print args. +fn parse_arg_list(s: ParserState, closer: TokenType) -> ([Expr], ParserState) { + let mut args = []; + let mut st = s; + if !check(st, closer) { + let (first, s1) = parse_expression(st); + args = match first { + Some(e) => args ++ [e], + None => args + }; + st = s1; + let mut go = true; + while go { + let (got_comma, s_c) = match_any(st, [Comma]); + if !got_comma { + st = s_c; + go = false; + } else { + let s_sn = skip_newlines(s_c); + let (e_opt, s_e) = parse_expression(s_sn); + args = match e_opt { + Some(e) => args ++ [e], + None => args + }; + st = s_e; + } + } + } + // `return` (not a bare trailing tuple): an if-statement immediately + // followed by a tuple tail trips an AffineScript checker bug (E0104 + // "Expected a function, got Unit", no span) — the `()` of the if is + // applied to the tuple. Verified minimal repro; `return` sidesteps it. + return (args, st); +} + +fn parse_primary(s: ParserState) -> (Option, ParserState) { + match peek(s) { + Some(tok) => parse_primary_tok(s, tok), + None => (None, s) + } +} + +fn parse_primary_tok(s: ParserState, tok: Token) -> (Option, ParserState) { + let loc = tok.loc; + match tok.type_ { + Integer(n) => (Some(IntLit(n, loc)), advance(s)), + FloatTok(f) => (Some(FloatLit(f, loc)), advance(s)), + StringTok(str) => (Some(StringLit(str, loc)), advance(s)), + True => (Some(BoolLit(true, loc)), advance(s)), + False => (Some(BoolLit(false, loc)), advance(s)), + Nil => (Some(NilLit(loc)), advance(s)), + Identifier(name) => (Some(Ident(name, loc)), advance(s)), + LBracket => parse_array_literal(s, loc), + LParen => parse_grouped(s), + Fn => parse_lambda(s, loc), + _ => { + let s1 = add_diagnostic(s, E0001, "Unexpected token '" ++ tok.lexeme ++ "'", tok.loc); + (None, s1) + } + } +} + +fn parse_array_literal(s: ParserState, start_loc: Location) -> (Option, ParserState) { + let s1 = advance(s); // [ + let s2 = skip_newlines(s1); + let (elems, s3) = parse_arg_list(s2, RBracket); + let s4 = skip_newlines(s3); + let s5 = expect_(s4, RBracket, "Expected ']' after array elements"); + let loc = mk_loc(start_loc.start, current_loc(s5).end_, s5.file); + (Some(Array(elems, loc)), s5) +} + +fn parse_grouped(s: ParserState) -> (Option, ParserState) { + let s1 = advance(s); // ( + let s2 = skip_newlines(s1); + let (expr, s3) = parse_expression(s2); + let s4 = skip_newlines(s3); + let s5 = expect_(s4, RParen, "Expected ')' after expression"); + (expr, s5) +} + +fn parse_lambda(s: ParserState, start_loc: Location) -> (Option, ParserState) { + let s1 = advance(s); // fn + let s2 = expect_(s1, LParen, "Expected '(' after 'fn'"); + let (params, s3) = parse_lambda_params(s2); + let s4 = expect_(s3, RParen, "Expected ')' after parameters"); + let (got_arrow, s5) = match_any(s4, [Arrow]); + if got_arrow { + let (body_opt, s6) = parse_expression(s5); + match body_opt { + Some(body) => { + let loc = mk_loc(start_loc.start, current_loc(s6).end_, s6.file); + (Some(Lambda(params, None, LambdaExpr(body), loc)), s6) + }, + None => (None, s6) + } + } else { + // Block body (future) — Parser.res returns None here. + (None, s5) + } +} + +// Lambda params: untyped identifiers (Parser.res `type_: None`). +fn parse_lambda_params(s: ParserState) -> ([Param], ParserState) { + let mut params = []; + let mut st = skip_newlines(s); + if !check(st, RParen) { + let (p0, st0) = lambda_param_at(params, st); + params = p0; + st = st0; + let mut go = true; + while go { + let (got_comma, s_c) = match_any(st, [Comma]); + if !got_comma { + st = s_c; + go = false; + } else { + let s_sn = skip_newlines(s_c); + let (pn, stn) = lambda_param_at(params, s_sn); + params = pn; + st = stn; + } + } + } + return (params, st); // `return` works around the if-then-tuple checker bug +} + +// If the current token is an identifier, append an untyped Param and advance; +// otherwise leave both unchanged. Returns (params, state). +fn lambda_param_at(params: [Param], s: ParserState) -> ([Param], ParserState) { + match peek(s) { + Some(tok) => match tok.type_ { + Identifier(name) => (params ++ [#{ name: name, type_: None, loc: tok.loc }], advance(s)), + _ => (params, s) + }, + None => (params, s) + } +} + +// ============================================ +// Type-annotation parsing +// ============================================ + +// Consume a closing '>' for a type-arg list. The lexer fuses `>>` into a +// single GreaterGreater; if a closing angle sits against an enclosing one we +// split it in place (rebuild the token array, leaving a `Greater` at pos). +fn expect_close_angle(s: ParserState) -> ParserState { + match peek(s) { + Some(tok) => match tok.type_ { + Greater => advance(s), + GreaterGreater => { + let split = #{ type_: Greater, lexeme: ">", loc: tok.loc }; + set_token_at(s, s.pos, split) + }, + _ => add_diagnostic(s, E0006, "Expected '>' to close type arguments", tok.loc) + }, + None => s + } +} + +// Optional `` / ``. Returns ((first, second), state). +fn parse_type_args(s: ParserState) -> ((Option, Option), ParserState) { + if check(s, Less) { + let s1 = advance(s); + let (first, s2) = parse_type_expr(s1); + let (got_comma, s3) = match_any(s2, [Comma]); + let (second, s4) = if got_comma { + parse_type_expr(s3) + } else { + (None, s3) + }; + let s5 = expect_close_angle(s4); + ((first, second), s5) + } else { + ((None, None), s) + } +} + +fn parse_type_expr(s: ParserState) -> (Option, ParserState) { + match peek(s) { + Some(tok) => match tok.type_ { + TInt => (Some(TyInt), advance(s)), + TFloat => (Some(TyFloat), advance(s)), + TString => (Some(TyString), advance(s)), + TBool => (Some(TyBool), advance(s)), + TArray => { + let s1 = advance(s); + let ((first, _second), s2) = parse_type_args(s1); + match first { + Some(inner) => (Some(TyArray(inner)), s2), + // Bare `Array`: default element type to `Any`. + None => (Some(TyArray(TyIdent("Any"))), s2) + } + }, + TEcho => { + let s1 = advance(s); + let ((a, b), s2) = parse_type_args(s1); + (Some(TyEcho(a, b)), s2) + }, + TEchoR => { + let s1 = advance(s); + let ((a, b), s2) = parse_type_args(s1); + (Some(TyEchoResidue(a, b)), s2) + }, + Identifier(name) => (Some(TyIdent(name)), advance(s)), + _ => (None, s) + }, + None => (None, s) + } +} + +// Optional `: Type` annotation (used after let-names and parameter names). +fn parse_optional_type(s: ParserState) -> (Option, ParserState) { + let (got_colon, s1) = match_any(s, [Colon]); + if got_colon { + parse_type_expr(s1) + } else { + (None, s1) + } +} + +// ============================================ +// Statement parsing +// ============================================ + +fn parse_statement(s: ParserState) -> (Option, ParserState) { + let s0 = skip_newlines(s); + match peek(s0) { + Some(tok) => parse_statement_tok(s0, tok), + None => (None, s0) + } +} + +fn parse_statement_tok(s: ParserState, tok: Token) -> (Option, ParserState) { + let start_loc = tok.loc; + match tok.type_ { + Let => parse_let(s, start_loc), + If => parse_if(s, start_loc), + While => parse_while(s, start_loc), + For => parse_for(s, start_loc), + Return => parse_return(s, start_loc), + Break => (Some(BreakStmt(start_loc)), advance(s)), + Continue => (Some(ContinueStmt(start_loc)), advance(s)), + Identifier(name) => + if name == "print" { + parse_print(s, start_loc, false) + } else if name == "println" { + parse_print(s, start_loc, true) + } else { + parse_expr_stmt(s) + }, + Gutter => parse_gutter(s, start_loc), + _ => parse_expr_stmt(s) + } +} + +fn parse_let(s: ParserState, start_loc: Location) -> (Option, ParserState) { + let s1 = advance(s); // let + let (mutable_, s2) = match_any(s1, [Mutable]); + match peek(s2) { + Some(tok) => match tok.type_ { + Identifier(name) => { + let s3 = advance(s2); + let (type_, s4) = parse_optional_type(s3); + let s5 = expect_(s4, Equal, "Expected '=' after variable name"); + let (value_opt, s6) = parse_expression(s5); + match value_opt { + Some(value) => { + let loc = mk_loc(start_loc.start, current_loc(s6).end_, s6.file); + (Some(LetStmt(mutable_, name, type_, value, loc)), s6) + }, + None => (None, s6) + } + }, + _ => { + let s3 = add_diagnostic(s2, E0001, "Expected identifier after 'let'", current_loc(s2)); + (None, s3) + } + }, + None => { + let s3 = add_diagnostic(s2, E0001, "Expected identifier after 'let'", current_loc(s2)); + (None, s3) + } + } +} + +fn parse_if(s: ParserState, start_loc: Location) -> (Option, ParserState) { + let s1 = advance(s); // if + let (cond_opt, s2) = parse_expression(s1); + match cond_opt { + Some(cond) => { + let s3 = skip_newlines(s2); + let (then_, s4) = parse_block(s3); + // elseif chains + let (elseifs, s5) = parse_elseifs(s4); + // optional else + let (else_, s6) = if check(s5, Else) { + let s_e = advance(s5); + let s_e2 = skip_newlines(s_e); + let (body, s_e3) = parse_block(s_e2); + (Some(body), s_e3) + } else { + (None, s5) + }; + let loc = mk_loc(start_loc.start, current_loc(s6).end_, s6.file); + (Some(IfStmt(cond, then_, elseifs, else_, loc)), s6) + }, + None => (None, s2) + } +} + +fn parse_elseifs(s: ParserState) -> ([(Expr, [Stmt])], ParserState) { + let mut elseifs = []; + let mut st = s; + let mut go = true; + while go { + if check(st, Elseif) { + let s1 = advance(st); + let (elif_cond_opt, s2) = parse_expression(s1); + match elif_cond_opt { + Some(elif_cond) => { + let s3 = skip_newlines(s2); + let (elif_body, s4) = parse_block(s3); + elseifs = elseifs ++ [(elif_cond, elif_body)]; + st = s4; + }, + None => { st = s2; } + } + } else { + go = false; + } + } + (elseifs, st) +} + +fn parse_while(s: ParserState, start_loc: Location) -> (Option, ParserState) { + let s1 = advance(s); // while + let (cond_opt, s2) = parse_expression(s1); + match cond_opt { + Some(cond) => { + let s3 = skip_newlines(s2); + let (body, s4) = parse_block(s3); + let loc = mk_loc(start_loc.start, current_loc(s4).end_, s4.file); + (Some(WhileStmt(cond, body, loc)), s4) + }, + None => (None, s2) + } +} + +fn parse_for(s: ParserState, start_loc: Location) -> (Option, ParserState) { + let s1 = advance(s); // for + match peek(s1) { + Some(tok) => match tok.type_ { + Identifier(var) => { + let s2 = advance(s1); + let s3 = expect_(s2, In, "Expected 'in' after loop variable"); + let (iter_opt, s4) = parse_expression(s3); + match iter_opt { + Some(iter) => { + let s5 = skip_newlines(s4); + let (body, s6) = parse_block(s5); + let loc = mk_loc(start_loc.start, current_loc(s6).end_, s6.file); + (Some(ForStmt(var, iter, body, loc)), s6) + }, + None => (None, s4) + } + }, + _ => { + let s2 = add_diagnostic(s1, E0001, "Expected identifier after 'for'", current_loc(s1)); + (None, s2) + } + }, + None => { + let s2 = add_diagnostic(s1, E0001, "Expected identifier after 'for'", current_loc(s1)); + (None, s2) + } + } +} + +fn parse_return(s: ParserState, start_loc: Location) -> (Option, ParserState) { + let s1 = advance(s); // return + let (value, s2) = if check(s1, Newline) || check(s1, End) || check(s1, EOF) { + (None, s1) + } else { + parse_expression(s1) + }; + let loc = mk_loc(start_loc.start, current_loc(s2).end_, s2.file); + (Some(ReturnStmt(value, loc)), s2) +} + +// `is_println` distinguishes print vs println. NB: a parameter literally named +// `println` shadows the `println` builtin, which trips the resolver's global +// "Expected a function, got Unit" (E0104, no span) — so we avoid that name. +fn parse_print(s: ParserState, start_loc: Location, is_println: Bool) -> (Option, ParserState) { + let s1 = advance(s); // print / println + let msg = if is_println { "Expected '(' after 'println'" } else { "Expected '(' after 'print'" }; + let s2 = expect_(s1, LParen, msg); + let s3 = skip_newlines(s2); + let (args, s4) = parse_arg_list(s3, RParen); + let s5 = skip_newlines(s4); + let s6 = expect_(s5, RParen, "Expected ')' after arguments"); + let loc = mk_loc(start_loc.start, current_loc(s6).end_, s6.file); + (Some(PrintStmt(is_println, args, loc)), s6) +} + +// Gutter block — error-injection zone: swallow tokens until `end`, recover. +fn parse_gutter(s: ParserState, start_loc: Location) -> (Option, ParserState) { + let s1 = advance(s); // gutter + let s2 = skip_newlines(s1); + + let mut gutter_tokens = []; + let mut st = s2; + let mut scanning = true; + while scanning { + if !check(st, End) && !is_at_end(st) { + match peek(st) { + Some(tok) => { + gutter_tokens = gutter_tokens ++ [tok]; + st = advance(st); + }, + None => { scanning = false; } + } + } else { + scanning = false; + } + } + + let (recovered, s3) = if check(st, End) { + (true, advance(st)) + } else { + (false, add_diagnostic(st, E0005, "Missing 'end' for gutter block", start_loc)) + }; + + let loc = mk_loc(start_loc.start, current_loc(s3).end_, s3.file); + (Some(GutterBlock(gutter_tokens, recovered, loc)), s3) +} + +fn parse_expr_stmt(s: ParserState) -> (Option, ParserState) { + let (expr_opt, s1) = parse_expression(s); + match expr_opt { + Some(expr) => (Some(ExprStmt(expr)), s1), + None => { + // Skip bad token + let s2 = advance(s1); + (None, s2) + } + } +} + +fn parse_block(s: ParserState) -> ([Stmt], ParserState) { + let mut stmts = []; + let mut st = skip_newlines(s); + let mut go = true; + while go { + if !check(st, End) && !check(st, Elseif) && !check(st, Else) && !is_at_end(st) { + let (stmt_opt, s1) = parse_statement(st); + stmts = match stmt_opt { + Some(stmt) => stmts ++ [stmt], + None => stmts + }; + st = skip_newlines(s1); + } else { + go = false; + } + } + let st2 = if check(st, End) { advance(st) } else { st }; + (stmts, st2) +} + +// ============================================ +// Declaration parsing +// ============================================ + +fn parse_declaration(s: ParserState) -> (Option, ParserState) { + let s0 = skip_newlines(s); + match peek(s0) { + Some(tok) => parse_declaration_tok(s0, tok), + None => (None, s0) + } +} + +fn parse_declaration_tok(s: ParserState, tok: Token) -> (Option, ParserState) { + let start_loc = tok.loc; + match tok.type_ { + Function => parse_function(s, start_loc), + Main => parse_main(s, start_loc), + Struct => parse_struct(s, start_loc), + _ => { + // Top-level statement + let (stmt_opt, s1) = parse_statement(s); + match stmt_opt { + Some(stmt) => (Some(StmtDecl(stmt)), s1), + None => (None, s1) + } + } + } +} + +fn parse_function(s: ParserState, start_loc: Location) -> (Option, ParserState) { + let s1 = advance(s); // function + match peek(s1) { + Some(tok) => match tok.type_ { + Identifier(name) => { + let s2 = advance(s1); + let s3 = expect_(s2, LParen, "Expected '(' after function name"); + let (params, s4) = parse_typed_params(s3); + let s5 = expect_(s4, RParen, "Expected ')' after parameters"); + // optional return type `-> Type` + let (got_arrow, s6) = match_any(s5, [Arrow]); + let (return_type, s7) = if got_arrow { + parse_type_expr(s6) + } else { + (None, s6) + }; + let s8 = skip_newlines(s7); + let (body, s9) = parse_block(s8); + let loc = mk_loc(start_loc.start, current_loc(s9).end_, s9.file); + (Some(FunctionDecl(name, params, return_type, body, loc)), s9) + }, + _ => { + let s2 = add_diagnostic(s1, E0001, "Expected function name", current_loc(s1)); + (None, s2) + } + }, + None => { + let s2 = add_diagnostic(s1, E0001, "Expected function name", current_loc(s1)); + (None, s2) + } + } +} + +// Function params: each an identifier with an optional `: Type` annotation. +fn parse_typed_params(s: ParserState) -> ([Param], ParserState) { + let mut params = []; + let mut st = skip_newlines(s); + if !check(st, RParen) { + let (p0, st0) = typed_param_at(params, st); + params = p0; + st = st0; + let mut go = true; + while go { + let (got_comma, s_c) = match_any(st, [Comma]); + if !got_comma { + st = s_c; + go = false; + } else { + let s_sn = skip_newlines(s_c); + let (pn, stn) = typed_param_at(params, s_sn); + params = pn; + st = stn; + } + } + } + return (params, st); // `return` works around the if-then-tuple checker bug +} + +// If the current token is an identifier, append a Param with optional `: Type` +// and advance past name (+ annotation); otherwise leave both unchanged. +fn typed_param_at(params: [Param], s: ParserState) -> ([Param], ParserState) { + match peek(s) { + Some(tok) => match tok.type_ { + Identifier(pname) => { + let ploc = tok.loc; + let s1 = advance(s); + let (ptype, s2) = parse_optional_type(s1); + (params ++ [#{ name: pname, type_: ptype, loc: ploc }], s2) + }, + _ => (params, s) + }, + None => (params, s) + } +} + +fn parse_main(s: ParserState, start_loc: Location) -> (Option, ParserState) { + let s1 = advance(s); // main + let s2 = skip_newlines(s1); + let (body, s3) = parse_block(s2); + let loc = mk_loc(start_loc.start, current_loc(s3).end_, s3.file); + (Some(MainBlock(body, loc)), s3) +} + +fn parse_struct(s: ParserState, start_loc: Location) -> (Option, ParserState) { + let s1 = advance(s); // struct + match peek(s1) { + Some(tok) => match tok.type_ { + Identifier(name) => { + let s2 = advance(s1); + let s3 = skip_newlines(s2); + let (fields, s4) = parse_struct_fields(s3); + let s5 = if check(s4, End) { advance(s4) } else { s4 }; + let loc = mk_loc(start_loc.start, current_loc(s5).end_, s5.file); + (Some(StructDecl(name, fields, loc)), s5) + }, + _ => { + let s2 = add_diagnostic(s1, E0001, "Expected struct name", current_loc(s1)); + (None, s2) + } + }, + None => { + let s2 = add_diagnostic(s1, E0001, "Expected struct name", current_loc(s1)); + (None, s2) + } + } +} + +fn parse_struct_fields(s: ParserState) -> ([(String, TypeExpr)], ParserState) { + let mut fields = []; + let mut st = s; + let mut go = true; + while go { + if !check(st, End) && !is_at_end(st) { + let (fn2, st2, keep) = struct_field_at(fields, st); + fields = fn2; + st = st2; + go = keep; + } else { + go = false; + } + } + (fields, st) +} + +// Parse one struct field (`name: Type`) if present. Returns +// (fields, state, keep_going). `keep_going` is false only when the cursor is +// exhausted (mirrors the `None => ()` arm that would otherwise spin). +fn struct_field_at(fields: [(String, TypeExpr)], s: ParserState) -> ([(String, TypeExpr)], ParserState, Bool) { + match peek(s) { + Some(tok) => match tok.type_ { + Identifier(fname) => { + let s1 = advance(s); + let s2 = expect_(s1, Colon, "Expected ':' after field name"); + let (ty_opt, s3) = parse_type_expr(s2); + let new_fields = match ty_opt { + Some(ty) => fields ++ [(fname, ty)], + None => fields + }; + (new_fields, skip_newlines(s3), true) + }, + // Non-identifier: Parser.res's `| _ => ()` does not advance; the trailing + // skipNewlines then runs. We advance past the stray token to guarantee + // progress (a non-newline non-identifier would otherwise loop forever), + // which is a benign termination hardening over the .res source. + _ => (fields, skip_newlines(advance(s)), true) + }, + None => (fields, s, false) + } +} + +// ============================================ +// Main entry point +// ============================================ + +pub fn parse(tokens: [Token], file: String, run_number: Int) -> (Program, [Diagnostic]) { + let s0 = make(tokens, file, run_number); + let mut declarations = []; + let mut st = s0; + let mut go = true; + while go { + if !is_at_end(st) { + let (decl_opt, s1) = parse_declaration(st); + declarations = match decl_opt { + Some(decl) => declarations ++ [decl], + None => declarations + }; + st = skip_newlines(s1); + } else { + go = false; + } + } + + let prog_loc = mk_loc(#{ line: 1, column: 1, offset: 0 }, current_loc(st).end_, file); + let program = #{ declarations: declarations, loc: prog_loc }; + (program, st.diagnostics) +} From 17acf9bd6ac126a7ebb36e53735263dd43df79b5 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 24 Jun 2026 07:22:36 +0000 Subject: [PATCH 09/11] compiler: port TypeChecker.res + TypeSuperposition.res -> .affine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modules 5 + 6 (1123 + 323 lines), both verified green with affinescript check and the full harness (all six modules ok). TypeChecker.affine: the static checker. The .res's shared-mutable env (fresh-var counter + substitution map) and checkResult.errors are unified into one immutable, threaded CheckState, returned via (result, CheckState) tuples. The lexical environment is a frame-stack [[(String, Binding)]] (head = innermost); the substitution map is an Int-keyed assoc list. Unification is faithful arm-for-arm including numeric widening and the Echo/EchoR rule: same-kind structural only, Echo<_,_> deliberately NOT unifying with EchoR<_,_> (erasure is irreversible). The Echo builtins (echo / echo_to_residue / residue_strictly_loses / echo_input / echo_output) mirror the .res including the [Stab-Erase] diagnostics. Internal `ty` constructors are C-prefixed (CInt..CVar) to avoid colliding with Types' TypeExpr. TypeSuperposition.affine: the quantum-type demo (Collapsed | Superposition(...)); the deterministic collapse index is byte-faithful. Documented faithful deviations: the .res `exprLoc` helper is omitted — `infer_expr` returns the expression's own Location (a copyable summary) alongside the type, so diagnostics keep exact locations without re-traversing a consumed node; Array.zip/every/forEach over state-mutating callbacks become state-threading folds; minor local renames to avoid global-constructor collisions. No type rule, diagnostic, Echo/EchoR behaviour, or collapse arithmetic was changed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0195yA45jSSP7YDPwJSpw4bM --- compiler/src/TypeChecker.affine | 1123 +++++++++++++++++++++++++ compiler/src/TypeSuperposition.affine | 323 +++++++ 2 files changed, 1446 insertions(+) create mode 100644 compiler/src/TypeChecker.affine create mode 100644 compiler/src/TypeSuperposition.affine diff --git a/compiler/src/TypeChecker.affine b/compiler/src/TypeChecker.affine new file mode 100644 index 0000000..5f463d3 --- /dev/null +++ b/compiler/src/TypeChecker.affine @@ -0,0 +1,1123 @@ +// SPDX-License-Identifier: MPL-2.0 +// TypeChecker.affine — static type checker for Error-Lang +// (ported from compiler/src/TypeChecker.res). +// +// Typing rules (unchanged from the .res): +// - `let x = expr` — infer type from expr +// - `let x: Type = expr` — check expr against Type +// - `mutable` variables can be reassigned (same type only) +// - `if/elseif/else` — condition must be Bool, branches checked in nested scopes +// - `while` — condition must be Bool +// - `for x in expr` — expr must be Array +// - `function` — standard arrow type +// - `gutter` blocks — skip type checking (error injection zone) +// - `print/println` — accept any type +// - Operators: arithmetic on Int/Float, comparison returns Bool, logical on Bool +// +// ── AffineScript port: state model ── +// The ReScript checker mutates a shared `env` (mutable `nextVar`, a shared +// `Dict` of substitutions) and a mutable `checkResult.errors` +// array, all in place. AffineScript has no mutable struct fields / record +// spread, so: +// * The unification "globals" — fresh-var counter, substitution map, and +// accumulated errors — live in a single immutable `CheckState` that is +// THREADED through every function (`(result, CheckState)` tuples; +// callers `let (x, st) = f(st, ...);`). Because they live in one +// threaded value they are automatically shared exactly as the .res's +// parent-linked env shared them — so the .res's explicit +// "propagate state back" after a lambda becomes a no-op here. +// * The lexical environment (the .res `env.bindings` + `parent` chain) is +// a frame STACK `[[ (String, Binding) ]]` (head = innermost scope). +// `extend` pushes an empty frame; `bind` extends the head frame; +// `lookup` searches head→tail. Statements that bind (`let`, `for`, +// params) return the updated frame stack so later siblings see them. +// * The substitution map is `Int`-keyed, but stdlib `dict` is +// String-keyed; a tiny assoc-list `[(Int, Ty)]` with linear get/set is +// used instead (the same purely-functional style as stdlib `dict`). +// +// ── Internal type representation ── +// The .res internal `ty` constructors are named `TyInt`/`TyFloat`/… which +// would COLLIDE with the global `TypeExpr` constructors of the same name in +// `Types.affine` (constructor names are global in AffineScript). They are +// therefore prefixed `C` (for "check"): CInt/CFloat/CString/CBool/CNil/ +// CArray/CFun/CStruct/CEcho/CEchoR/CAny/CVar. The mapping is 1:1 with the +// .res `ty`; only the spelling changes. The `Types::TypeExpr` constructors +// (TyInt, TyArray, TyEcho, TyEchoResidue, TyIdent) are used unprefixed when +// translating annotations. +// +// ── Faithfulness notes: documented gaps / simplifications vs the .res ── +// * Echo / EchoR unification is reproduced EXACTLY: same-kind structural +// unification only; Echo<_,_> deliberately does NOT unify with +// EchoR<_,_> (irreversibility of erasure). The [Stab-Erase] residue +// rules in `infer_echo_builtin` (echo / echo_to_residue / +// residue_strictly_loses / echo_input / echo_output) match the .res +// arm-for-arm, including the "echo_input on a residue is illegal" +// diagnostic and "echo_output retained by both Echo and residue". +// * The standalone `exprLoc` helper of the .res is OMITTED. It existed +// only to recover an error location from an expression the checker was +// about to consume — a use-and-consume that is awkward under affine +// ownership. Instead `infer_expr` RETURNS the expression's own +// `Location` (a copyable summary) alongside the inferred type, so every +// error already has the precise location without re-traversing a moved +// node. Behaviour is identical (same locations attached to the same +// diagnostics). The thin wrapper `infer` drops the location for the +// majority of callers that ignore it. +// * `Array.getUnsafe(0)` / `sliceToEnd(~start=1)` -> head/tail via +// indexing (`xs[0]`, `xs[1:]`); `Array.zip(a,b)->Array.every(f)` and +// `->Array.forEach` over a state-mutating body become explicit +// state-threading folds (`unify_lists`, `*_list` helpers) since the +// callbacks mutate the shared substitution/error state. +// * Empty-array element type uses a fresh var exactly as the .res. +// * No `runTests`/Console code exists in the .res, so nothing is omitted. + +module TypeChecker; + +use prelude::*; +use string::{join}; +use Types::*; + +// ============================================ +// Internal Type Representation (`ty` in the .res; C-prefixed here) +// ============================================ + +pub enum Ty { + CInt, + CFloat, + CString, + CBool, + CNil, + CArray(Ty), + CFun([Ty], Ty), + CStruct(String), + // Echo — fiber / retained-loss witness type (domain A, codomain B). + CEcho(Ty, Ty), + // EchoR — residue: the A-witness has been erased (non-recoverable). + // Deliberately does NOT unify with CEcho. + CEchoR(Ty, Ty), + CAny, + CVar(Int) +} + +// ============================================ +// Bindings / scopes / threaded state +// ============================================ + +pub struct Binding { + ty: Ty, + mutable_: Bool +} + +// One lexical frame is an assoc list of name -> binding. +// The environment is a stack of frames (head = innermost). + +pub struct TypeError { + message: String, + loc: Location +} + +// The threaded unification + error state (the .res's shared env globals + +// checkResult.errors, all in one immutable value). +pub struct CheckState { + nextVar: Int, + subs: [(Int, Ty)], + errors: [TypeError] +} + +pub fn make_state() -> CheckState { + #{ nextVar: 0, subs: [], errors: [] } +} + +fn add_error(st: CheckState, message: String, loc: Location) -> CheckState { + #{ nextVar: st.nextVar, subs: st.subs, + errors: st.errors ++ [#{ message: message, loc: loc }] } +} + +fn fresh_var(st: CheckState) -> (Ty, CheckState) { + let id = st.nextVar; + let st2 = #{ nextVar: st.nextVar + 1, subs: st.subs, errors: st.errors }; + (CVar(id), st2) +} + +// ---- substitution map (Int-keyed assoc list; last-write-wins) ---- + +fn subs_get(st: CheckState, id: Int) -> Option { + let mut out = None; + let mut found = false; + for pair in st.subs { + let (k, v) = pair; + if !found && k == id { + out = Some(v); + found = true; + } + } + out +} + +fn subs_set(st: CheckState, id: Int, t: Ty) -> CheckState { + let mut rest = []; + for pair in st.subs { + let (k, v) = pair; + if k != id { + rest = rest ++ [(k, v)]; + } + } + #{ nextVar: st.nextVar, subs: [(id, t)] ++ rest, errors: st.errors } +} + +// ---- scopes ---- + +fn empty_scopes() -> [[(String, Binding)]] { + [[]] +} + +// Push a fresh innermost frame. +fn extend(scopes: [[(String, Binding)]]) -> [[(String, Binding)]] { + [[]] ++ scopes +} + +// Extend the innermost frame with a new binding (shadowing-friendly: the +// new pair is prepended so lookups find it first). +fn bind(scopes: [[(String, Binding)]], name: String, ty: Ty, mutable_: Bool) -> [[(String, Binding)]] { + let n = len(scopes); + if n == 0 { + [[(name, #{ ty: ty, mutable_: mutable_ })]] + } else { + let head = scopes[0]; + let new_head = [(name, #{ ty: ty, mutable_: mutable_ })] ++ head; + [new_head] ++ scopes[1:] + } +} + +fn lookup_frame(frame: [(String, Binding)], name: String) -> Option { + let mut out = None; + let mut found = false; + for pair in frame { + let (k, b) = pair; + if !found && k == name { + out = Some(b); + found = true; + } + } + out +} + +fn lookup_var(scopes: [[(String, Binding)]], name: String) -> Option { + let mut out = None; + let mut found = false; + for frame in scopes { + if !found { + match lookup_frame(frame, name) { + Some(b) => { out = Some(b); found = true; }, + None => {} + } + } + } + out +} + +// ============================================ +// AST Type -> Internal Type +// ============================================ + +pub fn type_expr_to_ty(te: TypeExpr) -> Ty { + match te { + TyInt => CInt, + TyFloat => CFloat, + TyString => CString, + TyBool => CBool, + TyArray(inner) => CArray(type_expr_to_ty(inner)), + TyEcho(a, b) => CEcho(opt_type_expr_to_ty(a), opt_type_expr_to_ty(b)), + TyEchoResidue(a, b) => CEchoR(opt_type_expr_to_ty(a), opt_type_expr_to_ty(b)), + TyIdent(name) => CStruct(name) + } +} + +// An omitted Echo type argument (`Echo` or bare `Echo`) is treated as +// `Any`, which unifies with everything. +fn opt_type_expr_to_ty(o: Option) -> Ty { + match o { + Some(t) => type_expr_to_ty(t), + None => CAny + } +} + +// ============================================ +// Type Display +// ============================================ + +pub fn ty_to_string(t: Ty) -> String { + match t { + CInt => "Int", + CFloat => "Float", + CString => "String", + CBool => "Bool", + CNil => "Nil", + CArray(inner) => "Array<" ++ ty_to_string(inner) ++ ">", + CFun(params, ret) => { + let param_str = join(map(params, ty_to_string), ", "); + "(" ++ param_str ++ ") -> " ++ ty_to_string(ret) + }, + CStruct(name) => name, + CEcho(a, b) => "Echo<" ++ ty_to_string(a) ++ ", " ++ ty_to_string(b) ++ ">", + CEchoR(a, b) => "EchoR<" ++ ty_to_string(a) ++ ", " ++ ty_to_string(b) ++ ">", + CAny => "Any", + CVar(id) => "?" ++ int_to_string(id) + } +} + +// ============================================ +// Unification +// ============================================ + +// Resolve a type through the substitution map (read-only; threads no state). +pub fn resolve(st: CheckState, t: Ty) -> Ty { + match t { + CVar(id) => + match subs_get(st, id) { + Some(resolved) => resolve(st, resolved), + None => CVar(id) + }, + CArray(inner) => CArray(resolve(st, inner)), + CFun(params, ret) => CFun(map(params, |p| resolve(st, p)), resolve(st, ret)), + CEcho(a, b) => CEcho(resolve(st, a), resolve(st, b)), + CEchoR(a, b) => CEchoR(resolve(st, a), resolve(st, b)), + _ => t + } +} + +// Unify two types, possibly extending the substitution map. Returns +// (success, new_state). +pub fn unify(st: CheckState, a0: Ty, b0: Ty) -> (Bool, CheckState) { + let a = resolve(st, a0); + let b = resolve(st, b0); + match (a, b) { + (CAny, _) => (true, st), + (_, CAny) => (true, st), + (CNil, _) => (true, st), + (_, CNil) => (true, st), + (CInt, CInt) => (true, st), + (CFloat, CFloat) => (true, st), + (CString, CString) => (true, st), + (CBool, CBool) => (true, st), + (CVar(id), other) => (true, subs_set(st, id, other)), + (other, CVar(id)) => (true, subs_set(st, id, other)), + (CArray(ia), CArray(ib)) => unify(st, ia, ib), + (CStruct(na), CStruct(nb)) => (na == nb, st), + // Echo and EchoR unify structurally with their own kind only. Echo<_,_> + // does NOT unify with EchoR<_,_>: once a witness is erased the residue + // cannot be passed where a recoverable Echo is required. + (CEcho(a1, b1), CEcho(a2, b2)) => { + let (ok1, st1) = unify(st, a1, a2); + if ok1 { + let (ok2, st2) = unify(st1, b1, b2); + (ok2, st2) + } else { + (false, st1) + } + }, + (CEchoR(a1, b1), CEchoR(a2, b2)) => { + let (ok1, st1) = unify(st, a1, a2); + if ok1 { + let (ok2, st2) = unify(st1, b1, b2); + (ok2, st2) + } else { + (false, st1) + } + }, + (CFun(pa, ra), CFun(pb, rb)) => + if len(pa) != len(pb) { + (false, st) + } else { + let (params_ok, st1) = unify_lists(st, pa, pb); + if params_ok { + unify(st1, ra, rb) + } else { + (false, st1) + } + }, + _ => (false, st) + } +} + +// Unify two equal-length type lists pairwise, threading state and ANDing +// success (the .res `Array.zip(pa,pb)->Array.every(unify)`). +fn unify_lists(st: CheckState, xs: [Ty], ys: [Ty]) -> (Bool, CheckState) { + let n = len(xs); + let mut i = 0; + let mut ok = true; + let mut state = st; + while i < n { + let (one_ok, s2) = unify(state, xs[i], ys[i]); + state = s2; + if !one_ok { ok = false; } + i = i + 1; + } + (ok, state) +} + +// Numeric widening: Int can widen to Float. +fn is_numeric(t: Ty) -> Bool { + match t { + CInt => true, + CFloat => true, + _ => false + } +} + +fn widen_numeric(a: Ty, b: Ty) -> Ty { + match (a, b) { + (CFloat, _) => CFloat, + (_, CFloat) => CFloat, + _ => CInt + } +} + +// ============================================ +// Echo Builtins +// ============================================ + +// Runtime operations over Echo types (mirroring EchoTypes.jl): +// echo(x, y) : (A, B) -> Echo +// echo_to_residue(e) : Echo -> EchoR (erases witness) +// residue_strictly_loses(r) : EchoR -> Bool +// echo_input(e) : Echo -> A (illegal on residue) +// echo_output(e) : Echo | EchoR -> B +fn is_echo_builtin(name: String) -> Bool { + name == "echo" || name == "echo_to_residue" || name == "residue_strictly_loses" + || name == "echo_input" || name == "echo_output" +} + +// ============================================ +// Expression Type Inference +// +// `infer_expr` returns (ty, Location, CheckState): the inferred type, the +// expression's own location (copyable — replaces the .res `exprLoc`), and +// the threaded state. `infer` is the location-dropping wrapper most callers +// use. Scopes are read-only here (expressions never bind), so they are +// passed by value (cloned into recursive calls). +// ============================================ + +fn infer(st: CheckState, scopes: [[(String, Binding)]], e: Expr) -> (Ty, CheckState) { + let (t, _loc, st2) = infer_expr(st, scopes, e); + (t, st2) +} + +fn infer_expr(st: CheckState, scopes: [[(String, Binding)]], e: Expr) -> (Ty, Location, CheckState) { + match e { + IntLit(_n, loc) => (CInt, loc, st), + FloatLit(_f, loc) => (CFloat, loc, st), + StringLit(_s, loc) => (CString, loc, st), + BoolLit(_b, loc) => (CBool, loc, st), + NilLit(loc) => (CNil, loc, st), + + Ident(name, loc) => + match lookup_var(scopes, name) { + Some(b) => (b.ty, loc, st), + None => { + let st2 = add_error(st, "Undefined variable '" ++ name ++ "'", loc); + (CAny, loc, st2) + } + }, + + Array(elements, loc) => + if len(elements) == 0 { + let (fv, st2) = fresh_var(st); + (CArray(fv), loc, st2) + } else { + let (elem_ty, st_head) = infer(st, scopes, elements[0]); + let rest = elements[1:]; + let (final_elem, st_rest) = infer_array_rest(st_head, scopes, elem_ty, rest); + (CArray(resolve(st_rest, final_elem)), loc, st_rest) + }, + + Binary(left, op, right, loc) => { + let (t, st2) = infer_binary_op(st, scopes, left, op, right, loc); + (t, loc, st2) + }, + + Unary(op, operand, loc) => { + let (t, st2) = infer_unary_op(st, scopes, op, operand, loc); + (t, loc, st2) + }, + + Call(callee, args, loc) => + match callee { + Ident(name, id_loc) => + if is_echo_builtin(name) { + let (t, st2) = infer_echo_builtin(st, scopes, name, args, loc); + (t, loc, st2) + } else { + let (t, st2) = infer_call(st, scopes, Ident(name, id_loc), args, loc); + (t, loc, st2) + }, + _ => { + let (t, st2) = infer_call(st, scopes, callee, args, loc); + (t, loc, st2) + } + }, + + Index(base, idx, loc) => { + let (base_ty, st1) = infer(st, scopes, base); + let (idx_ty, st2) = infer(st1, scopes, idx); + let (ok, st3) = unify(st2, idx_ty, CInt); + let st4 = if !ok { + add_error(st3, "Array index must be Int, got " ++ ty_to_string(idx_ty), loc) + } else { + st3 + }; + match resolve(st4, base_ty) { + CArray(elem_ty) => (elem_ty, loc, st4), + CAny => (CAny, loc, st4), + other => { + let st5 = add_error(st4, "Cannot index non-array type " ++ ty_to_string(other), loc); + (CAny, loc, st5) + } + } + }, + + Member(base, _field, loc) => { + // Struct field access requires a type database; return Any for now. + let (_bt, st2) = infer(st, scopes, base); + (CAny, loc, st2) + }, + + Ternary(cond, then_expr, else_expr, loc) => { + let (cond_ty, st1) = infer(st, scopes, cond); + let (ok_c, st2) = unify(st1, cond_ty, CBool); + let st3 = if !ok_c { + add_error(st2, "Ternary condition must be Bool, got " ++ ty_to_string(cond_ty), loc) + } else { + st2 + }; + let (then_ty, st4) = infer(st3, scopes, then_expr); + let (else_ty, st5) = infer(st4, scopes, else_expr); + let (ok_b, st6) = unify(st5, then_ty, else_ty); + let st7 = if !ok_b { + add_error(st6, + "Ternary branches have different types: " ++ ty_to_string(then_ty) ++ " vs " ++ ty_to_string(else_ty), + loc) + } else { + st6 + }; + (resolve(st7, then_ty), loc, st7) + }, + + Lambda(params, ret_ann, body, loc) => { + let lambda_scopes = extend(scopes); + let (param_tys, scopes2, st1) = bind_lambda_params(st, lambda_scopes, params); + let (body_ty, st2) = match body { + LambdaExpr(expr) => infer(st1, scopes2, expr), + LambdaBlock(stmts) => check_stmts(st1, scopes2, stmts) + }; + let st3 = match ret_ann { + Some(ann) => { + let ann_ty = type_expr_to_ty(ann); + let (ok, sa) = unify(st2, body_ty, ann_ty); + if !ok { + add_error(sa, + "Lambda return type mismatch: declared " ++ ty_to_string(ann_ty) ++ ", body is " ++ ty_to_string(body_ty), + loc) + } else { + sa + } + }, + None => st2 + }; + (CFun(param_tys, resolve(st3, body_ty)), loc, st3) + } + } +} + +// Fold the tail of an array literal, unifying each element type with the +// head element type and threading state (the .res `sliceToEnd(1)->forEach`). +fn infer_array_rest(st: CheckState, scopes: [[(String, Binding)]], elem_ty: Ty, rest: [Expr]) -> (Ty, CheckState) { + let n = len(rest); + let mut i = 0; + let mut state = st; + while i < n { + let (t, loc, s2) = infer_expr(state, scopes, rest[i]); + let (ok, s3) = unify(s2, elem_ty, t); + state = if !ok { + add_error(s3, + "Array element type mismatch: expected " ++ ty_to_string(elem_ty) ++ ", got " ++ ty_to_string(t), + loc) + } else { + s3 + }; + i = i + 1; + } + (elem_ty, state) +} + +// Bind lambda params (each typed-or-fresh, mutable_=false), returning their +// types in order, the extended scopes, and threaded state. +fn bind_lambda_params(st: CheckState, scopes: [[(String, Binding)]], params: [Param]) -> ([Ty], [[(String, Binding)]], CheckState) { + let n = len(params); + let mut i = 0; + let mut tys = []; + let mut sc = scopes; + let mut state = st; + while i < n { + let p = params[i]; + let (ty, s2) = match p.type_ { + Some(te) => (type_expr_to_ty(te), state), + None => fresh_var(state) + }; + sc = bind(sc, p.name, ty, false); + tys = tys ++ [ty]; + state = s2; + i = i + 1; + } + (tys, sc, state) +} + +// Non-echo call inference (the .res Call(callee, args) arm). +fn infer_call(st: CheckState, scopes: [[(String, Binding)]], callee: Expr, args: [Expr], loc: Location) -> (Ty, CheckState) { + let (callee_ty, st1) = infer(st, scopes, callee); + match resolve(st1, callee_ty) { + CFun(param_tys, ret_ty) => + if len(args) != len(param_tys) { + let st2 = add_error(st1, + "Function expects " ++ int_to_string(len(param_tys)) ++ " arguments, got " ++ int_to_string(len(args)), + loc); + (ret_ty, st2) + } else { + let st2 = check_args(st1, scopes, args, param_tys); + (ret_ty, st2) + }, + CAny => (CAny, st1), + other => { + let st2 = add_error(st1, "Cannot call non-function type " ++ ty_to_string(other), loc); + (CAny, st2) + } + } +} + +// Check each argument against its parameter type, threading state. +fn check_args(st: CheckState, scopes: [[(String, Binding)]], args: [Expr], param_tys: [Ty]) -> CheckState { + let n = len(args); + let mut i = 0; + let mut state = st; + while i < n { + let (arg_ty, loc, s2) = infer_expr(state, scopes, args[i]); + let pty = param_tys[i]; + let (ok, s3) = unify(s2, arg_ty, pty); + state = if !ok { + add_error(s3, + "Argument type mismatch: expected " ++ ty_to_string(pty) ++ ", got " ++ ty_to_string(arg_ty), + loc) + } else { + s3 + }; + i = i + 1; + } + state +} + +fn infer_binary_op( + st: CheckState, + scopes: [[(String, Binding)]], + left: Expr, + op: BinaryOp, + right: Expr, + loc: Location +) -> (Ty, CheckState) { + let (lt, st1) = infer(st, scopes, left); + let (rt, st2) = infer(st1, scopes, right); + let lt_r = resolve(st2, lt); + let rt_r = resolve(st2, rt); + + match op { + // Arithmetic + Add => arith_op(st2, op, lt_r, rt_r, loc), + Sub => arith_op(st2, op, lt_r, rt_r, loc), + Mul => arith_op(st2, op, lt_r, rt_r, loc), + Div => arith_op(st2, op, lt_r, rt_r, loc), + Mod => arith_op(st2, op, lt_r, rt_r, loc), + + // Comparison + Eq => cmp_op(st2, lt, rt, lt_r, rt_r, loc), + Neq => cmp_op(st2, lt, rt, lt_r, rt_r, loc), + Lt => cmp_op(st2, lt, rt, lt_r, rt_r, loc), + Gt => cmp_op(st2, lt, rt, lt_r, rt_r, loc), + Lte => cmp_op(st2, lt, rt, lt_r, rt_r, loc), + Gte => cmp_op(st2, lt, rt, lt_r, rt_r, loc), + + // Logical + LAnd => logical_op(st2, lt_r, rt_r, loc), + LOr => logical_op(st2, lt_r, rt_r, loc), + + // Bitwise + BAnd => bitwise_op(st2, lt_r, rt_r, loc), + BOr => bitwise_op(st2, lt_r, rt_r, loc), + BXor => bitwise_op(st2, lt_r, rt_r, loc), + Shl => bitwise_op(st2, lt_r, rt_r, loc), + Shr => bitwise_op(st2, lt_r, rt_r, loc) + } +} + +fn arith_op(st: CheckState, op: BinaryOp, lt_r: Ty, rt_r: Ty, loc: Location) -> (Ty, CheckState) { + if is_numeric(lt_r) && is_numeric(rt_r) { + (widen_numeric(lt_r, rt_r), st) + } else { + let is_add = match op { Add => true, _ => false }; + let str_concat = is_add && (lt_r == CString) && (rt_r == CString); + if str_concat { + (CString, st) + } else { + let st2 = add_error(st, + "Operator requires numeric operands, got " ++ ty_to_string(lt_r) ++ " and " ++ ty_to_string(rt_r), + loc); + (CAny, st2) + } + } +} + +fn cmp_op(st: CheckState, lt: Ty, rt: Ty, lt_r: Ty, rt_r: Ty, loc: Location) -> (Ty, CheckState) { + let (ok, st1) = unify(st, lt, rt); + let st2 = if !ok { + add_error(st1, "Cannot compare " ++ ty_to_string(lt_r) ++ " with " ++ ty_to_string(rt_r), loc) + } else { + st1 + }; + (CBool, st2) +} + +fn logical_op(st: CheckState, lt_r: Ty, rt_r: Ty, loc: Location) -> (Ty, CheckState) { + let st1 = if lt_r != CBool { + add_error(st, "Logical operator requires Bool, got " ++ ty_to_string(lt_r), loc) + } else { + st + }; + let st2 = if rt_r != CBool { + add_error(st1, "Logical operator requires Bool, got " ++ ty_to_string(rt_r), loc) + } else { + st1 + }; + (CBool, st2) +} + +fn bitwise_op(st: CheckState, lt_r: Ty, rt_r: Ty, loc: Location) -> (Ty, CheckState) { + let st1 = if lt_r != CInt { + add_error(st, "Bitwise operator requires Int, got " ++ ty_to_string(lt_r), loc) + } else { + st + }; + let st2 = if rt_r != CInt { + add_error(st1, "Bitwise operator requires Int, got " ++ ty_to_string(rt_r), loc) + } else { + st1 + }; + (CInt, st2) +} + +fn infer_unary_op( + st: CheckState, + scopes: [[(String, Binding)]], + op: UnaryOp, + operand: Expr, + loc: Location +) -> (Ty, CheckState) { + let (t, st1) = infer(st, scopes, operand); + let t_r = resolve(st1, t); + match op { + Neg => { + let st2 = if !is_numeric(t_r) { + add_error(st1, "Negation requires numeric type, got " ++ ty_to_string(t_r), loc) + } else { + st1 + }; + (t_r, st2) + }, + LNot => { + let st2 = if t_r != CBool { + add_error(st1, "Logical NOT requires Bool, got " ++ ty_to_string(t_r), loc) + } else { + st1 + }; + (CBool, st2) + }, + BNot => { + let st2 = if t_r != CInt { + add_error(st1, "Bitwise NOT requires Int, got " ++ ty_to_string(t_r), loc) + } else { + st1 + }; + (CInt, st2) + } + } +} + +// Echo / EchoR builtins. Arguments are inferred first (so nested errors +// surface regardless of arity), mirroring the .res. +fn infer_echo_builtin( + st: CheckState, + scopes: [[(String, Binding)]], + name: String, + args: [Expr], + loc: Location +) -> (Ty, CheckState) { + let (arg_tys0, st1) = infer_all(st, scopes, args); + // Pre-resolve each argument type against the substitution map. + let arg_tys = map(arg_tys0, |t| resolve(st1, t)); + let arity = len(arg_tys); + + if name == "echo" { + let st2 = expect_arity(st1, name, 2, arity, loc); + if arity == 2 { + (CEcho(arg_tys[0], arg_tys[1]), st2) + } else { + (CEcho(CAny, CAny), st2) + } + } else if name == "echo_to_residue" { + let st2 = expect_arity(st1, name, 1, arity, loc); + if arity >= 1 { + match arg_tys[0] { + CEcho(a, b) => (CEchoR(a, b), st2), + CAny => (CEchoR(CAny, CAny), st2), + other => { + let st3 = add_error(st2, "echo_to_residue expects Echo, got " ++ ty_to_string(other), loc); + (CEchoR(CAny, CAny), st3) + } + } + } else { + (CEchoR(CAny, CAny), st2) + } + } else if name == "residue_strictly_loses" { + let st2 = expect_arity(st1, name, 1, arity, loc); + if arity >= 1 { + match arg_tys[0] { + CEchoR(_a, _b) => (CBool, st2), + CAny => (CBool, st2), + other => { + let st3 = add_error(st2, "residue_strictly_loses expects EchoR, got " ++ ty_to_string(other), loc); + (CBool, st3) + } + } + } else { + (CBool, st2) + } + } else if name == "echo_input" { + let st2 = expect_arity(st1, name, 1, arity, loc); + if arity >= 1 { + match arg_tys[0] { + CEcho(a, _b) => (a, st2), + CAny => (CAny, st2), + CEchoR(_a, _b) => { + let st3 = add_error(st2, + "echo_input: the input witness was erased by echo_to_residue — a residue is non-recoverable", + loc); + (CAny, st3) + }, + other => { + let st3 = add_error(st2, "echo_input expects Echo, got " ++ ty_to_string(other), loc); + (CAny, st3) + } + } + } else { + (CAny, st2) + } + } else if name == "echo_output" { + let st2 = expect_arity(st1, name, 1, arity, loc); + if arity >= 1 { + // The output is retained by both the Echo and its residue. + match arg_tys[0] { + CEcho(_a, b) => (b, st2), + CEchoR(_a, b) => (b, st2), + CAny => (CAny, st2), + other => { + let st3 = add_error(st2, "echo_output expects Echo or EchoR, got " ++ ty_to_string(other), loc); + (CAny, st3) + } + } + } else { + (CAny, st2) + } + } else { + (CAny, st1) + } +} + +// Infer all argument types, threading state. +fn infer_all(st: CheckState, scopes: [[(String, Binding)]], args: [Expr]) -> ([Ty], CheckState) { + let n = len(args); + let mut i = 0; + let mut tys = []; + let mut state = st; + while i < n { + let (t, s2) = infer(state, scopes, args[i]); + tys = tys ++ [t]; + state = s2; + i = i + 1; + } + (tys, state) +} + +fn expect_arity(st: CheckState, name: String, n: Int, arity: Int, loc: Location) -> CheckState { + if arity != n { + add_error(st, + name ++ " expects " ++ int_to_string(n) ++ " argument(s), got " ++ int_to_string(arity), + loc) + } else { + st + } +} + +// ============================================ +// Statement Type Checking +// +// `check_stmt` returns (CheckState, scopes): a `let`/`for` extends the +// current scope, so later sibling statements see the new binding. Nested +// blocks (`if`/`while`/`for` bodies) run in a pushed scope that is then +// discarded (the outer scope is unchanged), matching the .res's +// extendEnv-per-block. +// ============================================ + +fn check_stmt(st: CheckState, scopes: [[(String, Binding)]], stmt: Stmt) -> (CheckState, [[(String, Binding)]]) { + match stmt { + LetStmt(mutable_, name, type_, value, loc) => { + let (value_ty, st1) = infer(st, scopes, value); + match type_ { + Some(ann) => { + let ann_ty = type_expr_to_ty(ann); + let (ok, st2) = unify(st1, value_ty, ann_ty); + let st3 = if !ok { + add_error(st2, + "Type annotation mismatch for '" ++ name ++ "': declared " ++ ty_to_string(ann_ty) ++ ", value is " ++ ty_to_string(value_ty), + loc) + } else { + st2 + }; + (st3, bind(scopes, name, ann_ty, mutable_)) + }, + None => { + let resolved = resolve(st1, value_ty); + (st1, bind(scopes, name, resolved, mutable_)) + } + } + }, + + AssignStmt(target, value, loc) => + match target { + Ident(name, _id_loc) => + match lookup_var(scopes, name) { + Some(b) => + if b.mutable_ { + let (value_ty, st1) = infer(st, scopes, value); + let (ok, st2) = unify(st1, value_ty, b.ty); + let st3 = if !ok { + add_error(st2, + "Cannot assign " ++ ty_to_string(value_ty) ++ " to " ++ ty_to_string(b.ty) ++ " variable '" ++ name ++ "'", + loc) + } else { + st2 + }; + (st3, scopes) + } else { + let st1 = add_error(st, "Cannot reassign immutable variable '" ++ name ++ "'", loc); + let (_vt, st2) = infer(st1, scopes, value); + (st2, scopes) + }, + None => { + let st1 = add_error(st, "Undefined variable '" ++ name ++ "'", loc); + let (_vt, st2) = infer(st1, scopes, value); + (st2, scopes) + } + }, + _ => { + // Complex assignment targets (index, member) — infer both sides. + let (_tt, st1) = infer(st, scopes, target); + let (_vt, st2) = infer(st1, scopes, value); + (st2, scopes) + } + }, + + IfStmt(cond, then_, elseifs, else_, loc) => { + let (cond_ty, st1) = infer(st, scopes, cond); + let (ok_c, st2) = unify(st1, cond_ty, CBool); + let st3 = if !ok_c { + add_error(st2, "If condition must be Bool, got " ++ ty_to_string(cond_ty), loc) + } else { + st2 + }; + let then_scopes = extend(scopes); + let (st4, _ts) = check_block(st3, then_scopes, then_); + let st5 = check_elseifs(st4, scopes, elseifs, loc); + let st6 = match else_ { + Some(else_body) => { + let else_scopes = extend(scopes); + let (s, _es) = check_block(st5, else_scopes, else_body); + s + }, + None => st5 + }; + (st6, scopes) + }, + + WhileStmt(cond, body, loc) => { + let (cond_ty, st1) = infer(st, scopes, cond); + let (ok_c, st2) = unify(st1, cond_ty, CBool); + let st3 = if !ok_c { + add_error(st2, "While condition must be Bool, got " ++ ty_to_string(cond_ty), loc) + } else { + st2 + }; + let body_scopes = extend(scopes); + let (st4, _bs) = check_block(st3, body_scopes, body); + (st4, scopes) + }, + + ForStmt(var_name, iter, body, loc) => { + let (iter_ty, st1) = infer(st, scopes, iter); + let (elem_ty, st2) = match resolve(st1, iter_ty) { + CArray(inner) => (inner, st1), + CAny => (CAny, st1), + other => { + let s = add_error(st1, "For loop requires Array, got " ++ ty_to_string(other), loc); + (CAny, s) + } + }; + let body_scopes = bind(extend(scopes), var_name, elem_ty, false); + let (st3, _bs) = check_block(st2, body_scopes, body); + (st3, scopes) + }, + + ReturnStmt(value, _loc) => + match value { + Some(expr) => { + let (_t, st1) = infer(st, scopes, expr); + (st1, scopes) + }, + None => (st, scopes) + }, + + BreakStmt(_loc) => (st, scopes), + ContinueStmt(_loc) => (st, scopes), + + PrintStmt(_println, args, _loc) => { + // print/println accept any type. + let st1 = infer_for_effect(st, scopes, args); + (st1, scopes) + }, + + // Gutter blocks are intentional error injection zones — skip checking. + GutterBlock(_tokens, _recovered, _loc) => (st, scopes), + + ExprStmt(expr) => { + let (_t, st1) = infer(st, scopes, expr); + (st1, scopes) + } + } +} + +// Check a list of elseif (cond, body) pairs, each in its own pushed scope. +fn check_elseifs(st: CheckState, scopes: [[(String, Binding)]], elseifs: [(Expr, [Stmt])], loc: Location) -> CheckState { + let n = len(elseifs); + let mut i = 0; + let mut state = st; + while i < n { + let (eif_cond, eif_body) = elseifs[i]; + let (eif_ty, s1) = infer(state, scopes, eif_cond); + let (ok, s2) = unify(s1, eif_ty, CBool); + let s3 = if !ok { + add_error(s2, "Elseif condition must be Bool, got " ++ ty_to_string(eif_ty), loc) + } else { + s2 + }; + let eif_scopes = extend(scopes); + let (s4, _es) = check_block(s3, eif_scopes, eif_body); + state = s4; + i = i + 1; + } + state +} + +// Infer each expression purely for side effects (errors), discarding types. +fn infer_for_effect(st: CheckState, scopes: [[(String, Binding)]], exprs: [Expr]) -> CheckState { + let n = len(exprs); + let mut i = 0; + let mut state = st; + while i < n { + let (_t, s2) = infer(state, scopes, exprs[i]); + state = s2; + i = i + 1; + } + state +} + +// Check a block of statements in a (already-extended) scope. Bindings made +// by a statement are visible to its successors within the block. +fn check_block(st: CheckState, scopes: [[(String, Binding)]], stmts: [Stmt]) -> (CheckState, [[(String, Binding)]]) { + let n = len(stmts); + let mut i = 0; + let mut state = st; + let mut sc = scopes; + while i < n { + let (s2, sc2) = check_stmt(state, sc, stmts[i]); + state = s2; + sc = sc2; + i = i + 1; + } + (state, sc) +} + +// Check statements and return their (block) type: TyNil, as in the .res +// `checkStmts`. Used as a lambda block body. +fn check_stmts(st: CheckState, scopes: [[(String, Binding)]], stmts: [Stmt]) -> (Ty, CheckState) { + let (state, _sc) = check_block(st, scopes, stmts); + (CNil, state) +} + +// ============================================ +// Declaration Checking +// ============================================ + +fn check_decl(st: CheckState, scopes: [[(String, Binding)]], decl: Decl) -> (CheckState, [[(String, Binding)]]) { + match decl { + FunctionDecl(name, params, return_type, body, _loc) => { + let fn_scopes = extend(scopes); + let (param_tys, scopes2, st1) = bind_lambda_params(st, fn_scopes, params); + // Infer body in the function scope (errors surface; result discarded). + let (st2, _bs) = check_block(st1, scopes2, body); + let ret_ty = match return_type { + Some(ann) => type_expr_to_ty(ann), + None => CNil + }; + // Bind the function in the OUTER environment. + (st2, bind(scopes, name, CFun(param_tys, ret_ty), false)) + }, + + StructDecl(name, _fields, _loc) => + // Register the struct name as a type. + (st, bind(scopes, name, CStruct(name), false)), + + MainBlock(body, _loc) => { + let main_scopes = extend(scopes); + let (st1, _ms) = check_block(st, main_scopes, body); + (st1, scopes) + }, + + StmtDecl(stmt) => check_stmt(st, scopes, stmt) + } +} + +// ============================================ +// Program Entry Point +// ============================================ + +// Type-check a complete Error-Lang program. Returns the accumulated errors. +pub fn check_program(prog: Program) -> [TypeError] { + let st0 = make_state(); + let scopes0 = empty_scopes(); + let n = len(prog.declarations); + let mut i = 0; + let mut state = st0; + let mut scopes = scopes0; + while i < n { + let (s2, sc2) = check_decl(state, scopes, prog.declarations[i]); + state = s2; + scopes = sc2; + i = i + 1; + } + state.errors +} diff --git a/compiler/src/TypeSuperposition.affine b/compiler/src/TypeSuperposition.affine new file mode 100644 index 0000000..ed73b59 --- /dev/null +++ b/compiler/src/TypeSuperposition.affine @@ -0,0 +1,323 @@ +// SPDX-License-Identifier: MPL-2.0 +// TypeSuperposition.affine — quantum type system (ported from +// compiler/src/TypeSuperposition.res). +// +// "Variables exist in multiple types simultaneously until observed +// (printed, used in arithmetic, compared, …)." Observation collapses the +// superposition deterministically (context-hash + seed indexes the +// possible-types array). +// +// ── AffineScript port conventions ── +// * `open Types` -> `use Types::*;`. The `typeExpr` constructors +// TyInt/TyFloat/TyString/TyBool/TyArray/… come from the `Types` +// module unchanged (they are global constructors of `TypeExpr`), so +// the quantum machinery reuses them directly. +// * ReScript inline-record variant `Superposition({possibleTypes, seed, +// declaredAt})` -> positional `Superposition([TypeExpr], Int, +// Location)`. `quantumVariable` is a struct (no mutable fields); the +// "update on observe" rebuilds the whole record. +// * `detectSuperposition` mutates a `quantumVars` array in ReScript; here +// the recursion threads an accumulator `[(String, QuantumVariable)]` +// through fold/helper calls and returns the grown list (immutable). +// * `Array.length(possibleTypes)` -> `len(...)`; `arr->Option.getOr(d)` +// -> indexed read with an explicit bounds guard (`arr[i]` is the +// builtin indexer); `mod` -> `%`. +// +// ── Faithfulness notes: documented gaps / simplifications vs the .res ── +// * Collapse arithmetic, context hashing, and the per-literal +// possible-type sets are reproduced exactly (same constants, same +// order, same TyInt default). +// * `quantumType` field on `quantumVariable` is renamed to `qtype` +// (struct fields and a same-named outer type would otherwise read +// ambiguously); purely a local rename, semantics identical. +// * No `runTests`/Console code exists in the .res, so nothing is omitted. + +module TypeSuperposition; + +use prelude::*; +use string::{join}; +use Types::*; + +// ============================================ +// Quantum type states +// ============================================ + +// ReScript: Collapsed(typeExpr) | Superposition({possibleTypes, seed, declaredAt}) +pub enum QuantumType { + Collapsed(TypeExpr), + Superposition([TypeExpr], Int, Location) +} + +// Type observation contexts +pub enum ObservationContext { + Arithmetic, // Used in +, -, *, / + StringOp, // Used in ++, interpolation + Comparison, // Used in ==, !=, <, > + Print, // Used in println + Assignment, // Assigned to typed variable + FunctionArg // Passed to typed parameter +} + +// Variable with quantum type state. +// `qtype` mirrors the .res field `quantumType`; `observedAt` keeps the +// (location, context) pair as in the source. +pub struct QuantumVariable { + name: String, + qtype: QuantumType, + observedAt: Option<(Location, ObservationContext)>, + declaredAt: Location +} + +// ============================================ +// Possible types for a literal +// ============================================ + +// Determine possible types for a literal based on its form. +pub fn possible_types_for_literal(e: Expr) -> [TypeExpr] { + match e { + // Integer literal could be Int, Float (via coercion), or String. + IntLit(_n, _loc) => [TyInt, TyFloat, TyString], + // Float could be Float or String. + FloatLit(_f, _loc) => [TyFloat, TyString], + // String is always String (but might be coercible to Int/Float). + StringLit(_s, _loc) => [TyString, TyInt, TyFloat], + // Bool could be Bool, Int (0/1), or String. + BoolLit(_b, _loc) => [TyBool, TyInt, TyString], + // Unknown expression — full superposition. + _ => [TyInt, TyFloat, TyString, TyBool] + } +} + +// ============================================ +// Collapse +// ============================================ + +// Context -> deterministic hash contribution (mirrors the .res switch). +fn context_hash(context: ObservationContext) -> Int { + match context { + Arithmetic => 0, + StringOp => 1, + Comparison => 2, + Print => 3, + Assignment => 4, + FunctionArg => 5 + } +} + +// Collapse a quantum type given an observation context and seed. +// Collapse is deterministic but depends on context, seed, and the +// variable's own seed; the index wraps modulo the possible-type count. +pub fn collapse_type(qt: QuantumType, context: ObservationContext, seed: Int) -> TypeExpr { + match qt { + Collapsed(t) => t, // Already collapsed. + Superposition(possible_types, var_seed, _declared) => { + let ch = context_hash(context); + let n = len(possible_types); + // `mod 0` is undefined; the .res relies on non-empty possibleTypes. + // Guard defensively, defaulting to TyInt (the .res getOr default). + if n == 0 { + TyInt + } else { + let hash = (var_seed + seed + ch) % n; + if hash >= 0 && hash < n { + possible_types[hash] + } else { + TyInt + } + } + } + } +} + +// Check if an observation would cause a type mismatch. +pub fn would_cause_mismatch( + qt: QuantumType, + context: ObservationContext, + expected_type: TypeExpr, + seed: Int +) -> Bool { + let collapsed = collapse_type(qt, context, seed); + collapsed != expected_type +} + +// ============================================ +// Construction & observation +// ============================================ + +// Create a quantum variable from a let statement. +pub fn create_quantum_variable( + name: String, + value: Expr, + type_annotation: Option, + loc: Location, + seed: Int +) -> QuantumVariable { + let qt = match type_annotation { + // Explicit type annotation — collapsed from the start. + Some(annotated) => Collapsed(annotated), + // No type annotation — quantum superposition! + None => { + let possible_types = possible_types_for_literal(value); + Superposition(possible_types, seed, loc) + } + }; + #{ name: name, qtype: qt, observedAt: None, declaredAt: loc } +} + +// Observe a quantum variable (collapse its type). Returns the updated +// variable paired with the collapsed type (Some unless somehow absent). +pub fn observe_variable( + qvar: QuantumVariable, + context: ObservationContext, + observe_loc: Location, + seed: Int +) -> (QuantumVariable, Option) { + match qvar.qtype { + // Already collapsed — just return it. + Collapsed(t) => (qvar, Some(t)), + // COLLAPSE THE WAVEFUNCTION! + Superposition(possible_types, var_seed, declared) => { + let collapsed = collapse_type(Superposition(possible_types, var_seed, declared), context, seed); + let updated = #{ + name: qvar.name, + qtype: Collapsed(collapsed), + observedAt: Some((observe_loc, context)), + declaredAt: qvar.declaredAt + }; + (updated, Some(collapsed)) + } + } +} + +// ============================================ +// Superposition detection over a program +// ============================================ + +// Whether a quantum variable is (still) in superposition. +fn is_superposed(qt: QuantumType) -> Bool { + match qt { + Superposition(_p, _s, _d) => true, + _ => false + } +} + +// Analyze a single statement, growing the accumulator of detected quantum +// variables. Only `let` without a type annotation introduces a quantum +// variable; if/elseif/else bodies are descended into. +fn analyze_stmt(acc: [(String, QuantumVariable)], seed: Int, stmt: Stmt) -> [(String, QuantumVariable)] { + match stmt { + LetStmt(_mutable, name, type_, value, loc) => + match type_ { + None => { + let qvar = create_quantum_variable(name, value, type_, loc, seed); + if is_superposed(qvar.qtype) { + acc ++ [(name, qvar)] + } else { + acc + } + }, + Some(_t) => acc + }, + IfStmt(_cond, then_, elseifs, else_, _loc) => { + let a1 = analyze_stmts(acc, seed, then_); + let a2 = fold(elseifs, a1, |a, pair| { + let (_eif_cond, body) = pair; + analyze_stmts(a, seed, body) + }); + match else_ { + Some(body) => analyze_stmts(a2, seed, body), + None => a2 + } + }, + _ => acc + } +} + +fn analyze_stmts(acc: [(String, QuantumVariable)], seed: Int, stmts: [Stmt]) -> [(String, QuantumVariable)] { + fold(stmts, acc, |a, s| analyze_stmt(a, seed, s)) +} + +// Analyze a declaration: only main-block and function bodies contain +// statements to scan. +fn analyze_decl(acc: [(String, QuantumVariable)], seed: Int, decl: Decl) -> [(String, QuantumVariable)] { + match decl { + MainBlock(body, _loc) => analyze_stmts(acc, seed, body), + FunctionDecl(_name, _params, _ret, body, _loc) => analyze_stmts(acc, seed, body), + _ => acc + } +} + +// Detect type-superposition paradoxes in a program. +pub fn detect_superposition(prog: Program, seed: Int) -> [(String, QuantumVariable)] { + fold(prog.declarations, [], |acc, decl| analyze_decl(acc, seed, decl)) +} + +// ============================================ +// Display +// ============================================ + +// Render a single typeExpr to its short name (mirrors the inner switch). +fn type_name(t: TypeExpr) -> String { + match t { + TyInt => "Int", + TyFloat => "Float", + TyString => "String", + TyBool => "Bool", + _ => "Unknown" + } +} + +// Format a quantum type for display. +pub fn format_quantum_type(qt: QuantumType) -> String { + match qt { + Collapsed(t) => type_name(t), + Superposition(possible_types, _s, _d) => + join(map(possible_types, type_name), " | ") + } +} + +// ============================================ +// Prediction & visualization +// ============================================ + +// Predict the collapse target and supply a human reason for it. +pub fn predict_collapse( + qt: QuantumType, + context: ObservationContext, + seed: Int +) -> (TypeExpr, String) { + let collapsed = collapse_type(qt, context, seed); + let reason = match context { + Arithmetic => "Used in arithmetic operation", + StringOp => "Used in string operation", + Comparison => "Used in comparison", + Print => "Printed to console", + Assignment => "Assigned to typed variable", + FunctionArg => "Passed as function argument" + }; + (collapsed, reason) +} + +// Generate a visualization of a type collapse. +pub fn visualize_collapse( + var_name: String, + before: QuantumType, + after: TypeExpr, + context: ObservationContext +) -> String { + let before_str = format_quantum_type(before); + let after_str = format_quantum_type(Collapsed(after)); + let context_str = match context { + Arithmetic => "arithmetic", + StringOp => "string operation", + Comparison => "comparison", + Print => "println", + Assignment => "assignment", + FunctionArg => "function call" + }; + "\n🌀 TYPE COLLAPSE DETECTED\n\nVariable: " ++ var_name + ++ "\nBefore: " ++ before_str ++ " (superposition)" + ++ "\nAfter: " ++ after_str ++ " (collapsed)" + ++ "\nContext: " ++ context_str + ++ "\n\nThe act of observation collapsed the type!\n" +} From bfcee302892ebbcb5b79d2eb89b7d797aef5ab7a Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 24 Jun 2026 07:33:33 +0000 Subject: [PATCH 10/11] compiler: port Bytecode.res + Codegen.res + VM.res -> .affine Modules 7-9 (251 + 526 + 727 = 1504 lines), all verified green with affinescript check and the full 9-module harness. Completes the core compiler execution path. Bytecode.affine: 44 opcodes (stack/vars/arith-with-position-metadata/comparison/ control-flow/arrays/haptics/the five Echo ops/print/halt) + Value/Chunk/ BytecodeProgram + value_to_string/opcode_to_string. paradoxType constructors Px-prefixed to avoid colliding with Types' StabilityFactor::NullPropagation. Codegen.affine: AST -> bytecode. Immutable Compiler threaded; compile_expr returns the expression Location (so ExprStmt's trailing OpPop needs no re-traversal of a consumed node); jump backpatching via array rebuild (set_code_at). Echo lowering pushes args left-to-right then emits the dedicated OpEcho* op. VM.affine: stack interpreter. Immutable Vm; stack is a [Value] with an sp mirror; globals an assoc list. Runtime errors are values (Result + ExecResult = ExContinue|ExHalt|ExError), not exceptions -- error strings preserved verbatim. Echo runtime semantics byte-faithful: OpEchoToResidue debits echo_erase_cost (15.0), erases the witness, pushes VResidue (idempotent on a residue); OpEchoInput errors on a residue; OpEchoOutput returns the retained output. Pop-order, OpArray ordering, and OpSub/OpDiv operand order all match VM.res arm-for-arm. Documented faithful deviations: float pow uses repeated multiplication (integer- exact; the stdlib has no ln/log) -- the only numeric divergence; the .res "Not implemented" ops (OpLte/OpGte, OpCall) reproduced exactly; the Console *debug* disassembler omitted (its pure helpers kept); real VM print/IO kept. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0195yA45jSSP7YDPwJSpw4bM --- compiler/src/Bytecode.affine | 251 ++++++++++++ compiler/src/Codegen.affine | 526 +++++++++++++++++++++++++ compiler/src/VM.affine | 727 +++++++++++++++++++++++++++++++++++ 3 files changed, 1504 insertions(+) create mode 100644 compiler/src/Bytecode.affine create mode 100644 compiler/src/Codegen.affine create mode 100644 compiler/src/VM.affine diff --git a/compiler/src/Bytecode.affine b/compiler/src/Bytecode.affine new file mode 100644 index 0000000..1662268 --- /dev/null +++ b/compiler/src/Bytecode.affine @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: MPL-2.0 +// Bytecode.affine — bytecode instruction set for the Error-Lang VM +// (ported from compiler/src/Bytecode.res). +// +// Stack-based bytecode VM designed for: +// - Computational haptics integration +// - Positional semantics preservation +// - Paradox visualization +// - Educational transparency +// +// ── AffineScript port conventions ── +// * `open` becomes module imports. Bytecode defines the opcode/value/chunk +// types every sibling compiler back-end module reuses, so Codegen and VM +// `use Bytecode::*;` after `use Types::*;`. +// * AffineScript has no mutable struct fields and no record-spread; the +// types here are pure data (immutable), so the only adaptation is the +// ReScript inline-record variants and inline-record struct fields, which +// become positional / named-immutable respectively. +// * ReScript `value` variant `VFunction({arity, address, name})` -> the +// positional `VFunction(Int, Int, String)` (arity, address, name). +// `VEcho({input, output})` -> `VEcho(Value, Value)`; +// `VResidue({output})` -> `VResidue(Value)`. +// * `positionMetadata.operatorType` field is renamed `opType` to avoid the +// field/enum-name ambiguity the TypeSuperposition port flagged +// (`quantumType` -> `qtype`); purely a local rename, semantics identical. +// * Opcode/value constructor names do not collide with `Types` and are kept +// verbatim (OpPush/OpAdd/…, VInt/VFloat/…). +// +// ── Faithfulness notes: documented gaps / simplifications vs Bytecode.res ── +// * `paradoxType` constructors are PREFIXED `Px` (PxTypeSuperposition … +// PxMemoryPhantom). Reason: the unprefixed `NullPropagation` would COLLIDE +// with the global `StabilityFactor::NullPropagation(Int)` constructor in +// Types.affine (constructor names are global in AffineScript), exactly the +// situation TypeChecker.affine handled by C-prefixing its `ty` ctors. The +// payloads are opaque to every consumer (`OpInjectParadox(_)` discards +// them in Codegen and VM), so the spelling change is invisible to +// behaviour. The 1:1 mapping is: PxTypeSuperposition, PxPositionalSemantics, +// PxScopeLeakage, PxTemporalCorruption, PxArithmeticDrift, PxNullPropagation, +// PxContextCollapse, PxReservedWordRoulette, PxGlobalEntanglement, +// PxMemoryPhantom. +// * `disassemble` (a `Console.log`-based debug pretty-printer; not referenced +// by Codegen or the VM, not part of program behaviour) is OMITTED per the +// "omit Console/test-harness code" rule. The pure helpers it called — +// `value_to_string` and `opcode_to_string` — ARE ported: `value_to_string` +// is needed at runtime by the VM's positional-concatenation path, and +// `opcode_to_string` mirrors the .res disassembly text faithfully. +// * `Int.toString` / `Float.toString` / `String.padStart` -> `int_to_string` +// / `float_to_string` / (padStart only existed inside the omitted +// `disassemble`, so it is not needed). Array.map/joinWith -> map + join. +// * No `runTests` code exists in Bytecode.res, so nothing else is omitted. + +module Bytecode; + +use prelude::*; +use string::{join}; +use Types::{Location}; + +// ============================================ +// Operator position metadata +// ============================================ + +// Operators whose meaning flips with source position. +pub enum OperatorType { + PlusOp, // Can be addition OR concatenation + StarOp // Can be multiplication OR exponentiation +} + +// Position metadata for operators that change behavior based on location. +// (ReScript inline record under `OpAdd`/`OpMul`; here a named struct.) +pub struct PositionMetadata { + line: Int, + column: Int, + opType: OperatorType +} + +// Paradox kinds (Px-prefixed; see header faithfulness note re: collision with +// Types::StabilityFactor::NullPropagation). +pub enum ParadoxType { + PxTypeSuperposition, + PxPositionalSemantics, + PxScopeLeakage, + PxTemporalCorruption, + PxArithmeticDrift, + PxNullPropagation, + PxContextCollapse, + PxReservedWordRoulette, + PxGlobalEntanglement, + PxMemoryPhantom +} + +// ============================================ +// Runtime values +// ============================================ + +pub enum Value { + VInt(Int), + VFloat(Float), + VString(String), + VBool(Bool), + VNil, + VArray([Value]), + // ReScript VFunction({arity, address, name}) -> positional (arity, address, name). + VFunction(Int, Int, String), + // A single fiber witness: input `x` reached output `y` (one element of + // `Echo`). ReScript VEcho({input, output}) -> positional (input, output). + VEcho(Value, Value), + // The residue of an echo after erasure: the input witness is gone + // (non-recoverable), only the reached output is retained. This is + // `EchoR` at runtime. ReScript VResidue({output}) -> positional (output). + VResidue(Value) +} + +// ============================================ +// Bytecode instructions +// ============================================ + +pub enum Opcode { + // Stack manipulation + OpPush(Value), // Push constant onto stack + OpPop, // Pop top of stack + OpDup, // Duplicate top of stack + + // Variables + OpGetLocal(Int), // Get local variable + OpSetLocal(Int), // Set local variable + OpGetGlobal(String), // Get global variable + OpSetGlobal(String), // Set global variable + + // Arithmetic (with positional semantics metadata) + OpAdd(PositionMetadata), // Addition (or concatenation based on position!) + OpSub, // Subtraction + OpMul(PositionMetadata), // Multiplication (or exponentiation based on position!) + OpDiv, // Division + OpMod, // Modulo + OpNegate, // Unary negation + + // Comparison + OpEq, OpNeq, OpLt, OpGt, OpLte, OpGte, + + // Logical + OpAnd, OpOr, OpNot, + + // Control flow + OpJump(Int), // Unconditional jump + OpJumpIfFalse(Int), // Conditional jump + OpCall(Int), // Call function (arity) + OpReturn, // Return from function + + // Arrays + OpArray(Int), // Create array with N elements + OpIndex, // Array indexing + + // Special (computational haptics) + OpTrace(String), // Add trace point + OpCheckpoint(String), // Add checkpoint + OpUpdateStability, // Recalculate stability score + OpInjectParadox(ParadoxType), // Activate a paradox + + // Echo types (structured loss) + OpEcho, // Pop output then input, push a fiber witness VEcho(input, output) + OpEchoToResidue, // Pop an echo, push its residue (erases the witness; costs stability) + OpResidueStrictlyLoses, // Pop a value, push VBool(true) iff it is a residue + OpEchoInput, // Pop an echo, push its input witness (runtime error on a residue) + OpEchoOutput, // Pop an echo or residue, push its retained output + + // Debug + OpPrint(Bool), // Print (println if true) + OpHalt // Stop execution +} + +// ============================================ +// Compiled bytecode chunk + program +// ============================================ + +pub struct Chunk { + code: [Opcode], + constants: [Value], + // Source location mapping for error reporting. + locations: [Location] +} + +pub struct BytecodeProgram { + main: Chunk, + functions: [(String, Chunk)] +} + +// ============================================ +// Utilities +// ============================================ + +pub fn value_to_string(v: Value) -> String { + match v { + VInt(n) => int_to_string(n), + VFloat(f) => float_to_string(f), + VString(s) => "\"" ++ s ++ "\"", + VBool(b) => if b { "true" } else { "false" }, + VNil => "nil", + VArray(arr) => { + let items = join(map(arr, value_to_string), ", "); + "[" ++ items ++ "]" + }, + VFunction(_arity, _address, name) => "", + VEcho(input, output) => + "echo(" ++ value_to_string(input) ++ " ↦ " ++ value_to_string(output) ++ ")", + VResidue(output) => "residue(↦ " ++ value_to_string(output) ++ ")" + } +} + +pub fn opcode_to_string(op: Opcode) -> String { + match op { + OpPush(v) => "PUSH " ++ value_to_string(v), + OpPop => "POP", + OpDup => "DUP", + OpGetLocal(i) => "GET_LOCAL " ++ int_to_string(i), + OpSetLocal(i) => "SET_LOCAL " ++ int_to_string(i), + OpGetGlobal(name) => "GET_GLOBAL " ++ name, + OpSetGlobal(name) => "SET_GLOBAL " ++ name, + OpAdd(_p) => "ADD", + OpSub => "SUB", + OpMul(_p) => "MUL", + OpDiv => "DIV", + OpMod => "MOD", + OpNegate => "NEGATE", + OpEq => "EQ", + OpNeq => "NEQ", + OpLt => "LT", + OpGt => "GT", + OpLte => "LTE", + OpGte => "GTE", + OpAnd => "AND", + OpOr => "OR", + OpNot => "NOT", + OpJump(offset) => "JUMP " ++ int_to_string(offset), + OpJumpIfFalse(offset) => "JUMP_IF_FALSE " ++ int_to_string(offset), + OpCall(arity) => "CALL " ++ int_to_string(arity), + OpReturn => "RETURN", + OpArray(size) => "ARRAY " ++ int_to_string(size), + OpIndex => "INDEX", + OpTrace(msg) => "TRACE \"" ++ msg ++ "\"", + OpCheckpoint(name) => "CHECKPOINT \"" ++ name ++ "\"", + OpUpdateStability => "UPDATE_STABILITY", + OpInjectParadox(_p) => "INJECT_PARADOX", + OpEcho => "ECHO", + OpEchoToResidue => "ECHO_TO_RESIDUE", + OpResidueStrictlyLoses => "RESIDUE_STRICTLY_LOSES", + OpEchoInput => "ECHO_INPUT", + OpEchoOutput => "ECHO_OUTPUT", + OpPrint(is_println) => if is_println { "PRINTLN" } else { "PRINT" }, + OpHalt => "HALT" + } +} diff --git a/compiler/src/Codegen.affine b/compiler/src/Codegen.affine new file mode 100644 index 0000000..f7e0070 --- /dev/null +++ b/compiler/src/Codegen.affine @@ -0,0 +1,526 @@ +// SPDX-License-Identifier: MPL-2.0 +// Codegen.affine — AST-to-bytecode compiler for Error-Lang +// (ported from compiler/src/Codegen.res). +// +// Compiles parsed AST into bytecode while preserving: +// - Positional semantics metadata (line/column attached to OpAdd/OpMul) +// - Computational haptics trace points (OpCheckpoint markers) +// - Paradox injection points +// +// ── AffineScript port: state model ── +// The ReScript compiler is a `mutable` struct whose `emit`/`addConstant`/ +// `addLocal` push into `code`/`constants`/`locations`/`locals` in place, and +// whose jump-patching writes `c.code[idx] = …` directly. AffineScript has no +// mutable struct fields, no record-spread, and no in-place array assignment, +// so: +// * `Compiler` is an immutable struct; every "mutating" helper (`emit`, +// `add_constant`, `add_local`, `set_code_at`) TAKES a Compiler and RETURNS +// a new one via full-record reconstruction. +// * The ReScript `compileExpr : compiler -> expr -> unit` (mutates `c`) +// becomes `compile_expr : (Compiler, Expr) -> (Compiler, Location)`: it +// threads the rebuilt compiler AND returns the expression's own location. +// Returning the location (a copyable summary) replaces the .res +// `expr->getLocation` re-traversal — under affine ownership the `expr` is +// consumed by `compile_expr` and cannot be inspected again afterwards (the +// same restructuring TypeChecker.affine used to retire its `exprLoc` +// helper). `compile_stmt`/`compile_decl`/`compile` thread the Compiler the +// same way. +// * In-place jump backpatching `c.code[elseJump] = OpJump(off)` is rendered +// by `set_code_at` (rebuild the code array with the patched opcode at the +// index), mirroring Parser.affine's `set_token_at`. +// +// Constructors come from `Types` (Expr/Stmt/Decl/BinaryOp/UnaryOp/…) and +// `Bytecode` (Opcode/Value/PositionMetadata/OperatorType). The Bytecode +// `paradoxType` rename (Px-prefix) and `operatorType`-field rename +// (`opType`) carry over; Codegen does not construct paradoxes, but it does +// build `PositionMetadata { line, column, opType: PlusOp/StarOp }`. +// +// ── Faithfulness notes: documented gaps / simplifications vs Codegen.res ── +// * The lowering is reproduced arm-for-arm: same opcodes emitted in the same +// order for every Expr/Stmt/Decl, the same "not implemented yet" fallbacks +// (Member/Lambda/non-echo Call -> OpPush(VNil); BAnd/BOr/BXor/Shl/Shr -> +// OpPush(VNil); BNot/array-assign -> no-op; struct -> OpCheckpoint), the +// same ternary/if/while jump backpatching offsets (`elseStart - jump - 1` +// etc.), and the same Echo-builtin dispatch (echo / echo_to_residue / +// residue_strictly_loses / echo_input / echo_output push their args +// left-to-right then emit the dedicated opcode). +// * The five Echo builtins lower EXACTLY as the .res: arguments compiled +// left-to-right (so for `echo(x, y)` the output `y` lands on top, which is +// what OpEcho pops first), then the matching OpEcho* opcode is emitted. +// * `addConstant` is ported verbatim even though — exactly as in Codegen.res +// — nothing calls it (constants are inlined into `OpPush(VInt(n))` etc.). +// Kept for parity; `constants` stays empty, matching the .res output. +// * ReScript `try/catch` around `compile` wrapped exceptions into +// `Error(...)`. The AffineScript port performs no throwing operations +// (all array access is via safe rebuild/index), so `compile` returns +// `Result` and always yields `Ok(...)` — the +// same observable result the .res produced on every input that did not +// throw. There is no exception machinery to translate. +// * `Array.forEach`/`Array.push`/`Array.length` -> explicit threading folds +// (`compile_each_expr`, `compile_each_stmt`, `emit_elseifs`) and the +// `len`/`++` builtins. `expr->getLocation` standalone helper -> the +// location returned by `compile_expr` (see state model above); a tiny +// read-only `get_location` is still provided for the few call sites that +// need a location WITHOUT compiling (none remain, so it is omitted). +// * No `runTests`/Console code exists in Codegen.res, so nothing is omitted. + +module Codegen; + +use prelude::*; +use Types::*; +use Bytecode::*; + +// ============================================ +// Compiler state (immutable; threaded) +// ============================================ + +pub struct Compiler { + code: [Opcode], + constants: [Value], + locations: [Location], + localCount: Int, + locals: [String] +} + +pub fn make() -> Compiler { + #{ code: [], constants: [], locations: [], localCount: 0, locals: [] } +} + +// Emit a bytecode instruction (append op + location). Returns a new Compiler. +fn emit(c: Compiler, op: Opcode, loc: Location) -> Compiler { + #{ code: c.code ++ [op], constants: c.constants, + locations: c.locations ++ [loc], localCount: c.localCount, locals: c.locals } +} + +// Add a constant to the constant pool, returning (index, new Compiler). +// (Parity helper — unused, like Codegen.res's addConstant.) +fn add_constant(c: Compiler, v: Value) -> (Int, Compiler) { + let c2 = #{ code: c.code, constants: c.constants ++ [v], + locations: c.locations, localCount: c.localCount, locals: c.locals }; + (len(c2.constants) - 1, c2) +} + +// Resolve a local variable to its slot index (first match), read-only. +fn resolve_local(c: Compiler, name: String) -> Option { + let mut out = None; + let mut found = false; + let mut i = 0; + let n = len(c.locals); + while i < n { + if !found && c.locals[i] == name { + out = Some(i); + found = true; + } + i = i + 1; + } + out +} + +// Add a local variable, returning (slot index, new Compiler). +fn add_local(c: Compiler, name: String) -> (Int, Compiler) { + let c2 = #{ code: c.code, constants: c.constants, locations: c.locations, + localCount: c.localCount + 1, locals: c.locals ++ [name] }; + (c2.localCount - 1, c2) +} + +// Backpatch: replace code[idx] with `op` (rebuild array). Mirrors the .res +// in-place `c.code[idx] = op`. See Parser.affine `set_token_at`. +fn set_code_at(c: Compiler, idx: Int, op: Opcode) -> Compiler { + let mut out = []; + let mut i = 0; + let n = len(c.code); + while i < n { + if i == idx { out = out ++ [op]; } else { out = out ++ [c.code[i]]; } + i = i + 1; + } + #{ code: out, constants: c.constants, locations: c.locations, + localCount: c.localCount, locals: c.locals } +} + +// ============================================ +// Expression compilation +// +// `compile_expr` emits the ops for `expr` and returns (new Compiler, the +// expression's own Location). Returning the location lets `ExprStmt` emit the +// trailing OpPop without re-traversing the (already consumed) node. +// ============================================ + +fn compile_expr(c: Compiler, expr: Expr) -> (Compiler, Location) { + match expr { + IntLit(n, loc) => (emit(c, OpPush(VInt(n)), loc), loc), + FloatLit(f, loc) => (emit(c, OpPush(VFloat(f)), loc), loc), + StringLit(s, loc) => (emit(c, OpPush(VString(s)), loc), loc), + BoolLit(b, loc) => (emit(c, OpPush(VBool(b)), loc), loc), + NilLit(loc) => (emit(c, OpPush(VNil), loc), loc), + + Ident(name, loc) => { + // Try local first, then global. + let c2 = match resolve_local(c, name) { + Some(index) => emit(c, OpGetLocal(index), loc), + None => emit(c, OpGetGlobal(name), loc) + }; + (c2, loc) + }, + + Array(elements, loc) => { + // Compile each element, then create an array with N elements. + let nelem = len(elements); + let c1 = compile_each_expr(c, elements); + (emit(c1, OpArray(nelem), loc), loc) + }, + + Binary(left, op, right, loc) => { + // Compile operands left-to-right. + let (c1, _l1) = compile_expr(c, left); + let (c2, _l2) = compile_expr(c1, right); + // Emit operator, carrying positional metadata for +/*. + let op_code = match op { + Add => { + let pos = #{ line: loc.start.line, column: loc.start.column, opType: PlusOp }; + OpAdd(pos) + }, + Sub => OpSub, + Mul => { + let pos = #{ line: loc.start.line, column: loc.start.column, opType: StarOp }; + OpMul(pos) + }, + Div => OpDiv, + Mod => OpMod, + Eq => OpEq, + Neq => OpNeq, + Lt => OpLt, + Gt => OpGt, + Lte => OpLte, + Gte => OpGte, + LAnd => OpAnd, + LOr => OpOr, + // Bitwise ops not implemented yet -> push nil (matches .res). + BAnd => OpPush(VNil), + BOr => OpPush(VNil), + BXor => OpPush(VNil), + Shl => OpPush(VNil), + Shr => OpPush(VNil) + }; + (emit(c2, op_code, loc), loc) + }, + + Unary(op, operand, loc) => { + let (c1, _l1) = compile_expr(c, operand); + let c2 = match op { + Neg => emit(c1, OpNegate, loc), + LNot => emit(c1, OpNot, loc), + BNot => c1 // Not implemented (no-op, as in .res). + }; + (c2, loc) + }, + + Call(callee, args, loc) => { + // Echo builtins compile to dedicated opcodes (the VM has no general call + // yet). Arguments are pushed left-to-right; for `echo(x, y)` the output + // `y` is on top, which is exactly what OpEcho pops first. + let c2 = match callee { + Ident(name, _id_loc) => + if name == "echo" { + let c1 = compile_each_expr(c, args); + emit(c1, OpEcho, loc) + } else if name == "echo_to_residue" { + let c1 = compile_each_expr(c, args); + emit(c1, OpEchoToResidue, loc) + } else if name == "residue_strictly_loses" { + let c1 = compile_each_expr(c, args); + emit(c1, OpResidueStrictlyLoses, loc) + } else if name == "echo_input" { + let c1 = compile_each_expr(c, args); + emit(c1, OpEchoInput, loc) + } else if name == "echo_output" { + let c1 = compile_each_expr(c, args); + emit(c1, OpEchoOutput, loc) + } else { + // Other function calls not fully implemented yet. + emit(c, OpPush(VNil), loc) + }, + _ => emit(c, OpPush(VNil), loc) + }; + (c2, loc) + }, + + Index(base, index, loc) => { + let (c1, _l1) = compile_expr(c, base); + let (c2, _l2) = compile_expr(c1, index); + (emit(c2, OpIndex, loc), loc) + }, + + Member(base, _field, loc) => { + // Member access not implemented -> push nil. (The base is NOT compiled + // in Codegen.res's Member arm; it is discarded with `_obj`.) + (emit(c, OpPush(VNil), loc), loc) + }, + + Ternary(cond, then_, else_, loc) => { + // Compile condition. + let (c1, _lc) = compile_expr(c, cond); + + // Jump-if-false to else branch (offset patched later). + let else_jump = len(c1.code); + let c2 = emit(c1, OpJumpIfFalse(0), loc); + + // Compile then branch. + let (c3, _lt) = compile_expr(c2, then_); + + // Jump over else branch (patched later). + let end_jump = len(c3.code); + let c4 = emit(c3, OpJump(0), loc); + + // Patch the else jump. + let else_start = len(c4.code); + let c5 = set_code_at(c4, else_jump, OpJumpIfFalse(else_start - else_jump - 1)); + + // Compile else branch. + let (c6, _le) = compile_expr(c5, else_); + + // Patch the end jump. + let end_pos = len(c6.code); + let c7 = set_code_at(c6, end_jump, OpJump(end_pos - end_jump - 1)); + (c7, loc) + }, + + Lambda(_params, _ret, _body, loc) => { + // Lambdas not implemented -> push nil. + (emit(c, OpPush(VNil), loc), loc) + } + } +} + +// Compile a sequence of expressions left-to-right, threading the Compiler. +fn compile_each_expr(c: Compiler, exprs: [Expr]) -> Compiler { + let n = len(exprs); + let mut i = 0; + let mut cc = c; + while i < n { + let (c2, _loc) = compile_expr(cc, exprs[i]); + cc = c2; + i = i + 1; + } + cc +} + +// ============================================ +// Statement compilation +// ============================================ + +fn compile_stmt(c: Compiler, stmt: Stmt) -> Compiler { + match stmt { + LetStmt(_mutable, name, _type, value, loc) => { + // Compile initializer, then bind a fresh local and store into it. + let (c1, _lv) = compile_expr(c, value); + let (index, c2) = add_local(c1, name); + emit(c2, OpSetLocal(index), loc) + }, + + AssignStmt(target, value, loc) => { + // Compile the value first. + let (c1, _lv) = compile_expr(c, value); + match target { + Ident(name, _id_loc) => + match resolve_local(c1, name) { + Some(index) => emit(c1, OpSetLocal(index), loc), + None => emit(c1, OpSetGlobal(name), loc) + }, + Index(base, index, _il) => { + // Array assignment not fully implemented: compile both sides. + let (c2, _lb) = compile_expr(c1, base); + let (c3, _li) = compile_expr(c2, index); + c3 + }, + _ => c1 // Other assignment targets not implemented. + } + }, + + IfStmt(cond, then_, elseifs, else_, loc) => { + // Compile condition. + let (c1, _lc) = compile_expr(c, cond); + + // Jump-if-false to elseif/else (patched later). + let then_jump = len(c1.code); + let c2 = emit(c1, OpJumpIfFalse(0), loc); + + // Compile then branch. + let c3 = compile_each_stmt(c2, then_); + + // Jump to end (patched later). + let end_jump = len(c3.code); + let c4 = emit(c3, OpJump(0), loc); + + // Patch the then jump. + let else_start = len(c4.code); + let c5 = set_code_at(c4, then_jump, OpJumpIfFalse(else_start - then_jump - 1)); + + // Compile elseifs (simplified — not handling multiple elseifs, as .res). + let c6 = emit_elseifs(c5, elseifs, loc); + + // Compile else branch. + let c7 = match else_ { + Some(body) => compile_each_stmt(c6, body), + None => c6 + }; + + // Patch the end jump. + let end_pos = len(c7.code); + set_code_at(c7, end_jump, OpJump(end_pos - end_jump - 1)) + }, + + WhileStmt(cond, body, loc) => { + let loop_start = len(c.code); + + // Compile condition. + let (c1, _lc) = compile_expr(c, cond); + + // Jump-if-false to end (patched later). + let exit_jump = len(c1.code); + let c2 = emit(c1, OpJumpIfFalse(0), loc); + + // Compile body. + let c3 = compile_each_stmt(c2, body); + + // Jump back to loop start. + let loop_end = len(c3.code); + let c4 = emit(c3, OpJump(loop_start - loop_end - 1), loc); + + // Patch the exit jump. + let end_pos = len(c4.code); + set_code_at(c4, exit_jump, OpJumpIfFalse(end_pos - exit_jump - 1)) + }, + + ForStmt(var_name, iter, body, loc) => { + // For loops over arrays (simplified placeholder, as .res). + let (c1, _li) = compile_expr(c, iter); + let (var_index, c2) = add_local(c1, var_name); + let c3 = emit(c2, OpSetLocal(var_index), loc); + compile_each_stmt(c3, body) + }, + + ReturnStmt(value, loc) => + match value { + Some(expr) => { + let (c1, _le) = compile_expr(c, expr); + emit(c1, OpReturn, loc) + }, + None => { + let c1 = emit(c, OpPush(VNil), loc); + emit(c1, OpReturn, loc) + } + }, + + BreakStmt(loc) => emit(c, OpJump(0), loc), // Break not fully implemented. + ContinueStmt(loc) => emit(c, OpJump(0), loc), // Continue not fully implemented. + + PrintStmt(is_println, args, loc) => { + // Compile each argument and print it. + let n = len(args); + let mut i = 0; + let mut cc = c; + while i < n { + let (c1, _la) = compile_expr(cc, args[i]); + cc = emit(c1, OpPrint(is_println), loc); + i = i + 1; + } + cc + }, + + GutterBlock(_tokens, _recovered, loc) => { + // Gutter blocks are error recovery — emit a checkpoint. + emit(c, OpCheckpoint("gutter"), loc) + }, + + ExprStmt(expr) => { + // Compile the expression, then pop its value. The location returned by + // compile_expr stands in for the .res `expr->getLocation` (the node is + // already consumed). + let (c1, loc) = compile_expr(c, expr); + emit(c1, OpPop, loc) + } + } +} + +// Compile a sequence of statements, threading the Compiler. +fn compile_each_stmt(c: Compiler, stmts: [Stmt]) -> Compiler { + let n = len(stmts); + let mut i = 0; + let mut cc = c; + while i < n { + cc = compile_stmt(cc, stmts[i]); + i = i + 1; + } + cc +} + +// Compile elseif (cond, body) pairs (each self-patching its own skip jump). +fn emit_elseifs(c: Compiler, elseifs: [(Expr, [Stmt])], loc: Location) -> Compiler { + let n = len(elseifs); + let mut i = 0; + let mut cc = c; + while i < n { + let (cond, body) = elseifs[i]; + let (c1, _lc) = compile_expr(cc, cond); + let elseif_jump = len(c1.code); + let c2 = emit(c1, OpJumpIfFalse(0), loc); + let c3 = compile_each_stmt(c2, body); + let elseif_end = len(c3.code); + cc = set_code_at(c3, elseif_jump, OpJumpIfFalse(elseif_end - elseif_jump - 1)); + i = i + 1; + } + cc +} + +// ============================================ +// Declaration compilation +// ============================================ + +fn compile_decl(c: Compiler, decl: Decl) -> Compiler { + match decl { + FunctionDecl(_name, _params, _ret, body, loc) => { + // Functions not fully implemented — checkpoint then inline the body. + let c1 = emit(c, OpCheckpoint("function"), loc); + compile_each_stmt(c1, body) + }, + + StructDecl(_name, _fields, loc) => { + // Structs not implemented — emit a checkpoint. + emit(c, OpCheckpoint("struct"), loc) + }, + + MainBlock(body, loc) => { + let c1 = emit(c, OpCheckpoint("main_start"), loc); + let c2 = compile_each_stmt(c1, body); + emit(c2, OpCheckpoint("main_end"), loc) + }, + + StmtDecl(stmt) => compile_stmt(c, stmt) + } +} + +// ============================================ +// Program compilation +// ============================================ + +pub fn compile(program: Program) -> Result { + let c0 = make(); + + // Compile all declarations. + let n = len(program.declarations); + let mut i = 0; + let mut c = c0; + while i < n { + c = compile_decl(c, program.declarations[i]); + i = i + 1; + } + + // Emit halt. + let c_final = emit(c, OpHalt, program.loc); + + // Build the chunk and program. + let chunk = #{ code: c_final.code, constants: c_final.constants, + locations: c_final.locations }; + let bytecode_program = #{ main: chunk, functions: [] }; + Ok(bytecode_program) +} diff --git a/compiler/src/VM.affine b/compiler/src/VM.affine new file mode 100644 index 0000000..06635df --- /dev/null +++ b/compiler/src/VM.affine @@ -0,0 +1,727 @@ +// SPDX-License-Identifier: MPL-2.0 +// VM.affine — bytecode virtual machine for Error-Lang +// (ported from compiler/src/VM.res). +// +// Stack-based interpreter with computational haptics integration. +// +// ── AffineScript port: state model ── +// The ReScript VM is a `mutable` struct whose `push`/`pop`/`peek` mutate the +// `stack` array and `sp`/`ip`/`fp` in place, threads variables through a +// `Map.t`, and signals runtime failure by `raise(RuntimeError(msg))` caught in +// `run`. AffineScript has no mutable struct fields, no in-place array writes, +// and no ReScript-style exceptions, so: +// * `Vm` is an immutable struct; the stack is a `[Value]` rebuilt each step +// and `sp` is kept as an explicit mirror of `len(stack)` (push appends + +// bumps sp; pop truncates the last element + drops sp). This differs from +// the .res representation (a grow-only array + an independent `sp` that can +// trail `length`) ONLY in the pathological stale-tail case, which the +// codegen — emitting balanced push/pop — never produces; for all bytecode +// Codegen.affine generates the two representations are observationally +// identical. `OpSetLocal`'s mid-stack write `stack[base+i] = v` is rendered +// by `set_stack_at` (rebuild with one slot replaced; cf. Parser +// `set_token_at`). +// * `globals: Map.t` -> an assoc list `[(String, Value)]` with +// linear get/set (last-write-wins via prepend), the same purely-functional +// style TypeChecker.affine used for its Int-keyed substitution map. +// * RUNTIME ERRORS: instead of `raise(RuntimeError(msg))`, fallible helpers +// return `Result<…, String>` and `execute_instruction` returns an +// `ExecResult` ( ExContinue(Vm) | ExHalt(Vm) | ExError(String) ). `run` +// turns `ExError(m)` into `Error("Runtime error: " ++ m)` — exactly the +// message the .res `catch RuntimeError(msg)` produced. The error STRINGS +// are preserved verbatim (e.g. "Stack underflow", "Cannot add these +// types", "echo_input: the witness was erased …"). +// * Value/opcode/chunk types and `value_to_string` come from `Bytecode` +// (`use Bytecode::*;`); the paradox payload is opaque here +// (`OpInjectParadox(_)`), so the Px-prefix rename is invisible. +// +// ── Echo semantics (kept byte-faithful) ── +// OpEcho pops output then input and pushes VEcho(input, output). OpEchoToResidue +// pops an echo, debits `echo_erase_cost` stability, records a "witness erased" +// trace, and pushes VResidue(output); a residue is idempotent (no further +// loss). OpResidueStrictlyLoses pushes VBool(true) for a residue, false for an +// echo. OpEchoInput returns the input witness (runtime error on a residue: +// "echo_input: the witness was erased — a residue is non-recoverable"). +// OpEchoOutput returns the retained output of an echo OR a residue. These +// mirror VM.res arm-for-arm. +// +// ── Faithfulness notes: documented gaps / simplifications vs VM.res ── +// * `Math.pow` (JS) has no AffineScript-stdlib equivalent for Float^Float +// (the stdlib exposes `math::pow : (Int, Int) -> Int`, plus `exp`/`sqrt`, +// but no `ln`/`log`). The StarOp-exponentiation path is therefore realized +// by `math_pow(base, exp)`: a faithful repeated-multiplication power over +// the INTEGER part of the exponent (negative exponents -> reciprocal). For +// integer exponents — the case the positional rule actually produces from +// integer operands — this is exact and identical to `Math.pow`; a +// FRACTIONAL exponent is approximated by truncation (its fractional part is +// dropped) because no logarithm primitive is available. This is the only +// numeric divergence from the .res. +// * `Console.log` for `OpPrint`, `OpCheckpoint`, and the `debug` instruction +// trace are REAL VM behaviour (program output + the haptics checkpoint +// markers + the opt-in debug stream), not a test harness, so they are kept +// — rendered with the `println` builtin. The checkpoint/print/debug text is +// reproduced (incl. the "📍 Checkpoint: …" and "IP: … " formats). +// * `mod(a,b)` -> the `%` operator; `Int.toFloat` -> the `float(...)` cast; +// `Float.toString` -> `float_to_string`; `Map.get/set` -> assoc-list +// get/set; `Array.reverse` (OpArray) -> an explicit reversing pop loop that +// already yields elements in stack order (so no separate reverse needed — +// see OpArray). `peek`/`pop` bounds checks and their error strings are +// preserved. +// * `OpLte`/`OpGte` raise "Not implemented" in the .res; reproduced exactly +// (ExError "Not implemented"). `OpCall` raises "Function calls not yet +// implemented"; reproduced. `OpReturn` with no frames halts; reproduced. +// * No `runTests`/Console *test* code exists in VM.res, so nothing is omitted. + +module VM; + +use prelude::*; +use Bytecode::*; + +// Stability debited when an Echo is erased to its residue. Echo-Lang's conceit +// is that loss can be *structured* but is never free: erasing the witness is a +// thermodynamic act (a Landauer-style cost; cf. echo-types `fiber_erasure_bound`, +// k·T·⌊log₂ n⌋). Modelled here as a fixed symbolic debit until fibre cardinality +// is computable at runtime. +pub fn echo_erase_cost() -> Float { 15.0 } + +// ============================================ +// VM state (immutable; threaded) +// ============================================ + +pub struct CallFrame { + functionName: String, + returnAddress: Int, + localBase: Int // Base of local variables in the stack. +} + +pub struct Vm { + // Execution state. + stack: [Value], + sp: Int, // Stack pointer (mirror of len(stack)). + ip: Int, // Instruction pointer. + frames: [CallFrame], + fp: Int, // Frame pointer. + + // Variables (assoc list; the .res Map.t). + globals: [(String, Value)], + + // Program. + chunk: Chunk, + + // Computational haptics state. + stabilityScore: Float, + activeParadoxes: Int, // Bitmask. + traceHistory: [(String, Value)], + + // Debug. + debug: Bool +} + +pub fn make(chunk: Chunk) -> Vm { + #{ stack: [], sp: 0, ip: 0, frames: [], fp: 0, globals: [], + chunk: chunk, stabilityScore: 100.0, activeParadoxes: 0, + traceHistory: [], debug: false } +} + +// ============================================ +// Float power (see header faithfulness note: replaces JS Math.pow). +// ============================================ + +fn math_pow(base: Float, ex: Float) -> Float { + let n = trunc(ex); + let neg = n < 0; + let mut k = if neg { -n } else { n }; + let mut acc = 1.0; + while k > 0 { + acc = acc * base; + k = k - 1; + } + if neg { 1.0 / acc } else { acc } +} + +// ============================================ +// Stack operations (immutable; return rebuilt Vm / values) +// ============================================ + +// Push appends the value and bumps sp. +fn push(vm: Vm, value: Value) -> Vm { + #{ stack: vm.stack ++ [value], sp: vm.sp + 1, ip: vm.ip, + frames: vm.frames, fp: vm.fp, globals: vm.globals, chunk: vm.chunk, + stabilityScore: vm.stabilityScore, activeParadoxes: vm.activeParadoxes, + traceHistory: vm.traceHistory, debug: vm.debug } +} + +// Replace stack[idx] with `value` (rebuild). Used by OpSetLocal's mid-stack +// write; does not change sp. +fn set_stack_at(vm: Vm, idx: Int, value: Value) -> Vm { + let mut out = []; + let mut i = 0; + let n = len(vm.stack); + while i < n { + if i == idx { out = out ++ [value]; } else { out = out ++ [vm.stack[i]]; } + i = i + 1; + } + #{ stack: out, sp: vm.sp, ip: vm.ip, frames: vm.frames, fp: vm.fp, + globals: vm.globals, chunk: vm.chunk, stabilityScore: vm.stabilityScore, + activeParadoxes: vm.activeParadoxes, traceHistory: vm.traceHistory, + debug: vm.debug } +} + +// Pop: error on underflow; otherwise truncate the last element and drop sp. +fn pop(vm: Vm) -> Result<(Value, Vm), String> { + if vm.sp == 0 { + Err("Stack underflow") + } else { + let top_idx = vm.sp - 1; + let value = vm.stack[top_idx]; + let mut out = []; + let mut i = 0; + while i < top_idx { + out = out ++ [vm.stack[i]]; + i = i + 1; + } + let vm2 = #{ stack: out, sp: vm.sp - 1, ip: vm.ip, frames: vm.frames, + fp: vm.fp, globals: vm.globals, chunk: vm.chunk, + stabilityScore: vm.stabilityScore, + activeParadoxes: vm.activeParadoxes, + traceHistory: vm.traceHistory, debug: vm.debug }; + Ok((value, vm2)) + } +} + +// Pop two values (right first, then left — matching the .res order). +fn pop2(vm: Vm) -> Result<(Value, Value, Vm), String> { + match pop(vm) { + Ok((right, vm1)) => + match pop(vm1) { + Ok((left, vm2)) => Ok((right, left, vm2)), + Err(e) => Err(e) + }, + Err(e) => Err(e) + } +} + +fn peek(vm: Vm, offset: Int) -> Result { + let index = vm.sp - 1 - offset; + if index < 0 || index >= vm.sp { + Err("Stack peek out of bounds") + } else { + Ok(vm.stack[index]) + } +} + +// ---- globals (assoc list; last write wins via prepend) ---- + +fn globals_get(vm: Vm, name: String) -> Option { + let mut out = None; + let mut found = false; + for pair in vm.globals { + let (k, v) = pair; + if !found && k == name { + out = Some(v); + found = true; + } + } + out +} + +fn globals_set(vm: Vm, name: String, value: Value) -> Vm { + let mut rest = []; + for pair in vm.globals { + let (k, v) = pair; + if k != name { + rest = rest ++ [(k, v)]; + } + } + #{ stack: vm.stack, sp: vm.sp, ip: vm.ip, frames: vm.frames, fp: vm.fp, + globals: [(name, value)] ++ rest, chunk: vm.chunk, + stabilityScore: vm.stabilityScore, activeParadoxes: vm.activeParadoxes, + traceHistory: vm.traceHistory, debug: vm.debug } +} + +// ---- single-field rebuilders (used by handlers that touch one field) ---- + +fn with_ip(vm: Vm, new_ip: Int) -> Vm { + #{ stack: vm.stack, sp: vm.sp, ip: new_ip, frames: vm.frames, fp: vm.fp, + globals: vm.globals, chunk: vm.chunk, stabilityScore: vm.stabilityScore, + activeParadoxes: vm.activeParadoxes, traceHistory: vm.traceHistory, + debug: vm.debug } +} + +fn with_stability(vm: Vm, score: Float) -> Vm { + #{ stack: vm.stack, sp: vm.sp, ip: vm.ip, frames: vm.frames, fp: vm.fp, + globals: vm.globals, chunk: vm.chunk, stabilityScore: score, + activeParadoxes: vm.activeParadoxes, traceHistory: vm.traceHistory, + debug: vm.debug } +} + +fn push_trace(vm: Vm, msg: String, value: Value) -> Vm { + #{ stack: vm.stack, sp: vm.sp, ip: vm.ip, frames: vm.frames, fp: vm.fp, + globals: vm.globals, chunk: vm.chunk, stabilityScore: vm.stabilityScore, + activeParadoxes: vm.activeParadoxes, + traceHistory: vm.traceHistory ++ [(msg, value)], debug: vm.debug } +} + +// ============================================ +// Positional operator resolution +// PlusOp: even column = addition, odd column = concatenation +// StarOp: column % 3 == 0 = multiplication, else = exponentiation +// ============================================ + +fn resolve_positional_operator(pos: PositionMetadata, left: Value, right: Value) -> Result { + match pos.opType { + PlusOp => + if pos.column % 2 == 0 { + // Addition. + match (left, right) { + (VInt(a), VInt(b)) => Ok(VInt(a + b)), + (VFloat(a), VFloat(b)) => Ok(VFloat(a + b)), + (VInt(a), VFloat(b)) => Ok(VFloat(float(a) + b)), + (VFloat(a), VInt(b)) => Ok(VFloat(a + float(b))), + _ => Err("Cannot add these types") + } + } else { + // Concatenation. + let left_str = value_to_string(left); + let right_str = value_to_string(right); + Ok(VString(left_str ++ right_str)) + }, + StarOp => + if pos.column % 3 == 0 { + // Multiplication. + match (left, right) { + (VInt(a), VInt(b)) => Ok(VInt(a * b)), + (VFloat(a), VFloat(b)) => Ok(VFloat(a * b)), + (VInt(a), VFloat(b)) => Ok(VFloat(float(a) * b)), + (VFloat(a), VInt(b)) => Ok(VFloat(a * float(b))), + _ => Err("Cannot multiply these types") + } + } else { + // Exponentiation. + match (left, right) { + (VInt(a), VInt(b)) => Ok(VFloat(math_pow(float(a), float(b)))), + (VFloat(a), VFloat(b)) => Ok(VFloat(math_pow(a, b))), + (VInt(a), VFloat(b)) => Ok(VFloat(math_pow(float(a), b))), + (VFloat(a), VInt(b)) => Ok(VFloat(math_pow(a, float(b)))), + _ => Err("Cannot exponentiate these types") + } + } + } +} + +// ============================================ +// Instruction execution +// ============================================ + +pub enum ExecResult { + ExContinue(Vm), // Keep executing. + ExHalt(Vm), // Stop, success (current top of stack is the result). + ExError(String) // Runtime error. +} + +// Execute the instruction at vm.ip. Advances ip past the instruction first +// (control-flow ops then adjust it), mirroring the .res. +fn execute_instruction(vm0: Vm) -> ExecResult { + if vm0.ip >= len(vm0.chunk.code) { + ExHalt(vm0) + } else { + let instruction = vm0.chunk.code[vm0.ip]; + + // Optional debug trace (real opt-in VM output). + if vm0.debug { + println("IP: " ++ int_to_string(vm0.ip) ++ " " ++ opcode_to_string(instruction)); + } + + // Advance past the instruction (control-flow ops adjust ip afterwards). + let vm = with_ip(vm0, vm0.ip + 1); + exec_op(vm, instruction) + } +} + +fn exec_op(vm: Vm, instruction: Opcode) -> ExecResult { + match instruction { + // ---- Stack manipulation ---- + OpPush(value) => ExContinue(push(vm, value)), + OpPop => + match pop(vm) { + Ok((_v, vm1)) => ExContinue(vm1), + Err(e) => ExError(e) + }, + OpDup => + match peek(vm, 0) { + Ok(value) => ExContinue(push(vm, value)), + Err(e) => ExError(e) + }, + + // ---- Variables ---- + OpGetLocal(index) => { + let base = if len(vm.frames) > 0 { vm.frames[vm.fp].localBase } else { 0 }; + let slot = base + index; + if slot >= 0 && slot < len(vm.stack) { + ExContinue(push(vm, vm.stack[slot])) + } else { + ExError("Local variable out of bounds") + } + }, + OpSetLocal(index) => + match peek(vm, 0) { + Ok(value) => { + let base = if len(vm.frames) > 0 { vm.frames[vm.fp].localBase } else { 0 }; + let slot = base + index; + if slot >= 0 && slot < len(vm.stack) { + ExContinue(set_stack_at(vm, slot, value)) + } else { + ExError("Local variable out of bounds") + } + }, + Err(e) => ExError(e) + }, + OpGetGlobal(name) => + match globals_get(vm, name) { + Some(value) => ExContinue(push(vm, value)), + None => ExError("Undefined variable '" ++ name ++ "'") + }, + OpSetGlobal(name) => + match peek(vm, 0) { + Ok(value) => ExContinue(globals_set(vm, name, value)), + Err(e) => ExError(e) + }, + + // ---- Arithmetic with positional semantics ---- + OpAdd(pos) => + match pop2(vm) { + Ok((right, left, vm2)) => + match resolve_positional_operator(pos, left, right) { + Ok(result) => ExContinue(push(vm2, result)), + Err(e) => ExError(e) + }, + Err(e) => ExError(e) + }, + OpSub => + match pop2(vm) { + Ok((right, left, vm2)) => + match (left, right) { + (VInt(a), VInt(b)) => ExContinue(push(vm2, VInt(a - b))), + (VFloat(a), VFloat(b)) => ExContinue(push(vm2, VFloat(a - b))), + (VInt(a), VFloat(b)) => ExContinue(push(vm2, VFloat(float(a) - b))), + (VFloat(a), VInt(b)) => ExContinue(push(vm2, VFloat(a - float(b)))), + _ => ExError("Cannot subtract these types") + }, + Err(e) => ExError(e) + }, + OpMul(pos) => + match pop2(vm) { + Ok((right, left, vm2)) => + match resolve_positional_operator(pos, left, right) { + Ok(result) => ExContinue(push(vm2, result)), + Err(e) => ExError(e) + }, + Err(e) => ExError(e) + }, + OpDiv => + match pop2(vm) { + Ok((right, left, vm2)) => + match (left, right) { + (VInt(a), VInt(b)) => + if b != 0 { ExContinue(push(vm2, VInt(a / b))) } + else { ExError("Division by zero or invalid types") }, + (VFloat(a), VFloat(b)) => + if b != 0.0 { ExContinue(push(vm2, VFloat(a / b))) } + else { ExError("Division by zero or invalid types") }, + _ => ExError("Division by zero or invalid types") + }, + Err(e) => ExError(e) + }, + OpMod => + match pop2(vm) { + Ok((right, left, vm2)) => + match (left, right) { + (VInt(a), VInt(b)) => + if b != 0 { ExContinue(push(vm2, VInt(a % b))) } + else { ExError("Modulo requires integers") }, + _ => ExError("Modulo requires integers") + }, + Err(e) => ExError(e) + }, + OpNegate => + match pop(vm) { + Ok((value, vm1)) => + match value { + VInt(n) => ExContinue(push(vm1, VInt(-n))), + VFloat(f) => ExContinue(push(vm1, VFloat(-f))), + _ => ExError("Cannot negate this type") + }, + Err(e) => ExError(e) + }, + + // ---- Comparison ---- + OpEq => + match pop2(vm) { + Ok((right, left, vm2)) => ExContinue(push(vm2, VBool(left == right))), + Err(e) => ExError(e) + }, + OpNeq => + match pop2(vm) { + Ok((right, left, vm2)) => ExContinue(push(vm2, VBool(left != right))), + Err(e) => ExError(e) + }, + OpLt => + match pop2(vm) { + Ok((right, left, vm2)) => + match (left, right) { + (VInt(a), VInt(b)) => ExContinue(push(vm2, VBool(a < b))), + (VFloat(a), VFloat(b)) => ExContinue(push(vm2, VBool(a < b))), + _ => ExError("Cannot compare these types") + }, + Err(e) => ExError(e) + }, + OpGt => + match pop2(vm) { + Ok((right, left, vm2)) => + match (left, right) { + (VInt(a), VInt(b)) => ExContinue(push(vm2, VBool(a > b))), + (VFloat(a), VFloat(b)) => ExContinue(push(vm2, VBool(a > b))), + _ => ExError("Cannot compare these types") + }, + Err(e) => ExError(e) + }, + OpLte => ExError("Not implemented"), + OpGte => ExError("Not implemented"), + + // ---- Logical ---- + OpAnd => + match pop2(vm) { + Ok((right, left, vm2)) => + match (left, right) { + (VBool(a), VBool(b)) => ExContinue(push(vm2, VBool(a && b))), + _ => ExError("AND requires booleans") + }, + Err(e) => ExError(e) + }, + OpOr => + match pop2(vm) { + Ok((right, left, vm2)) => + match (left, right) { + (VBool(a), VBool(b)) => ExContinue(push(vm2, VBool(a || b))), + _ => ExError("OR requires booleans") + }, + Err(e) => ExError(e) + }, + OpNot => + match pop(vm) { + Ok((value, vm1)) => + match value { + VBool(b) => ExContinue(push(vm1, VBool(!b))), + _ => ExError("NOT requires boolean") + }, + Err(e) => ExError(e) + }, + + // ---- Control flow ---- + OpJump(offset) => ExContinue(with_ip(vm, vm.ip + offset)), + OpJumpIfFalse(offset) => + match pop(vm) { + Ok((condition, vm1)) => + match condition { + VBool(false) => ExContinue(with_ip(vm1, vm1.ip + offset)), + VBool(true) => ExContinue(vm1), + _ => ExError("Condition must be boolean") + }, + Err(e) => ExError(e) + }, + OpCall(_arity) => ExError("Function calls not yet implemented"), + OpReturn => + if len(vm.frames) == 0 { + ExHalt(vm) + } else { + let frame = vm.frames[vm.fp]; + let vm1 = with_ip(vm, frame.returnAddress); + // fp = fp - 1 (frame stack is left intact, as in the .res). + ExContinue(#{ stack: vm1.stack, sp: vm1.sp, ip: vm1.ip, + frames: vm1.frames, fp: vm1.fp - 1, globals: vm1.globals, + chunk: vm1.chunk, stabilityScore: vm1.stabilityScore, + activeParadoxes: vm1.activeParadoxes, + traceHistory: vm1.traceHistory, debug: vm1.debug }) + }, + + // ---- Arrays ---- + OpArray(size) => { + // Pop `size` values. The .res pushes them into `arr` then reverses; popping + // builds them in reverse, so prepending each popped value yields the + // original stack order directly (equivalent to push-then-reverse). + let mut elems = []; + let mut k = 0; + let mut cur = vm; + let mut err = None; + while k < size { + match err { + Some(_e) => { k = size; }, + None => + match pop(cur) { + Ok((v, vm1)) => { + elems = [v] ++ elems; + cur = vm1; + k = k + 1; + }, + Err(e) => { err = Some(e); k = size; } + } + } + } + match err { + Some(e) => ExError(e), + None => ExContinue(push(cur, VArray(elems))) + } + }, + OpIndex => + match pop2(vm) { + // pop2 returns (right=index, left=array). + Ok((index, array, vm2)) => + match (array, index) { + (VArray(arr), VInt(i)) => + if i >= 0 && i < len(arr) { + ExContinue(push(vm2, arr[i])) + } else { + ExError("Invalid array indexing") + }, + _ => ExError("Invalid array indexing") + }, + Err(e) => ExError(e) + }, + + // ---- Computational haptics ---- + OpTrace(msg) => + match peek(vm, 0) { + Ok(value) => ExContinue(push_trace(vm, msg, value)), + Err(e) => ExError(e) + }, + OpCheckpoint(name) => { + // Real haptics output marker. + println("📍 Checkpoint: " ++ name); + ExContinue(vm) + }, + OpUpdateStability => { + // Recalculate based on active paradoxes (simple heuristic, as .res). + let paradox_count = float(vm.activeParadoxes); + ExContinue(with_stability(vm, 100.0 - (paradox_count * 10.0))) + }, + OpInjectParadox(_p) => { + // Activate a paradox (bump the bitmask) and debit stability. + let vm1 = #{ stack: vm.stack, sp: vm.sp, ip: vm.ip, frames: vm.frames, + fp: vm.fp, globals: vm.globals, chunk: vm.chunk, + stabilityScore: vm.stabilityScore - 5.0, + activeParadoxes: vm.activeParadoxes + 1, + traceHistory: vm.traceHistory, debug: vm.debug }; + ExContinue(vm1) + }, + + // ---- Echo types (structured loss) ---- + OpEcho => + match pop2(vm) { + // pop2 returns (right=output, left=input). + Ok((output, input, vm2)) => ExContinue(push(vm2, VEcho(input, output))), + Err(e) => ExError(e) + }, + OpEchoToResidue => + match pop(vm) { + Ok((e, vm1)) => + match e { + VEcho(_input, output) => { + // Erasure is not free: destroying the witness costs stability. + let vm2 = with_stability(vm1, vm1.stabilityScore - echo_erase_cost()); + let vm3 = push_trace(vm2, "echo_to_residue: witness erased", VEcho(_input, output)); + ExContinue(push(vm3, VResidue(output))) + }, + // Already a residue — idempotent, no further loss. + VResidue(out) => ExContinue(push(vm1, VResidue(out))), + _ => ExError("echo_to_residue expects an Echo value") + }, + Err(e) => ExError(e) + }, + OpResidueStrictlyLoses => + match pop(vm) { + Ok((e, vm1)) => + match e { + VResidue(_out) => ExContinue(push(vm1, VBool(true))), + VEcho(_in, _out) => ExContinue(push(vm1, VBool(false))), + _ => ExError("residue_strictly_loses expects an Echo or residue value") + }, + Err(e) => ExError(e) + }, + OpEchoInput => + match pop(vm) { + Ok((e, vm1)) => + match e { + VEcho(input, _output) => ExContinue(push(vm1, input)), + VResidue(_out) => + ExError("echo_input: the witness was erased — a residue is non-recoverable"), + _ => ExError("echo_input expects an Echo value") + }, + Err(e) => ExError(e) + }, + OpEchoOutput => + match pop(vm) { + Ok((e, vm1)) => + match e { + VEcho(_input, output) => ExContinue(push(vm1, output)), + VResidue(output) => ExContinue(push(vm1, output)), + _ => ExError("echo_output expects an Echo or residue value") + }, + Err(e) => ExError(e) + }, + + // ---- Debug ---- + OpPrint(is_println) => + match pop(vm) { + Ok((value, vm1)) => { + let str = value_to_string(value); + // .res uses Console.log for both print and println (same behavior). + if is_println { println(str); } else { println(str); } + ExContinue(vm1) + }, + Err(e) => ExError(e) + }, + OpHalt => ExHalt(vm) + } +} + +// ============================================ +// VM driver +// ============================================ + +// Run the VM to completion, returning the top of stack (or VNil) or a runtime +// error. Mirrors VM.res `run` (loop while ip < code length). +pub fn run(vm0: Vm) -> Result { + let mut vm = vm0; + let mut running = true; + let mut err = None; + while running && vm.ip < len(vm.chunk.code) { + match execute_instruction(vm) { + ExContinue(vm1) => { vm = vm1; }, + ExHalt(vm1) => { vm = vm1; running = false; }, + ExError(m) => { err = Some(m); running = false; } + } + } + match err { + Some(m) => Err("Runtime error: " ++ m), + None => + // Return top of stack or nil. + if vm.sp > 0 { + match peek(vm, 0) { + Ok(v) => Ok(v), + Err(e) => Err("Runtime error: " ++ e) + } + } else { + Ok(VNil) + } + } +} + +// Execute a bytecode program (debug-gated instruction trace). +pub fn execute(program: BytecodeProgram, debug: Bool) -> Result { + let vm0 = make(program.main); + let vm = #{ stack: vm0.stack, sp: vm0.sp, ip: vm0.ip, frames: vm0.frames, + fp: vm0.fp, globals: vm0.globals, chunk: vm0.chunk, + stabilityScore: vm0.stabilityScore, + activeParadoxes: vm0.activeParadoxes, + traceHistory: vm0.traceHistory, debug: debug }; + run(vm) +} From 852c2157bc3916c61c0a7b1742165edaa5aa12f7 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 24 Jun 2026 07:56:16 +0000 Subject: [PATCH 11/11] compiler: port the remaining 8 compiler/src modules -> .affine Completes the compiler/src ReScript->AffineScript port: all 17 modules are now .affine. 2785 lines across Stability, Pretty, TokenStream, LayerNavigator, Analyzer, FiveWhys, IncrementalLexer, IncrementalParser. All verified green with affinescript check and the full 17-source harness. - Stability: paradox/consequence/discovery detection + stability arithmetic. - Pretty: AST->source pretty-printer (immutable Printer threaded through pp_*). - TokenStream: proc-macro-style token-stream API + of_string lexer. - LayerNavigator: 5-layer (Grammar/Parser/AST/Semantics/Runtime) views; owns Layer. - Analyzer: paradox/report formatters, causality, forensic trace. - FiveWhys: automated root-cause why-chains. - IncrementalLexer / IncrementalParser: edit-driven re-lex / re-parse + splice. Documented omissions (one AffineScript limitation, three call sites): the record-constructor functions generate_report (Stability), analyze_program (Analyzer) and create_layer_views (LayerNavigator) build records whose fields are typed Dict in the shipped types. Dict is effectively a PHANTOM TYPE in AffineScript -- accepted in annotations but with no value-level introduction (no literal, no constructor, no Dict-returning builtin; the stdlib `dict` is a distinct assoc-list type that does not unify). Those records therefore cannot be constructed; every function that READS such a field is ported. Closing them needs the Dict fields switched to the idiomatic assoc-list representation [(K,V)] (or affinescript's Dict made constructable) -- tracked as a follow-up. format_diagnostic is reimplemented locally (byte-faithful) since the shipped Types.affine omitted it. Confirmed all three prior affinescript bugs (builtin-shadow bit once via a `length` param). The Dict phantom-type is the fourth distinct affinescript limitation found this migration. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0195yA45jSSP7YDPwJSpw4bM --- compiler/src/Analyzer.affine | 233 +++++++++++ compiler/src/FiveWhys.affine | 297 ++++++++++++++ compiler/src/IncrementalLexer.affine | 231 +++++++++++ compiler/src/IncrementalParser.affine | 332 ++++++++++++++++ compiler/src/LayerNavigator.affine | 333 ++++++++++++++++ compiler/src/Pretty.affine | 515 ++++++++++++++++++++++++ compiler/src/Stability.affine | 307 +++++++++++++++ compiler/src/TokenStream.affine | 537 ++++++++++++++++++++++++++ 8 files changed, 2785 insertions(+) create mode 100644 compiler/src/Analyzer.affine create mode 100644 compiler/src/FiveWhys.affine create mode 100644 compiler/src/IncrementalLexer.affine create mode 100644 compiler/src/IncrementalParser.affine create mode 100644 compiler/src/LayerNavigator.affine create mode 100644 compiler/src/Pretty.affine create mode 100644 compiler/src/Stability.affine create mode 100644 compiler/src/TokenStream.affine diff --git a/compiler/src/Analyzer.affine b/compiler/src/Analyzer.affine new file mode 100644 index 0000000..44994f0 --- /dev/null +++ b/compiler/src/Analyzer.affine @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: MPL-2.0 +// Analyzer.affine — static analysis & paradox detection +// (ported from compiler/src/Analyzer.res). +// +// ── AffineScript port conventions ── +// * `open Types` + `open Stability` -> `use Types::*;` + `use Stability::*;`. +// The `Paradox`/`Consequence`/`Discovery` enums and the +// `operator_behavior_from_position` / `alternative_operator_behaviors` / +// `is_context_collapse` / `stability_bar` / `consequence_emoji` helpers +// are reused from Stability.affine verbatim. +// * ReScript inline-record variants -> positional (matching Stability's +// positional constructors and Types' positional AST/StabilityFactor ctors). +// * Backtick `${…}` templates with embedded newlines -> `++` concatenation +// with `\n`; emoji/box glyphs preserved. `Array.map(...)->Array.joinWith` -> +// `join(map(...), sep)`; `Array.slice(~start,~end)` -> `xs[a:b]`; +// `Array.mapWithIndex` -> an index-carrying loop. +// +// ── Faithfulness notes: documented gaps / simplifications vs Analyzer.res ── +// * `analyze_program` is OMITTED. It constructs an `AnalysisResult` whose +// `stabilityReport` field is a `Types::StabilityReport`, and it does so by +// calling `Stability::generate_report` — both of which are unconstructable +// because `StabilityReport.breakdown : Dict` has no value-level +// introduction in AffineScript (full rationale in Stability.affine's +// header). The AST walk it performs (paradox detection via +// `is_context_collapse` on let-names and `operator_behavior_from_position` +// on +/-/* binaries, plus mutable-state factor accumulation) cannot be +// surfaced without building that result record, so it is not ported. +// Consumers that already hold an `AnalysisResult` work: `forensic_analysis` +// (which only READS `analysis.stabilityReport.factors`) is ported, as is +// `format_stability_report` (which READS a `StabilityReport`). +// * Everything else is ported with identical strings/arithmetic: the +// `Paradox`/`Consequence`/etc. constructors, `trace_causality`, +// `generate_alternatives`, `forensic_analysis`, `format_paradox`, and +// `format_stability_report` (including the top-3 recommendation slice). +// * No `runTests`/Console code exists in Analyzer.res, so nothing else omitted. + +module Analyzer; + +use prelude::*; +use string::{join}; +use Types::*; +use Stability::*; + +// ============================================ +// Program Analysis (result type) +// ============================================ + +// `stabilityReport` is a `Types::StabilityReport` (phantom `Dict` field — see +// header); the struct can be declared but not constructed here. +pub struct AnalysisResult { + paradoxes: [Paradox], + consequences: [Consequence], + stabilityReport: StabilityReport, + discoveries: [Discovery] +} + +// NOTE: `analyze_program` (Analyzer.res) is intentionally not ported — it +// builds an `AnalysisResult` containing a `Types::StabilityReport`, which is +// unconstructable in AffineScript (see header / Stability.affine). + +// ============================================ +// Causality Tracing +// ============================================ + +pub struct CausalityChain { + symptom: String, + symptomLocation: Location, + chain: [(String, Location)], + rootCause: String, + rootLocation: Location +} + +pub fn trace_causality(symptom: String, symptom_loc: Location, prog: Program) -> CausalityChain { + // Simplified causality tracing — in a real implementation this would track + // data flow through the AST. + #{ symptom: symptom, + symptomLocation: symptom_loc, + chain: [("Type mismatch propagated from", symptom_loc)], + rootCause: "Unvalidated user input", + rootLocation: symptom_loc } +} + +// ============================================ +// Alternative Code Generation +// ============================================ + +pub struct Alternative { + description: String, + code: String, + stabilityScore: Int, + improvements: [String] +} + +pub fn generate_alternatives(stmt: Stmt, current_stability: Int) -> [Alternative] { + match stmt { + LetStmt(mutable_, name, _type, _value, _loc) => + if mutable_ { + [ + #{ description: "Use immutable binding", + code: "let " ++ name ++ " = ...", + stabilityScore: current_stability + 15, + improvements: [ + "No mutation risk", + "Thread-safe by default", + "Easier to reason about" + ] } + ] + } else { + [] + }, + _ => [] + } +} + +// ============================================ +// Forensic Analysis (Deep Dive) +// ============================================ + +pub struct ForensicTrace { + target: String, + targetLocation: Location, + instabilityFactors: [(String, Int, String)], // (description, impact, reason) + probabilityMap: [(String, Int)], // (scenario, probability %) + suggestedFix: String +} + +fn factor_description(factor: StabilityFactor) -> String { + match factor { + MutableState(_m, _r) => "Mutable state detected", + TypeInstability(_x) => "Type changed dynamically", + _ => "Unknown factor" + } +} + +pub fn forensic_analysis(target: String, target_loc: Location, analysis: AnalysisResult) -> ForensicTrace { + // Find all factors affecting this target. + let factors = map(analysis.stabilityReport.factors, |factor| { + let impact = stability_impact(factor); + (factor_description(factor), impact, "See stability report") + }); + + let probability_map = [ + ("Standard behavior", 40), + ("Positional override", 30), + ("Context collapse", 20), + ("Temporal corruption", 10) + ]; + + #{ target: target, + targetLocation: target_loc, + instabilityFactors: factors, + probabilityMap: probability_map, + suggestedFix: "Move to different position or add explicit type" } +} + +// ============================================ +// Visualization Formatters +// ============================================ + +pub fn format_paradox(paradox: Paradox) -> String { + match paradox { + ContextCollapseKeyword(keyword, line, _depth, _isKw, reason) => + "⚡ QUANTUM KEYWORD at line " ++ int_to_string(line) ++ "\n" ++ + " '" ++ keyword ++ "' is both keyword AND identifier\n" ++ + " Reason: " ++ reason, + + PositionalOperator(operator, line, column, behavior, alternatives) => + "🎲 POSITIONAL OPERATOR at line " ++ int_to_string(line) ++ ":" ++ int_to_string(column) ++ "\n" ++ + " '" ++ operator ++ "' behaves as: " ++ behavior ++ "\n" ++ + " Alternative positions:\n" ++ + join(map(alternatives, |pair| { + let (col, beh) = pair; + " Col " ++ int_to_string(col) ++ ": " ++ beh + }), "\n"), + + TypeSuperposition(variable, _line, possible_types, collapse_target) => + "🌀 TYPE SUPERPOSITION\n" ++ + " Variable '" ++ variable ++ "' exists as: " ++ join(possible_types, " | ") ++ "\n" ++ + " Will collapse to: " ++ collapse_target, + + ScopeLeakage(variable, declared_line, access_line, _runNumber, leak_reason) => + "🕐 SCOPE LEAKAGE\n" ++ + " Variable '" ++ variable ++ "' declared at line " ++ int_to_string(declared_line) ++ "\n" ++ + " Accessible at line " ++ int_to_string(access_line) ++ "\n" ++ + " Reason: " ++ leak_reason, + + TemporalCorruption(variable, _line, _affectedByRun, mechanism) => + "⏰ TEMPORAL CORRUPTION\n" ++ + " Variable '" ++ variable ++ "' affected by historical state\n" ++ + " Mechanism: " ++ mechanism + } +} + +fn factor_line(factor: StabilityFactor) -> String { + let emoji = consequence_emoji(factor); + let impact = stability_impact(factor); + let desc = match factor { + MutableState(mutations, readers) => + "Mutable state (" ++ int_to_string(mutations) ++ " mutations, " ++ int_to_string(readers) ++ " readers)", + TypeInstability(reassignments) => + "Type instability (" ++ int_to_string(reassignments) ++ " reassignments)", + NullPropagation(depth) => + "Null propagation (depth " ++ int_to_string(depth) ++ ")", + _ => "Other factor" + }; + emoji ++ " " ++ desc ++ ": " ++ int_to_string(impact) ++ " points" +} + +pub fn format_stability_report(report: StabilityReport) -> String { + let bar = stability_bar(report.score); + + let factor_list = join(map(report.factors, factor_line), "\n "); + + // Top-3 recommendations with 1-based numbering. + let recs = report.recommendations; + let top = if len(recs) > 3 { recs[0:3] } else { recs }; + let mut rec_lines = ""; + let mut i = 0; + let n = len(top); + while i < n { + if i > 0 { rec_lines = rec_lines ++ "\n"; } + rec_lines = rec_lines ++ " " ++ int_to_string(i + 1) ++ ". " ++ top[i]; + i = i + 1; + } + + "\n🎯 STABILITY REPORT\n" ++ + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n" ++ + bar ++ "\n\n" ++ + "Breakdown:\n" ++ + " " ++ factor_list ++ "\n\n" ++ + "💡 Top Recommendations:\n" ++ + rec_lines ++ "\n" +} diff --git a/compiler/src/FiveWhys.affine b/compiler/src/FiveWhys.affine new file mode 100644 index 0000000..673c253 --- /dev/null +++ b/compiler/src/FiveWhys.affine @@ -0,0 +1,297 @@ +// SPDX-License-Identifier: MPL-2.0 +// FiveWhys.affine — Five Whys root cause analysis +// (ported from compiler/src/FiveWhys.res). +// +// Automated root-cause analysis: traces symptom -> intermediate causes -> root +// cause, teaching causal reasoning rather than pattern matching. +// +// ── AffineScript port conventions ── +// * `open Types` + `open Stability` -> `use Types::*;` + `use Stability::*;`. +// The `layer` type lives in LayerNavigator (it OWNS the global +// Grammar/Parser/AST/Semantics/Runtime constructors), so this module does a +// SELECTIVE `use LayerNavigator::{Layer, Grammar, Parser, AST, Semantics, +// Runtime}` — selective so it does NOT pull in LayerNavigator's own +// `type_expr_to_string`, which would collide with the (simplified) one +// defined here. Function names are module-scoped, so both definitions +// coexist; only the imported *constructors* are shared. +// * `Array.push(whys, {...})` building a fixed 5-element list -> a single +// array literal of 5 records (same order, same contents). ReScript inline +// records -> AffineScript `#{ … }`. `option` -> `Option`. +// * `formatAnalysis`'s push-into-`lines`-then-`joinWith` -> accumulate a +// `[String]` (threaded `mut`) and `join(_, "\n")`; the `for i in 0 to n-1` +// / nested `for evidence in …` loops become index loops. `${…}` templates +// -> `++`. `Float.toInt(a /. b)` -> `trunc(a / b)`. +// +// ── Faithfulness notes: documented gaps / simplifications vs FiveWhys.res ── +// * No behaviour omitted and nothing here touches the unconstructable +// `Dict`: every why-chain (type-mismatch, mutation-impact, null-cascade, +// performance) is ported verbatim — same questions, answers, evidence, +// layers, root causes and recommendations — as is the generic `analyze` +// dispatch over `StabilityFactor` and the `format_analysis` renderer +// (identical "WHY n:" / "→" / "•" / "ROOT CAUSE" / "RECOMMENDATION" text). +// * `type_expr_to_string` is the .res's deliberately-simplified helper +// (Int/Float/String/Bool, else "Unknown"); ported as-is. +// * No `runTests`/Console code exists in FiveWhys.res, so nothing is omitted. + +module FiveWhys; + +use prelude::*; +use string::{join}; +use Types::*; +use Stability::*; +use LayerNavigator::{Layer, Grammar, Parser, AST, Semantics, Runtime}; + +// Why-chain: each "why" leads to deeper understanding. +pub struct Why { + question: String, + answer: String, + evidence: [String], + layer: Option +} + +pub struct AnalysisResult { + symptom: String, + whys: [Why], + rootCause: String, + rootLayer: Layer, + recommendation: String +} + +// Helper to convert typeExpr to string (simplified — matches the .res). +pub fn type_expr_to_string(t: TypeExpr) -> String { + match t { + TyInt => "Int", + TyFloat => "Float", + TyString => "String", + TyBool => "Bool", + _ => "Unknown" + } +} + +fn zero_loc() -> Location { + #{ file: "unknown", + start: #{ line: 0, column: 0, offset: 0 }, + end_: #{ line: 0, column: 0, offset: 0 } } +} + +/// Analyze type mismatch error. +pub fn analyze_type_mismatch(var_name: String, expected_type: TypeExpr, actual_type: TypeExpr, loc: Location) -> AnalysisResult { + let whys = [ + #{ question: "Why did we get a type mismatch?", + answer: "Variable '" ++ var_name ++ "' has type " ++ type_expr_to_string(actual_type) ++ ", but " ++ type_expr_to_string(expected_type) ++ " was expected", + evidence: ["Line " ++ int_to_string(loc.start.line) ++ ": type mismatch detected"], + layer: Some(Semantics) }, + #{ question: "Why does '" ++ var_name ++ "' have type " ++ type_expr_to_string(actual_type) ++ "?", + answer: "The variable was assigned a value of that type earlier", + evidence: ["Type inference from initial assignment"], + layer: Some(Semantics) }, + #{ question: "Why was this type assignment allowed?", + answer: "No explicit type annotation was provided", + evidence: ["Variable declared without type constraint"], + layer: Some(Grammar) }, + #{ question: "Why does missing type annotation cause problems?", + answer: "Without type annotations, the compiler infers types, which may not match intent", + evidence: ["Type inference is permissive without constraints"], + layer: Some(Semantics) }, + #{ question: "Why do we have type inference instead of required annotations?", + answer: "Design tradeoff: flexibility vs safety", + evidence: [ + "Flexibility: Less code to write", + "Safety: More runtime errors possible" + ], + layer: None } + ]; + + #{ symptom: "Type mismatch: expected " ++ type_expr_to_string(expected_type) ++ ", got " ++ type_expr_to_string(actual_type), + whys: whys, + rootCause: "Design tradeoff between flexibility and safety", + rootLayer: Grammar, + recommendation: "Add explicit type annotation: let " ++ var_name ++ ": " ++ type_expr_to_string(expected_type) ++ " = ..." } +} + +/// Analyze mutation instability. +pub fn analyze_mutation_impact(var_name: String, mutation_loc: Location, readers: [Location]) -> AnalysisResult { + let reader_evidence = map(readers, |loc| "Reader at line " ++ int_to_string(loc.start.line)); + let whys = [ + #{ question: "Why is stability dropping?", + answer: "Mutable variable '" ++ var_name ++ "' is being modified", + evidence: ["Mutation at line " ++ int_to_string(mutation_loc.start.line)], + layer: Some(Runtime) }, + #{ question: "Why does mutation reduce stability?", + answer: "Mutation affects " ++ int_to_string(len(readers)) ++ " other locations that read this variable", + evidence: reader_evidence, + layer: Some(Semantics) }, + #{ question: "Why do readers get affected by mutation?", + answer: "Shared mutable state creates invisible dependencies", + evidence: [ + "Each reader depends on the current value", + "Mutation changes the value for all readers", + "Order of reads matters" + ], + layer: Some(Semantics) }, + #{ question: "Why use shared mutable state?", + answer: "Variable was declared with 'mut' keyword", + evidence: ["Explicit mutability declaration"], + layer: Some(Grammar) }, + #{ question: "Why does mutability exist in the language?", + answer: "Design tradeoff: performance vs simplicity", + evidence: [ + "Mutation: Fast in-place updates", + "Immutability: Easier to reason about, no side effects" + ], + layer: None } + ]; + + #{ symptom: "Stability dropped due to mutation of '" ++ var_name ++ "'", + whys: whys, + rootCause: "Design tradeoff between performance and simplicity", + rootLayer: Grammar, + recommendation: "Use immutable data with functional updates (map, filter, reduce)" } +} + +/// Analyze null propagation cascade. +pub fn analyze_null_cascade(origin_loc: Location, cascade_depth: Int) -> AnalysisResult { + let whys = [ + #{ question: "Why did the program crash?", + answer: "Null pointer access occurred", + evidence: ["Null access at line " ++ int_to_string(origin_loc.start.line)], + layer: Some(Runtime) }, + #{ question: "Why was the value null?", + answer: "A function returned null instead of a value", + evidence: ["Function can return T or Nil"], + layer: Some(Semantics) }, + #{ question: "Why wasn't the null case handled?", + answer: "No null check or pattern matching was performed", + evidence: [ + "Direct access without checking", + "Propagated through " ++ int_to_string(cascade_depth) ++ " levels" + ], + layer: Some(Semantics) }, + #{ question: "Why is null checking optional?", + answer: "The language allows nullable types without forcing checks", + evidence: ["No Option type enforcement"], + layer: Some(Grammar) }, + #{ question: "Why does the language allow nullable types?", + answer: "Design tradeoff: convenience vs safety", + evidence: [ + "Convenience: No boilerplate for null checks", + "Safety: Runtime errors from unchecked nulls", + "Tony Hoare called null his 'billion dollar mistake'" + ], + layer: None } + ]; + + #{ symptom: "Null pointer exception with cascade", + whys: whys, + rootCause: "Design tradeoff between convenience and safety (nullable types)", + rootLayer: Grammar, + recommendation: "Use Option type with pattern matching to force null handling" } +} + +/// Analyze performance cliff. +pub fn analyze_performance_issue(operation: String, time_ms: Float, expected_ms: Float) -> AnalysisResult { + let slowdown = trunc(time_ms / expected_ms); + let whys = [ + #{ question: "Why is the code slow?", + answer: "Operation took " ++ float_to_string(time_ms) ++ "ms, expected " ++ float_to_string(expected_ms) ++ "ms (" ++ int_to_string(slowdown) ++ "x slower)", + evidence: ["Performance measurement: " ++ operation], + layer: Some(Runtime) }, + #{ question: "Why is " ++ operation ++ " so slow?", + answer: "Algorithm has O(n²) or worse complexity", + evidence: [ + "Nested loops detected", + "Repeated scans through data" + ], + layer: Some(AST) }, + #{ question: "Why use an O(n²) algorithm?", + answer: "Wrong data structure chosen for the operation", + evidence: [ + "Using array/list for lookups", + "Should use hash table or index" + ], + layer: Some(Semantics) }, + #{ question: "Why was the wrong data structure chosen?", + answer: "No explicit data structure selection in code", + evidence: ["Default collection used without optimization"], + layer: Some(Grammar) }, + #{ question: "Why doesn't the language enforce efficient data structures?", + answer: "Design tradeoff: ease of use vs performance", + evidence: [ + "Simple syntax: Easy to write, potentially slow", + "Explicit structures: More code, but faster" + ], + layer: None } + ]; + + #{ symptom: "Performance cliff: " ++ int_to_string(slowdown) ++ "x slower than expected", + whys: whys, + rootCause: "Design tradeoff between ease of use and performance", + rootLayer: AST, + recommendation: "Use hash-based data structure for O(1) lookups instead of O(n) scans" } +} + +/// Generic Five Whys analysis. +pub fn analyze(stability_factor: StabilityFactor) -> AnalysisResult { + match stability_factor { + TypeInstability(_reassignments) => + // Simplified — would track actual types in a full implementation. + analyze_type_mismatch("x", TyInt, TyString, zero_loc()), + MutableState(_mutations, _readers) => + analyze_mutation_impact("counter", zero_loc(), []), + NullPropagation(depth) => + analyze_null_cascade(zero_loc(), depth), + AlgorithmComplexity(time_ms) => + analyze_performance_issue("operation", time_ms, 10.0), + _ => + #{ symptom: "Unknown issue", + whys: [], + rootCause: "Needs analysis", + rootLayer: Runtime, + recommendation: "Investigate further" } + } +} + +fn layer_str(layer: Option) -> String { + match layer { + Some(Runtime) => " [Runtime]", + Some(Semantics) => " [Semantics]", + Some(AST) => " [AST]", + Some(Parser) => " [Parser]", + Some(Grammar) => " [Grammar]", + None => "" + } +} + +/// Format Five Whys analysis for display. +pub fn format_analysis(result: AnalysisResult) -> String { + let mut lines = []; + lines = lines ++ ["\n🔍 FIVE WHYS ROOT CAUSE ANALYSIS\n"]; + lines = lines ++ ["Symptom: " ++ result.symptom ++ "\n"]; + + let whys = result.whys; + let mut i = 0; + let n = len(whys); + while i < n { + let why = whys[i]; + let layer_s = layer_str(why.layer); + lines = lines ++ ["WHY " ++ int_to_string(i + 1) ++ ":" ++ layer_s ++ " " ++ why.question]; + lines = lines ++ [" → " ++ why.answer]; + + let ev = why.evidence; + let m = len(ev); + if m > 0 { + let mut k = 0; + while k < m { + lines = lines ++ [" • " ++ ev[k]]; + k = k + 1; + } + } + lines = lines ++ [""]; + i = i + 1; + } + + lines = lines ++ ["ROOT CAUSE: " ++ result.rootCause]; + lines = lines ++ ["\n💡 RECOMMENDATION: " ++ result.recommendation]; + + join(lines, "\n") +} diff --git a/compiler/src/IncrementalLexer.affine b/compiler/src/IncrementalLexer.affine new file mode 100644 index 0000000..c433c77 --- /dev/null +++ b/compiler/src/IncrementalLexer.affine @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// IncrementalLexer.affine — incremental lexing support for Error-Lang +// (ported from compiler/src/IncrementalLexer.res). +// +// Wraps the Error-Lang lexer (Lexer.lex) for incremental re-lexing. On an edit, +// only the affected token range is re-lexed and spliced into the cached token +// list (tree-sitter-style: cache tokens with byte offsets, find the affected +// range, re-lex it plus a small resync buffer, splice with adjusted offsets). +// +// ── AffineScript port conventions ── +// * `open Types` -> `use Types::*;`; `Lexer.lex` -> `use Lexer::{lex};` (same +// `(source, file, run_number) -> ([Token],[Diagnostic])` signature the Cst +// port already consumes). +// * `t` / `cachedToken` are immutable structs (they already were in the .res). +// * Pervasive record-spread (`{...ct.token, loc: {...loc, start: {...}, …}}`) +// -> full reconstruction via `shift_token` / `shift_cached` helpers that +// rebuild `Token`/`Location`/`Position` with offset-shifted fields. No +// record-spread is used anywhere. +// * `ref` cells in the binary/linear searches -> `let mut` locals. +// `String.substring(~start,~end)` -> `substring`; `Int.max`/`Int.min` -> +// `max`/`min`; `Array.slice(~start,~end)` -> `xs[a:b]`; +// `Array.filter`/`Array.map` -> prelude `filter`/`map`; `Array.some` -> a +// small `any`-style fold. +// +// ── Faithfulness notes: documented gaps / simplifications vs IncrementalLexer.res ── +// * The entire TEST harness is OMITTED per the "omit runTests / Console / +// assert" rule: `assert_matches_full_lex` and the twelve `test*` functions +// and `run_tests`. They use `Console.error` + `assert`, are not part of the +// compiler's behaviour, and several rely on the lexer treating `#`/`//` +// comments and `"…"` strings — orthogonal to the splice logic under port. +// * The real incremental behaviour is ported faithfully and completely: +// `full_lex`, `create`, `tokens`, `source`, `token_types`, +// `find_first_after` (binary search), `find_last_affected` (linear scan), +// and `edit` (resync-buffered re-lex + offset-adjusted head/new/tail splice +// + EOF guarantee) with identical arithmetic. + +module IncrementalLexer; + +use prelude::*; +use string::{substring}; +use Lexer::{lex}; +use Types::*; + +/// An edit to a source file. Replaces bytes [start, oldEnd) with newText. +pub struct Edit { + start: Int, + oldEnd: Int, + newText: String +} + +/// A cached token with byte offsets into the source. +pub struct CachedToken { + token: Token, + startOffset: Int, + endOffset: Int +} + +/// The incremental lexer state. +pub struct ILState { + source: String, + tokens: [CachedToken] +} + +/// Number of extra tokens to include before/after the edit region for correct +/// re-synchronisation. +pub fn resync_buffer() -> Int { 2 } + +// ---- Token/Location/Position reconstruction (no record-spread) ---- + +fn shift_position(p: Position, delta: Int) -> Position { + #{ line: p.line, column: p.column, offset: p.offset + delta } +} + +fn shift_token(tok: Token, delta: Int) -> Token { + let loc = tok.loc; + let new_loc = #{ start: shift_position(loc.start, delta), + end_: shift_position(loc.end_, delta), + file: loc.file }; + #{ type_: tok.type_, lexeme: tok.lexeme, loc: new_loc } +} + +fn shift_cached(ct: CachedToken, delta: Int) -> CachedToken { + #{ token: shift_token(ct.token, delta), + startOffset: ct.startOffset + delta, + endOffset: ct.endOffset + delta } +} + +/// Perform a full lex of the given source and return cached tokens. +pub fn full_lex(source: String) -> [CachedToken] { + let (toks, _diagnostics) = lex(source, "", 0); + map(toks, |tok| #{ token: tok, + startOffset: tok.loc.start.offset, + endOffset: tok.loc.end_.offset }) +} + +/// Create a new incremental lexer from the given source. +pub fn create(source: String) -> ILState { + #{ source: source, tokens: full_lex(source) } +} + +/// Get all current tokens. +pub fn tokens(state: ILState) -> [CachedToken] { state.tokens } + +/// Get the current source text. +pub fn source(state: ILState) -> String { state.source } + +fn is_eof(ct: CachedToken) -> Bool { + match ct.token.type_ { EOF => true, _ => false } +} + +/// Get token types (excluding EOF) for comparison. +pub fn token_types(state: ILState) -> [TokenType] { + let non_eof = filter(state.tokens, |ct| !is_eof(ct)); + map(non_eof, |ct| ct.token.type_) +} + +/// Binary search: find the first token index whose endOffset > target. +pub fn find_first_after(toks: [CachedToken], target: Int) -> Int { + let mut lo = 0; + let mut hi = len(toks); + while lo < hi { + let mid = lo + (hi - lo) / 2; + if toks[mid].endOffset <= target { + lo = mid + 1; + } else { + hi = mid; + } + } + lo +} + +/// Find the index of the first token starting at or past oldEnd. +pub fn find_last_affected(toks: [CachedToken], old_end: Int, from: Int) -> Int { + let n = len(toks); + let mut result = n; + let mut i = from; + let mut found = false; + while i < n && !found { + if toks[i].startOffset >= old_end { + result = i; + found = true; + } else { + i = i + 1; + } + } + result +} + +fn has_eof(toks: [CachedToken]) -> Bool { + let mut i = 0; + let n = len(toks); + let mut out = false; + while i < n && !out { + if is_eof(toks[i]) { out = true; } + i = i + 1; + } + out +} + +/// Apply an edit and re-lex only the affected region. Returns a new state with +/// the updated source and tokens. +pub fn edit(state: ILState, e: Edit) -> ILState { + let old_source = state.source; + let old_len = len(old_source); + + // Apply the text edit to the source string. + let prefix = substring(old_source, 0, e.start); + let suffix = substring(old_source, e.oldEnd, old_len); + let new_source = prefix ++ e.newText ++ suffix; + + let new_end = e.start + len(e.newText); + let delta = new_end - e.oldEnd; + let n = len(state.tokens); + + // First affected token index (minus the resync buffer, floored at 0). + let first_raw = find_first_after(state.tokens, e.start); + let first_affected = max(0, first_raw - resync_buffer()); + + // Last affected token (plus the resync buffer, capped at n). + let last_raw = find_last_affected(state.tokens, e.oldEnd, first_affected); + let last_affected = min(n, last_raw + resync_buffer()); + + // Byte range to re-lex in the new source. + let relex_start = if first_affected < n { + min(state.tokens[first_affected].startOffset, e.start) + } else { + e.start + }; + + let relex_end_old = if (last_affected - 1) >= 0 && (last_affected - 1) < n { + max(state.tokens[last_affected - 1].endOffset, e.oldEnd) + } else { + e.oldEnd + }; + let relex_end = min(len(new_source), relex_end_old + delta); + + // Re-lex the affected region. + let region = substring(new_source, relex_start, relex_end); + let new_tokens_raw = full_lex(region); + + // Offset new tokens (by relexStart) and filter out EOF. + let new_non_eof = filter(new_tokens_raw, |ct| !is_eof(ct)); + let new_tokens = map(new_non_eof, |ct| shift_cached(ct, relex_start)); + + // Head: tokens before the affected region. + let head = state.tokens[0:first_affected]; + + // Tail: tokens after the affected region with adjusted offsets. + let tail_slice = state.tokens[last_affected:n]; + let tail = map(tail_slice, |ct| shift_cached(ct, delta)); + + // Combine head + new + tail. + let combined = (head ++ new_tokens) ++ tail; + + // Ensure EOF is present. + let result = if has_eof(combined) { + combined + } else { + let eof_pos = len(new_source); + let eof_loc = #{ start: #{ line: 1, column: 1, offset: eof_pos }, + end_: #{ line: 1, column: 1, offset: eof_pos }, + file: "" }; + let eof_tok = #{ token: #{ type_: EOF, lexeme: "", loc: eof_loc }, + startOffset: eof_pos, endOffset: eof_pos }; + combined ++ [eof_tok] + }; + + #{ source: new_source, tokens: result } +} diff --git a/compiler/src/IncrementalParser.affine b/compiler/src/IncrementalParser.affine new file mode 100644 index 0000000..acb24ae --- /dev/null +++ b/compiler/src/IncrementalParser.affine @@ -0,0 +1,332 @@ +// SPDX-License-Identifier: MPL-2.0 +// IncrementalParser.affine — incremental parsing wrapper for Error-Lang +// (ported from compiler/src/IncrementalParser.res). +// +// Wraps Parser.parse for incremental re-parsing: cache top-level declarations +// with byte ranges; on edit, apply the text change, find overlapping decls, +// re-parse only those (via Lexer.lex + Parser.parse), splice the new decls into +// the cache adjusting byte offsets. +// +// ── AffineScript port: state model ── +// * The .res `t` has `mutable source/items/errors` mutated in place by `edit` +// (which returns only the diagnostics). AffineScript has no mutable struct +// fields, so `T` is immutable and the producers THREAD it: `make` returns a +// `T`; `edit` returns `(T, [ParseDiagnostic])` (the new state paired with +// the diagnostics the .res returned alone). Callers +// `let (t2, diags) = edit(t, e, file);`. This is the same threading the +// Parser/TypeChecker ports use. +// * Pervasive record-spread (`{...ci, start: …}`, `{...item, …}`) -> full +// reconstruction of `CachedDecl`. `Array.concatMany([a,b,c])` -> `a ++ b ++ +// c`; `Array.sliceToEnd(~start)` -> `xs[start:]`; `Array.slice(~start,~end)` +// -> `xs[a:b]`; `Option.isNone` -> `match`. +// * `Lexer.lex` / `Parser.parse` reuse the shipped modules' signatures +// (`([Token],[Diagnostic])` and `(Program,[Diagnostic])`). +// * `String.charCodeAt(src,i)` (NaN past end) -> `code_at` (= -1 past end); +// `Float.isNaN(c) || …` boundary test becomes `c == -1 || …`. +// `String.trimStart` is only used for its *length delta* (leading-ws +// count) and the all-whitespace test, so it is replaced by `leading_ws` +// (a leading-whitespace counter) — same numeric result, no full trim. +// * `formatDiagnostic` is NOT present in the shipped Types.affine (only +// `error_code_to_string` is), so it is reimplemented locally here, byte-for- +// byte faithful to Types.res `formatDiagnostic`. +// +// ── Faithfulness notes: documented gaps / simplifications vs IncrementalParser.res ── +// * No behaviour omitted. `decl_kind`, the top-keyword set, `starts_with_keyword` +// (whole-word check), `find_decl_boundaries`, `build_cache` (exact-count vs +// even-distribution fallback), `lex_source`, `full_parse`, `make`, `edit` +// (overlap scan, re-parse range, fragment re-parse, offset-adjusted splice, +// full-reparse fallback), `items`, `full_ast`, and `source` are all ported. +// * `edit`'s signature is the documented `(T, [ParseDiagnostic])` adaptation +// of the .res's mutate-and-return-diagnostics; behaviour is identical. +// * No `runTests`/Console code exists in IncrementalParser.res, so nothing is +// omitted on that account. + +module IncrementalParser; + +use prelude::*; +use string::{substring, split, char_at}; +use Lexer::{lex}; +use Parser::{parse}; +use Types::*; + +// ============================================ +// Types +// ============================================ + +/// Describes a text edit applied to the source. +pub struct Edit { + start: Int, + oldEnd: Int, + newText: String +} + +/// A cached top-level declaration with its byte range. +pub struct CachedDecl { + kind: String, + start: Int, + end_: Int, + decl: Decl +} + +/// Diagnostic from incremental parsing. +pub struct ParseDiagnostic { + message: String, + offset: Int +} + +/// The incremental parser state (immutable; threaded). +pub struct IPState { + source: String, + items: [CachedDecl], + errors: [Diagnostic] +} + +// ============================================ +// Helpers +// ============================================ + +// Code point at index `i`, or -1 at/past end of input. +fn code_at(s: String, i: Int) -> Int { + match char_at(s, i) { + Some(c) => char_to_int(c), + None => -1 + } +} + +// Count of leading ASCII-whitespace characters in `line`. +fn leading_ws(line: String) -> Int { + let n = len(line); + let mut i = 0; + let mut go = true; + while i < n && go { + let c = code_at(line, i); + if c == 32 || c == 9 || c == 13 { + i = i + 1; + } else { + go = false; + } + } + i +} + +// Local reimplementation of Types.res `formatDiagnostic` (not exported by the +// shipped Types.affine). +pub fn format_diagnostic(diag: Diagnostic) -> String { + let code_str = error_code_to_string(diag.code); + let loc = diag.loc; + "Error-LangError: " ++ loc.file ++ ":" ++ int_to_string(loc.start.line) ++ ":" ++ + int_to_string(loc.start.column) ++ ": " ++ diag.message ++ + " [code=" ++ code_str ++ " run=" ++ int_to_string(diag.runNumber) ++ "]" +} + +/// Classify a declaration to a kind string. +pub fn decl_kind(d: Decl) -> String { + match d { + FunctionDecl(_n, _p, _r, _b, _l) => "Function", + StructDecl(_n, _f, _l) => "Struct", + MainBlock(_b, _l) => "Main", + StmtDecl(_s) => "Statement" + } +} + +/// Top-level keywords that can start a declaration in Error-Lang. +pub fn top_keywords() -> [String] { + ["function", "main", "struct", "let", "if", "while", "for", "gutter"] +} + +// Is `c` an identifier-continuation byte (a-z, A-Z, 0-9, _)? +fn is_ident_code(c: Int) -> Bool { + (c >= 97 && c <= 122) || (c >= 65 && c <= 90) || (c >= 48 && c <= 57) || c == 95 +} + +/// Check if a substring at the given position starts with a top-level keyword +/// (as a whole word). +pub fn starts_with_keyword(src: String, pos: Int) -> Bool { + let kws = top_keywords(); + let slen = len(src); + let mut i = 0; + let n = len(kws); + let mut found = false; + while i < n && !found { + let kw = kws[i]; + let kw_len = len(kw); + if pos + kw_len <= slen { + let sub = substring(src, pos, pos + kw_len); + if sub == kw { + // Whole-word check: end-of-source or a non-identifier byte follows. + if pos + kw_len >= slen { + found = true; + } else { + let c = code_at(src, pos + kw_len); + if c == -1 || !is_ident_code(c) { + found = true; + } + } + } + } + i = i + 1; + } + found +} + +/// Find byte offsets of top-level declaration boundaries. +pub fn find_decl_boundaries(src: String) -> [Int] { + let lines = split(src, "\n"); + let mut boundaries = []; + let mut offset = 0; + let mut li = 0; + let nlines = len(lines); + while li < nlines { + let line = lines[li]; + let lead = leading_ws(line); + let trim_offset = offset + lead; + // `length(trimmed) > 0` <=> the line is not entirely leading whitespace. + if lead < len(line) && starts_with_keyword(src, trim_offset) { + boundaries = boundaries ++ [trim_offset]; + } + offset = offset + len(line) + 1; // +1 for newline + li = li + 1; + } + boundaries +} + +/// Build cached declarations from parsed program and source text. +pub fn build_cache(prog: Program, src: String) -> [CachedDecl] { + let boundaries = find_decl_boundaries(src); + let decls = prog.declarations; + let src_len = len(src); + let n_decls = len(decls); + let n_bounds = len(boundaries); + + if n_decls == 0 { + [] + } else if n_bounds == n_decls { + let mut out = []; + let mut i = 0; + while i < n_decls { + let s = boundaries[i]; + let e = if i + 1 < n_bounds { boundaries[i + 1] } else { src_len }; + out = out ++ [#{ kind: decl_kind(decls[i]), start: s, end_: e, decl: decls[i] }]; + i = i + 1; + } + out + } else { + // Fallback: distribute evenly. + let mut out = []; + let mut i = 0; + while i < n_decls { + let s = i * src_len / n_decls; + let e = (i + 1) * src_len / n_decls; + out = out ++ [#{ kind: decl_kind(decls[i]), start: s, end_: e, decl: decls[i] }]; + i = i + 1; + } + out + } +} + +/// Lex the source string into tokens. +pub fn lex_source(src: String, file: String) -> [Token] { + let (toks, _diags) = lex(src, file, 0); + toks +} + +/// Full parse: lex and parse the source. +pub fn full_parse(src: String, file: String) -> ([CachedDecl], [Diagnostic]) { + let (toks, lex_diags) = lex(src, file, 0); + let (prog, parse_diags) = parse(toks, file, 0); + let items = build_cache(prog, src); + (items, lex_diags ++ parse_diags) +} + +// ============================================ +// Public API +// ============================================ + +/// Create a new incremental parser by performing a full initial parse. +pub fn make(source: String, file: String) -> IPState { + let (items, errors) = full_parse(source, file); + #{ source: source, items: items, errors: errors } +} + +// Rebuild a CachedDecl with start/end shifted by `delta`. +fn shift_decl(ci: CachedDecl, delta: Int) -> CachedDecl { + #{ kind: ci.kind, start: ci.start + delta, end_: ci.end_ + delta, decl: ci.decl } +} + +/// Apply a text edit and re-parse only the affected declarations. Returns the +/// new state paired with diagnostics from the re-parsed region (the .res +/// mutated `t` in place and returned only the diagnostics — see header). +pub fn edit(t: IPState, e: Edit, file: String) -> (IPState, [ParseDiagnostic]) { + let edit_len_diff = len(e.newText) - (e.oldEnd - e.start); + + // 1. Apply text edit to source. + let before = substring(t.source, 0, e.start); + let after = substring(t.source, e.oldEnd, len(t.source)); + let new_source = before ++ e.newText ++ after; + + // 2. Find affected items. + let items = t.items; + let n_items = len(items); + let mut first_affected = -1; + let mut last_affected = -1; + let mut idx = 0; + while idx < n_items { + let item = items[idx]; + if item.end_ > e.start && item.start < e.oldEnd { + if first_affected == -1 { + first_affected = idx; + } + last_affected = idx; + } + idx = idx + 1; + } + + if first_affected == -1 { + // No overlap — full re-parse. + let (new_items, errors) = full_parse(new_source, file); + let t2 = #{ source: new_source, items: new_items, errors: errors }; + let diags = map(errors, |er| #{ message: format_diagnostic(er), offset: 0 }); + (t2, diags) + } else { + let first = first_affected; + let last = last_affected; + + // 3. Determine re-parse range. + let reparse_start = items[first].start; + let old_reparse_end = items[last].end_; + let reparse_end = min(old_reparse_end + edit_len_diff, len(new_source)); + let fragment_len = max(reparse_end - reparse_start, 0); + let fragment = substring(new_source, reparse_start, reparse_start + fragment_len); + + // 4. Re-parse the fragment. + let (toks, _lex_diags) = lex(fragment, file, 0); + let (prog, errors) = parse(toks, file, 0); + + let diagnostics = map(errors, |er| #{ message: format_diagnostic(er), offset: reparse_start }); + + // 5. Build new cached items from the fragment, offset by reparseStart. + let new_cached = map(build_cache(prog, fragment), |ci| shift_decl(ci, reparse_start)); + + // 6. Splice: before/after arrays by index ranges (after shifted by diff). + let before_items = items[0:first]; + let after_items = map(items[last + 1:n_items], |item| shift_decl(item, edit_len_diff)); + + let new_items = (before_items ++ new_cached) ++ after_items; + let t2 = #{ source: new_source, items: new_items, errors: errors }; + (t2, diagnostics) + } +} + +/// Return the current cached declarations. +pub fn items(t: IPState) -> [CachedDecl] { t.items } + +/// Reconstruct the full AST program from cached declarations. +pub fn full_ast(t: IPState) -> Program { + #{ declarations: map(t.items, |ci| ci.decl), + loc: #{ start: #{ line: 1, column: 1, offset: 0 }, + end_: #{ line: 0, column: 0, offset: len(t.source) }, + file: "" } } +} + +/// Return the current source text. +pub fn source(t: IPState) -> String { t.source } diff --git a/compiler/src/LayerNavigator.affine b/compiler/src/LayerNavigator.affine new file mode 100644 index 0000000..c1ef395 --- /dev/null +++ b/compiler/src/LayerNavigator.affine @@ -0,0 +1,333 @@ +// SPDX-License-Identifier: MPL-2.0 +// LayerNavigator.affine — navigate through abstraction layers +// (ported from compiler/src/LayerNavigator.res). +// +// Shows how code transforms through the 5 layers of abstraction: +// Grammar -> Parser -> AST -> Semantics -> Runtime. This module OWNS the +// `Layer` enum (FiveWhys.affine imports it). +// +// ── AffineScript port conventions ── +// * `open Types` -> `use Types::*;`. The `layer` type is defined here as the +// `Layer` enum; its constructors (Grammar/Parser/AST/Semantics/Runtime) are +// GLOBAL, so they live in exactly one module — here — and FiveWhys.affine +// does `use LayerNavigator::{Layer, Grammar, Parser, AST, Semantics, +// Runtime}` rather than redefining them. +// * ReScript backtick templates with `${…}` interpolation and embedded +// newlines -> `++` string concatenation with `\n` escapes; box-drawing +// glyphs (`├─`, `└─`) preserved verbatim. `String.repeat` -> string +// `repeat`. `Array.length` -> `len`. +// * `navigateToLayer`'s record spread `{...state, currentLayer}` -> full +// reconstruction, copying `selectedNode` / `layerViews` from the input +// (the latter is a `Dict`-bearing field reused from the parameter — see +// the omission note below). +// +// ── Faithfulness notes: documented gaps / simplifications vs LayerNavigator.res ── +// * `create_layer_views` is OMITTED. It builds `LayerView` records whose +// `metadata` field is typed `dict` (-> `Dict` here). As documented at length in Stability.affine, `Dict` is a +// phantom type constructor in the current AffineScript compiler with no +// value-level introduction (no literal, no builtin, statement-only unwired +// `transmute`, kind-rejected in normal fn signatures), and `Types`-style +// struct fields accept it only because record kind-checking does not +// recurse into fields. Since a `LayerView` cannot be CONSTRUCTED, the +// view-list builder is not expressible. Every per-layer STRING the builder +// would embed is ported and public (`get_grammar_rule`, `format_parse_tree`, +// `format_ast_node`, `analyze_semantics`), so a caller that supplies its +// own metadata container can assemble the views; only the `Dict`-typed +// assembly step is unavailable. `navigate_to_layer` (which only *copies* +// an existing `layerViews`) is ported and works. +// * Everything else is ported with identical output strings: the EBNF rules, +// parse-tree/AST renderings, semantic type-check text, type inference and +// matching, node-type names, the binary-op spelling, and `layer_name` / +// `layer_index`. +// * No `runTests`/Console code exists in LayerNavigator.res, so nothing else +// is omitted. + +module LayerNavigator; + +use prelude::*; +use string::{repeat}; +use Types::*; + +// The five layers of abstraction (this module owns the `layer` type). +pub enum Layer { + Grammar, // EBNF rules that define what's valid + Parser, // How text becomes structure (parse tree) + AST, // Abstract syntax tree (simplified structure) + Semantics, // Type checking, scope analysis + Runtime // Actual execution values +} + +// Layer view representation. `metadata` is `Dict` (phantom — +// see header); the struct can be declared but not constructed. +pub struct LayerView { + layer: Layer, + content: String, + highlighted: Option<(Int, Int)>, + metadata: Dict +} + +// Navigation state — which layer we're currently viewing. +pub struct NavigationState { + currentLayer: Layer, + selectedNode: Option, + layerViews: [LayerView] +} + +// NOTE: `create_layer_views` (LayerNavigator.res) is intentionally not ported — +// it constructs `LayerView` records carrying a `Dict` metadata +// field, which has no value-level introduction in AffineScript. See header. + +/// Get EBNF grammar rule for a statement. +pub fn get_grammar_rule(stmt: Stmt) -> String { + match stmt { + LetStmt(mutable_, _name, _type, _value, _loc) => + if mutable_ { + "letStmt ::= \"let\" \"mut\" identifier \"=\" expression" + } else { + "letStmt ::= \"let\" identifier \"=\" expression" + }, + IfStmt(_c, _t, _ei, _e, _loc) => + "ifStmt ::= \"if\" expression\n" ++ + " statement*\n" ++ + " (\"elseif\" expression statement*)*\n" ++ + " (\"else\" statement*)?\n" ++ + " \"end\"", + WhileStmt(_c, _b, _loc) => + "whileStmt ::= \"while\" expression\n" ++ + " statement*\n" ++ + " \"end\"", + ForStmt(_v, _it, _b, _loc) => + "forStmt ::= \"for\" identifier \"in\" expression\n" ++ + " statement*\n" ++ + " \"end\"", + PrintStmt(is_println, _args, _loc) => + if is_println { + "printStmt ::= \"println\" \"(\" expression (\",\" expression)* \")\"" + } else { + "printStmt ::= \"print\" \"(\" expression (\",\" expression)* \")\"" + }, + GutterBlock(_tk, _rec, _loc) => + "gutterBlock ::= \"gutter\"\n" ++ + " statement*\n" ++ + " \"end\"", + ExprStmt(_e) => + "exprStmt ::= expression", + _ => + "statement ::= /* unknown */" + } +} + +/// Format parse tree for display. +pub fn format_parse_tree(stmt: Stmt) -> String { + match stmt { + LetStmt(mutable_, name, _type, value, _loc) => { + let mut_str = if mutable_ { "mut " } else { "" }; + "letStmt\n" ++ + "├─ \"let\"\n" ++ + "├─ " ++ mut_str ++ "identifier(\"" ++ name ++ "\")\n" ++ + "├─ \"=\"\n" ++ + "└─ " ++ format_expr_parse_tree(value, 1) + }, + PrintStmt(is_println, args, _loc) => { + let fname = if is_println { "println" } else { "print" }; + "printStmt\n" ++ + "├─ \"" ++ fname ++ "\"\n" ++ + "├─ \"(\"\n" ++ + "├─ args: " ++ int_to_string(len(args)) ++ "\n" ++ + "└─ \")\"" + }, + _ => + "statement (simplified)" + } +} + +fn format_expr_parse_tree(expr: Expr, depth: Int) -> String { + let indent = repeat(" ", depth); + match expr { + IntLit(n, _loc) => + "literal(" ++ int_to_string(n) ++ ")", + StringLit(s, _loc) => + "literal(\"" ++ s ++ "\")", + Ident(name, _loc) => + "identifier(\"" ++ name ++ "\")", + Binary(left, op, right, _loc) => + "binary\n" ++ + indent ++ "├─ " ++ format_expr_parse_tree(left, depth + 1) ++ "\n" ++ + indent ++ "├─ operator(" ++ binary_op_to_string(op) ++ ")\n" ++ + indent ++ "└─ " ++ format_expr_parse_tree(right, depth + 1), + _ => + "expression" + } +} + +/// Format AST node for display. +pub fn format_ast_node(stmt: Stmt) -> String { + match stmt { + LetStmt(mutable_, name, type_, value, loc) => { + let type_str = match type_ { + Some(t) => ": " ++ type_expr_to_string(t), + None => "" + }; + "LetStmt {\n" ++ + " name: \"" ++ name ++ "\",\n" ++ + " mutable: " ++ (if mutable_ { "true" } else { "false" }) ++ ",\n" ++ + " type: " ++ type_str ++ ",\n" ++ + " value: " ++ format_expr_ast(value) ++ ",\n" ++ + " loc: line " ++ int_to_string(loc.start.line) ++ "\n" ++ + "}" + }, + PrintStmt(is_println, args, _loc) => + "PrintStmt {\n" ++ + " println: " ++ (if is_println { "true" } else { "false" }) ++ ",\n" ++ + " args: [" ++ int_to_string(len(args)) ++ " expressions]\n" ++ + "}", + _ => + "Statement { ... }" + } +} + +fn format_expr_ast(expr: Expr) -> String { + match expr { + IntLit(n, _loc) => "IntLit(" ++ int_to_string(n) ++ ")", + StringLit(s, _loc) => "StringLit(\"" ++ s ++ "\")", + Ident(name, _loc) => "Ident(\"" ++ name ++ "\")", + Binary(left, op, right, _loc) => + "Binary(" ++ format_expr_ast(left) ++ ", " ++ binary_op_to_string(op) ++ ", " ++ format_expr_ast(right) ++ ")", + _ => "Expr(...)" + } +} + +/// Analyze semantics for a node. +pub fn analyze_semantics(stmt: Stmt) -> String { + match stmt { + LetStmt(_mutable, name, type_, value, _loc) => { + let inferred_type = infer_expr_type(value); + let type_check = match type_ { + Some(annotated) => + if type_exprs_match(annotated, inferred_type) { + "✓ Type check passed: " ++ type_expr_to_string(annotated) + } else { + "✗ Type mismatch: expected " ++ type_expr_to_string(annotated) ++ ", got " ++ type_expr_to_string(inferred_type) + }, + None => + "Type inferred: " ++ type_expr_to_string(inferred_type) + }; + "Variable: " ++ name ++ "\n" ++ + "Scope: local\n" ++ + type_check + }, + _ => + "Semantic analysis: [pending]" + } +} + +/// Infer type of an expression (simplified). +pub fn infer_expr_type(expr: Expr) -> TypeExpr { + match expr { + IntLit(_n, _loc) => TyInt, + FloatLit(_f, _loc) => TyFloat, + StringLit(_s, _loc) => TyString, + BoolLit(_b, _loc) => TyBool, + _ => TyInt // Simplified + } +} + +/// Check if two type expressions match. +pub fn type_exprs_match(t1: TypeExpr, t2: TypeExpr) -> Bool { + match (t1, t2) { + (TyInt, TyInt) => true, + (TyFloat, TyFloat) => true, + (TyString, TyString) => true, + (TyBool, TyBool) => true, + _ => false + } +} + +/// Convert type expression to string. +pub fn type_expr_to_string(t: TypeExpr) -> String { + match t { + TyInt => "Int", + TyFloat => "Float", + TyString => "String", + TyBool => "Bool", + TyArray(inner) => "Array<" ++ type_expr_to_string(inner) ++ ">", + TyEcho(a, b) => + match a { + None => "Echo", + Some(ta) => match b { + None => "Echo<" ++ type_expr_to_string(ta) ++ ">", + Some(tb) => "Echo<" ++ type_expr_to_string(ta) ++ ", " ++ type_expr_to_string(tb) ++ ">" + } + }, + TyEchoResidue(a, b) => + match a { + None => "EchoR", + Some(ta) => match b { + None => "EchoR<" ++ type_expr_to_string(ta) ++ ">", + Some(tb) => "EchoR<" ++ type_expr_to_string(ta) ++ ", " ++ type_expr_to_string(tb) ++ ">" + } + }, + TyIdent(name) => name + } +} + +/// Get node type name. +pub fn node_type_name(stmt: Stmt) -> String { + match stmt { + LetStmt(_m, _n, _t, _v, _l) => "LetStmt", + AssignStmt(_t, _v, _l) => "AssignStmt", + IfStmt(_c, _t, _ei, _e, _l) => "IfStmt", + WhileStmt(_c, _b, _l) => "WhileStmt", + ForStmt(_v, _it, _b, _l) => "ForStmt", + PrintStmt(_p, _a, _l) => "PrintStmt", + GutterBlock(_tk, _r, _l) => "GutterBlock", + ExprStmt(_e) => "ExprStmt", + _ => "Statement" + } +} + +/// Binary operator to string. +pub fn binary_op_to_string(op: BinaryOp) -> String { + match op { + Add => "+", + Sub => "-", + Mul => "*", + Div => "/", + Mod => "%", + Eq => "==", + Neq => "!=", + Lt => "<", + Gt => ">", + Lte => "<=", + Gte => ">=", + _ => "op" + } +} + +/// Navigate to a specific layer (full reconstruction; copies the rest). +pub fn navigate_to_layer(state: NavigationState, target_layer: Layer) -> NavigationState { + #{ currentLayer: target_layer, selectedNode: state.selectedNode, layerViews: state.layerViews } +} + +/// Get layer name for display. +pub fn layer_name(layer: Layer) -> String { + match layer { + Grammar => "Grammar (EBNF)", + Parser => "Parser (Parse Tree)", + AST => "AST (Abstract Syntax)", + Semantics => "Semantics (Type Check)", + Runtime => "Runtime (Execution)" + } +} + +/// Get layer index (for visualization). +pub fn layer_index(layer: Layer) -> Int { + match layer { + Grammar => 0, + Parser => 1, + AST => 2, + Semantics => 3, + Runtime => 4 + } +} diff --git a/compiler/src/Pretty.affine b/compiler/src/Pretty.affine new file mode 100644 index 0000000..a360878 --- /dev/null +++ b/compiler/src/Pretty.affine @@ -0,0 +1,515 @@ +// SPDX-License-Identifier: MPL-2.0 +// Pretty.affine — pretty-printer for the Error-Lang AST +// (ported from compiler/src/Pretty.res). +// +// Converts AST nodes into formatted source strings: main/end blocks, gutter +// blocks, let/if/while/for statements, function declarations, struct +// definitions. Used by `fmt`, codegen output, and REPL display. +// +// ── AffineScript port: state model ── +// Pretty.res threads an imperative `printer` with `mutable buf` / `mutable +// indent`. AffineScript has no mutable struct fields, so `Printer` is an +// immutable struct and every printing helper TAKES a `Printer` and RETURNS a +// new one (full-record reconstruction). The mutually-recursive ReScript +// `let rec … and …` block becomes ordinary top-level `fn`s that thread the +// printer left-to-right (`let p1 = ppx(p, …); let p2 = ppy(p1, …); …`). +// * `emit(p, s)` -> rebuild with `buf ++ s`. +// * `newline(p)` -> append "\n" then `indent * indentWidth` spaces (built +// with the string `repeat`). +// * `indented(p, f)` -> bump `indent`, thread the body, then restore the +// indent to its prior value (the printer returned by the body carries the +// accumulated buffer; only `indent` is reset). +// * `Array.forEachWithIndex` -> an index-carrying fold over the list, +// threading the printer; `i > 0` separators are reproduced exactly. +// +// ── Faithfulness notes: documented gaps / simplifications vs Pretty.res ── +// * No behaviour omitted: identical output for every node, including the +// "(lhs op rhs)" parenthesisation of binaries, the `"` / `\` escaping in +// string literals, the ` ? : ` ternary, `fn(...) -> T => e` / `{ … }` +// lambdas, `elseif`/`else` chains, and the trailing newline after a +// program. The public API (`program_to_string`, +// `program_to_string_with_config`, `expr_to_string`, `stmt_to_string`, +// `decl_to_string`) is preserved. +// * No `runTests`/Console code exists in Pretty.res, so nothing is omitted. + +module Pretty; + +use prelude::*; +use string::{repeat, replace}; +use Types::*; + +// ============================================================ +// Configuration +// ============================================================ + +pub struct Config { + indentWidth: Int, // Spaces per indent level (2 or 4) + maxWidth: Int // Advisory max line width +} + +pub fn default_config() -> Config { + #{ indentWidth: 2, maxWidth: 100 } +} + +// ============================================================ +// Internal printer (immutable; threaded) +// ============================================================ + +struct Printer { + buf: String, + indent: Int, + config: Config +} + +fn make_printer(config: Config) -> Printer { + #{ buf: "", indent: 0, config: config } +} + +fn emit(p: Printer, s: String) -> Printer { + #{ buf: p.buf ++ s, indent: p.indent, config: p.config } +} + +fn with_indent(p: Printer, indent: Int) -> Printer { + #{ buf: p.buf, indent: indent, config: p.config } +} + +fn newline(p: Printer) -> Printer { + let pad = repeat(" ", p.indent * p.config.indentWidth); + emit(p, "\n" ++ pad) +} + +// ============================================================ +// Type expressions +// ============================================================ + +fn pp_type_expr(p: Printer, ty: TypeExpr) -> Printer { + match ty { + TyInt => emit(p, "int"), + TyFloat => emit(p, "float"), + TyString => emit(p, "string"), + TyBool => emit(p, "bool"), + TyArray(inner) => { + let p1 = emit(p, "["); + let p2 = pp_type_expr(p1, inner); + emit(p2, "]") + }, + TyEcho(a, b) => pp_echo_like(p, "Echo", a, b), + TyEchoResidue(a, b) => pp_echo_like(p, "EchoR", a, b), + TyIdent(name) => emit(p, name) + } +} + +// Print an Echo/EchoR head with its optional `` or `` arguments. +fn pp_echo_like(p: Printer, head: String, a: Option, b: Option) -> Printer { + let p0 = emit(p, head); + match a { + None => p0, + Some(ta) => match b { + None => { + let p1 = emit(p0, "<"); + let p2 = pp_type_expr(p1, ta); + emit(p2, ">") + }, + Some(tb) => { + let p1 = emit(p0, "<"); + let p2 = pp_type_expr(p1, ta); + let p3 = emit(p2, ", "); + let p4 = pp_type_expr(p3, tb); + emit(p4, ">") + } + } + } +} + +fn binary_op_str(op: BinaryOp) -> String { + match op { + Add => "+", + Sub => "-", + Mul => "*", + Div => "/", + Mod => "%", + Eq => "==", + Neq => "!=", + Lt => "<", + Gt => ">", + Lte => "<=", + Gte => ">=", + BAnd => "&", + BOr => "|", + BXor => "^", + Shl => "<<", + Shr => ">>", + LAnd => "and", + LOr => "or" + } +} + +fn pp_unary_op(p: Printer, op: UnaryOp) -> Printer { + match op { + Neg => emit(p, "-"), + LNot => emit(p, "not "), + BNot => emit(p, "~") + } +} + +// ============================================================ +// Expressions +// ============================================================ + +fn pp_expr(p: Printer, expr: Expr) -> Printer { + match expr { + IntLit(n, _loc) => emit(p, int_to_string(n)), + FloatLit(f, _loc) => emit(p, float_to_string(f)), + StringLit(s, _loc) => { + let p1 = emit(p, "\""); + let escaped = replace(replace(s, "\\", "\\\\"), "\"", "\\\""); + let p2 = emit(p1, escaped); + emit(p2, "\"") + }, + BoolLit(b, _loc) => emit(p, if b { "true" } else { "false" }), + NilLit(_loc) => emit(p, "nil"), + Ident(name, _loc) => emit(p, name), + + Array(elems, _loc) => { + let p1 = emit(p, "["); + let p2 = pp_expr_list(p1, elems); + emit(p2, "]") + }, + + Binary(lhs, op, rhs, _loc) => { + let p1 = emit(p, "("); + let p2 = pp_expr(p1, lhs); + let p3 = emit(p2, " "); + let p4 = emit(p3, binary_op_str(op)); + let p5 = emit(p4, " "); + let p6 = pp_expr(p5, rhs); + emit(p6, ")") + }, + + Unary(op, operand, _loc) => { + let p1 = pp_unary_op(p, op); + pp_expr(p1, operand) + }, + + Call(callee, args, _loc) => { + let p1 = pp_expr(p, callee); + let p2 = emit(p1, "("); + let p3 = pp_expr_list(p2, args); + emit(p3, ")") + }, + + Index(target, index, _loc) => { + let p1 = pp_expr(p, target); + let p2 = emit(p1, "["); + let p3 = pp_expr(p2, index); + emit(p3, "]") + }, + + Member(target, field, _loc) => { + let p1 = pp_expr(p, target); + let p2 = emit(p1, "."); + emit(p2, field) + }, + + Ternary(cond, then_, else_, _loc) => { + let p1 = pp_expr(p, cond); + let p2 = emit(p1, " ? "); + let p3 = pp_expr(p2, then_); + let p4 = emit(p3, " : "); + pp_expr(p4, else_) + }, + + Lambda(params, ret_ty, body, _loc) => { + let p1 = emit(p, "fn("); + let p2 = pp_param_list(p1, params); + let p3 = emit(p2, ")"); + let p4 = match ret_ty { + Some(ty) => { + let pa = emit(p3, " -> "); + pp_type_expr(pa, ty) + }, + None => p3 + }; + match body { + LambdaExpr(e) => { + let pb = emit(p4, " => "); + pp_expr(pb, e) + }, + LambdaBlock(stmts) => { + let pb = emit(p4, " {"); + let pc = pp_stmt_block(pb, stmts); + let pd = newline(pc); + emit(pd, "}") + } + } + } + } +} + +// Comma-separated expression list (`Array.forEachWithIndex` with `i > 0` sep). +fn pp_expr_list(p: Printer, elems: [Expr]) -> Printer { + let mut pp = p; + let mut i = 0; + let n = len(elems); + while i < n { + if i > 0 { pp = emit(pp, ", "); } + pp = pp_expr(pp, elems[i]); + i = i + 1; + } + pp +} + +// Comma-separated parameter list: `name` then optional `: Type`. +fn pp_param_list(p: Printer, params: [Param]) -> Printer { + let mut pp = p; + let mut i = 0; + let n = len(params); + while i < n { + if i > 0 { pp = emit(pp, ", "); } + let param = params[i]; + pp = emit(pp, param.name); + pp = match param.type_ { + Some(ty) => { + let pa = emit(pp, ": "); + pp_type_expr(pa, ty) + }, + None => pp + }; + i = i + 1; + } + pp +} + +// ============================================================ +// Statements +// ============================================================ + +fn pp_stmt(p: Printer, stmt: Stmt) -> Printer { + match stmt { + LetStmt(mutable_, name, type_, value, _loc) => { + let p1 = emit(p, "let "); + let p2 = if mutable_ { emit(p1, "mutable ") } else { p1 }; + let p3 = emit(p2, name); + let p4 = match type_ { + Some(ty) => { + let pa = emit(p3, ": "); + pp_type_expr(pa, ty) + }, + None => p3 + }; + let p5 = emit(p4, " = "); + pp_expr(p5, value) + }, + + AssignStmt(target, value, _loc) => { + let p1 = pp_expr(p, target); + let p2 = emit(p1, " = "); + pp_expr(p2, value) + }, + + IfStmt(cond, then_, elseifs, else_, _loc) => { + let p1 = emit(p, "if "); + let p2 = pp_expr(p1, cond); + let p3 = pp_stmt_block(p2, then_); + let p4 = pp_elseifs(p3, elseifs); + let p5 = match else_ { + Some(else_body) => { + let pa = newline(p4); + let pb = emit(pa, "else"); + pp_stmt_block(pb, else_body) + }, + None => p4 + }; + let p6 = newline(p5); + emit(p6, "end") + }, + + WhileStmt(cond, body, _loc) => { + let p1 = emit(p, "while "); + let p2 = pp_expr(p1, cond); + let p3 = pp_stmt_block(p2, body); + let p4 = newline(p3); + emit(p4, "end") + }, + + ForStmt(var, iter, body, _loc) => { + let p1 = emit(p, "for "); + let p2 = emit(p1, var); + let p3 = emit(p2, " in "); + let p4 = pp_expr(p3, iter); + let p5 = pp_stmt_block(p4, body); + let p6 = newline(p5); + emit(p6, "end") + }, + + ReturnStmt(value, _loc) => { + let p1 = emit(p, "return"); + match value { + Some(v) => { + let pa = emit(p1, " "); + pp_expr(pa, v) + }, + None => p1 + } + }, + + BreakStmt(_loc) => emit(p, "break"), + + ContinueStmt(_loc) => emit(p, "continue"), + + PrintStmt(is_println, args, _loc) => { + let p1 = emit(p, if is_println { "println" } else { "print" }); + let p2 = emit(p1, "("); + let p3 = pp_expr_list(p2, args); + emit(p3, ")") + }, + + GutterBlock(_tokens, _recovered, _loc) => emit(p, "gutter { ... }"), + + ExprStmt(e) => pp_expr(p, e) + } +} + +// Indented block of statements: each statement on its own newline. +fn pp_stmt_block(p: Printer, stmts: [Stmt]) -> Printer { + let outer = p.indent; + let mut pp = with_indent(p, outer + 1); + let mut i = 0; + let n = len(stmts); + while i < n { + pp = newline(pp); + pp = pp_stmt(pp, stmts[i]); + i = i + 1; + } + with_indent(pp, outer) +} + +fn pp_elseifs(p: Printer, elseifs: [(Expr, [Stmt])]) -> Printer { + let mut pp = p; + let mut i = 0; + let n = len(elseifs); + while i < n { + let (eif_cond, eif_body) = elseifs[i]; + pp = newline(pp); + pp = emit(pp, "elseif "); + pp = pp_expr(pp, eif_cond); + pp = pp_stmt_block(pp, eif_body); + i = i + 1; + } + pp +} + +// ============================================================ +// Declarations +// ============================================================ + +fn pp_decl(p: Printer, decl: Decl) -> Printer { + match decl { + FunctionDecl(name, params, return_type, body, _loc) => { + let p1 = emit(p, "function "); + let p2 = emit(p1, name); + let p3 = emit(p2, "("); + let p4 = pp_param_list(p3, params); + let p5 = emit(p4, ")"); + let p6 = match return_type { + Some(ty) => { + let pa = emit(p5, " -> "); + pp_type_expr(pa, ty) + }, + None => p5 + }; + let p7 = pp_stmt_block(p6, body); + let p8 = newline(p7); + emit(p8, "end") + }, + + StructDecl(name, fields, _loc) => { + let p1 = emit(p, "struct "); + let p2 = emit(p1, name); + let p3 = pp_struct_fields(p2, fields); + let p4 = newline(p3); + emit(p4, "end") + }, + + MainBlock(body, _loc) => { + let p1 = emit(p, "main"); + let p2 = pp_stmt_block(p1, body); + let p3 = newline(p2); + emit(p3, "end") + }, + + StmtDecl(stmt) => pp_stmt(p, stmt) + } +} + +// Indented `name: Type` fields for a struct declaration. +fn pp_struct_fields(p: Printer, fields: [(String, TypeExpr)]) -> Printer { + let outer = p.indent; + let mut pp = with_indent(p, outer + 1); + let mut i = 0; + let n = len(fields); + while i < n { + let (fname, ftype) = fields[i]; + pp = newline(pp); + pp = emit(pp, fname); + pp = emit(pp, ": "); + pp = pp_type_expr(pp, ftype); + i = i + 1; + } + with_indent(pp, outer) +} + +fn pp_program(p: Printer, prog: Program) -> Printer { + let mut pp = p; + let mut i = 0; + let decls = prog.declarations; + let n = len(decls); + while i < n { + if i > 0 { + pp = newline(pp); + pp = newline(pp); + } + pp = pp_decl(pp, decls[i]); + i = i + 1; + } + emit(pp, "\n") +} + +// ============================================================ +// Public API +// ============================================================ + +/// Pretty-print a program to a string with default configuration. +pub fn program_to_string(prog: Program) -> String { + let p = make_printer(default_config()); + let p2 = pp_program(p, prog); + p2.buf +} + +/// Pretty-print a program with custom configuration. +pub fn program_to_string_with_config(prog: Program, config: Config) -> String { + let p = make_printer(config); + let p2 = pp_program(p, prog); + p2.buf +} + +/// Pretty-print a single expression to a string. +pub fn expr_to_string(expr: Expr) -> String { + let p = make_printer(default_config()); + let p2 = pp_expr(p, expr); + p2.buf +} + +/// Pretty-print a single statement to a string. +pub fn stmt_to_string(stmt: Stmt) -> String { + let p = make_printer(default_config()); + let p2 = pp_stmt(p, stmt); + p2.buf +} + +/// Pretty-print a single declaration to a string. +pub fn decl_to_string(decl: Decl) -> String { + let p = make_printer(default_config()); + let p2 = pp_decl(p, decl); + p2.buf +} diff --git a/compiler/src/Stability.affine b/compiler/src/Stability.affine new file mode 100644 index 0000000..8459211 --- /dev/null +++ b/compiler/src/Stability.affine @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: MPL-2.0 +// Stability.affine — stability analysis & consequence amplification +// (ported from compiler/src/Stability.res). +// +// ── AffineScript port conventions ── +// * `open Types` -> `use Types::*;`. The shared stability arithmetic +// (`calculate_stability`, `stability_impact`) and the `StabilityFactor` +// constructors (`MutableState`/…) live in Types.affine and are reused +// verbatim; ReScript inline-record variant payloads (`MutableState({…})`) +// are positional there (`MutableState(mutations, readers)`). +// * The `paradox` / `consequence` / `discovery` enums are ported with +// positional constructor args (ReScript inline records -> positional). +// * `Dict.make`/`Dict.set` -> the stdlib `dict` assoc-list (`[(String,Int)]`, +// which is exactly `Dict` in Types.affine); `breakdown` is +// folded with `dict::insert`. +// * `Float.sqrt(Int.toFloat(x))->Float.toInt` -> `trunc(sqrt(float(x)))`. +// * `String.split(s,"")`/`Array.reverse` -> `split`/`collections::reverse`; +// `String.repeat` -> the string-`repeat` (selectively imported to avoid the +// prelude list-`repeat` of the same name); `Array.includes` -> `contains`; +// `Array.map(...)->Array.flat` -> `flat_map`. +// +// ── Faithfulness notes: documented gaps / simplifications vs Stability.res ── +// * `generate_report` is OMITTED — it is the one piece that is NOT expressible +// in AffineScript as it stands. It must construct a `Types::StabilityReport`, +// whose `breakdown` field is typed `Dict`. `Dict` is a phantom +// type constructor in the current compiler: it is accepted in annotations +// (struct-field lowering does not recurse into the field's kind — see +// lib/typecheck.ml `register_type_decl`/`infer_kind` where `TRecord` yields +// `KType` without inspecting fields), but it has NO value-level +// introduction anywhere — no Dict literal, no Dict-returning builtin or +// stdlib fn, `transmute<…>` is statement-only and unwired in the checker, +// and any *normal* `fn` mentioning `Dict<_,_>` in its signature is rejected +// at kind-check ("Too many arguments for kind"). The stdlib `dict` module is +// the assoc-list `[(String, V)]`, a DISTINCT nominal type that does not +// unify with `Dict`. Because Types.affine is a shipped module +// that must not be modified, the `breakdown` field type is fixed, so no +// `StabilityReport` value can be built. The report's *arithmetic* survives +// intact via `Types::calculate_stability` + `stability_impact` and the +// per-factor `recommend_stabilization`/`factor_category` helpers below +// (all ported); a caller that already holds a `StabilityReport` (e.g. via +// `check_discovery`, `Analyzer::format_stability_report`) works unchanged. +// * Everything else is ported with identical arithmetic and output strings: +// every paradox/consequence/discovery constructor, the position-hash +// operator arithmetic, the prime/Fibonacci/palindrome scope-leak +// predicates, the per-factor recommendations, discovery checks, the +// stability bar and the consequence emoji (UTF-8 glyphs preserved exactly). + +module Stability; + +use prelude::*; +use string::{split, repeat}; +use collections::{reverse}; +use math::{sqrt}; +use Types::*; + +// ============================================ +// Paradox Detection (Language Archaeology) +// ============================================ + +pub enum Paradox { + // (keyword, line, depth, isKeyword, reason) + ContextCollapseKeyword(String, Int, Int, Bool, String), + // (operator, line, column, behavior, alternatives) + PositionalOperator(String, Int, Int, String, [(Int, String)]), + // (variable, line, possibleTypes, collapseTarget) + TypeSuperposition(String, Int, [String], String), + // (variable, declaredLine, accessLine, runNumber, leakReason) + ScopeLeakage(String, Int, Int, Int, String), + // (variable, line, affectedByRun, mechanism) + TemporalCorruption(String, Int, Int, String) +} + +// Check if keyword collapses to identifier based on context +pub fn is_context_collapse(word: String, depth: Int, line: Int, column: Int) -> Bool { + if word == "end" { depth % 3 != 0 } // 'end' is identifier at depth 1, 2, 4, 5... + else if word == "let" { column % 5 != 2 } // 'let' is identifier at certain columns + else if word == "if" { depth >= 10 } // Deep nesting makes 'if' an identifier + else if word == "function" { line % 7 == 0 } // Line divisible by 7 makes it identifier + else { false } +} + +// Determine operator behavior based on position +pub fn operator_behavior_from_position(op: String, line: Int, column: Int) -> String { + let hash = (line * 31 + column) % 4; + if op == "+" && hash == 0 { "addition" } + else if op == "+" && hash == 1 { "concatenation" } + else if op == "+" && hash == 2 { "subtraction" } + else if op == "+" && hash == 3 { "xor" } + else if op == "=>" && hash == 0 { "lambda" } + else if op == "=>" && hash == 1 { "comparison" } + else if op == "." && hash == 0 { "member-access" } + else if op == "." && hash == 1 { "range" } + else { "unknown" } +} + +// Get alternative operator behaviors at different columns +pub fn alternative_operator_behaviors(op: String, line: Int, current_col: Int) -> [(Int, String)] { + [ + (current_col - 1, operator_behavior_from_position(op, line, current_col - 1)), + (current_col + 1, operator_behavior_from_position(op, line, current_col + 1)), + (current_col + 2, operator_behavior_from_position(op, line, current_col + 2)) + ] +} + +// Check if scope should leak on this run +pub fn is_prime(n: Int) -> Bool { + if n < 2 { false } + else if n == 2 { true } + else if n % 2 == 0 { false } + else { + let mut i = 3; + let mut result = true; + let mut go = true; + while go { + if i * i > n { go = false; } + else if n % i == 0 { result = false; go = false; } + else { i = i + 2; } + } + result + } +} + +fn is_perfect_square(x: Int) -> Bool { + if x < 0 { + false + } else { + let r = trunc(sqrt(float(x))); + r * r == x + } +} + +pub fn is_fibonacci(n: Int) -> Bool { + // Quick Fibonacci check using golden ratio property + is_perfect_square(5 * n * n + 4) || is_perfect_square(5 * n * n - 4) +} + +fn is_palindrome(s: String) -> Bool { + let chars = split(s, ""); + chars == reverse(chars) +} + +pub fn should_scope_leak(var_name: String, line: Int, run_number: Int) -> Bool { + is_prime(run_number) || is_palindrome(var_name) || is_fibonacci(line) +} + +// ============================================ +// Consequence Amplification +// ============================================ + +pub enum Consequence { + // (variable, mutationLine, affectedLocations, stabilityPenalty) + MutationRipple(String, Int, [Int], Int), + // (variable, typeLine, cascadeDepth, conflicts) + TypeCascade(String, Int, Int, [(Int, String)]), + // (origin, poisonedPath, finalStability) + NullPoison(Int, [(Int, String)], Int), + // (globalName, mutationLine, destabilizedFunctions, stabilityLoss) + GlobalEarthquake(String, Int, [String], Int) +} + +// Analyze mutation ripple effect +pub fn analyze_mutation_ripple(var_name: String, mutation_loc: Location, readers: [Location]) -> Consequence { + let affected_lines = map(readers, |loc| loc.start.line); + let penalty = 10 + len(readers) * 5; + MutationRipple(var_name, mutation_loc.start.line, affected_lines, penalty) +} + +// Track type instability cascade +pub fn analyze_type_cascade(var_name: String, reassignments: [(Location, TypeExpr)]) -> Consequence { + let conflicts = map(reassignments, |pair| { + let (loc, _typ) = pair; + (loc.start.line, "type-conflict") + }); + let type_line = if len(reassignments) > 0 { + let (loc0, _t0) = reassignments[0]; + loc0.start.line + } else { + 0 + }; + TypeCascade(var_name, type_line, len(reassignments), conflicts) +} + +// ============================================ +// Stability Recommendations +// ============================================ + +pub fn recommend_stabilization(factor: StabilityFactor) -> [String] { + match factor { + MutableState(_mutations, readers) => [ + "Consider using immutable data structures", + "This mutation affects " ++ int_to_string(readers) ++ " other locations", + "Alternative: Use functional updates (map, filter, reduce)" + ], + TypeInstability(reassignments) => [ + "Add explicit type annotation to prevent reassignment", + "Type changed " ++ int_to_string(reassignments) ++ " times", + "Alternative: Use different variable names for different types" + ], + NullPropagation(depth) => [ + "Use pattern matching to handle null cases explicitly", + "Null propagated through " ++ int_to_string(depth) ++ " levels", + "Alternative: Use Option type with match expression" + ], + GlobalState(_mutations, dependencies) => [ + "Pass state as function parameters instead of using globals", + int_to_string(dependencies) ++ " functions depend on this global", + "Alternative: Use a state struct passed explicitly" + ], + UnhandledError(paths) => [ + "Add error handling with Result type", + int_to_string(paths) ++ " error paths unhandled", + "Alternative: Propagate errors explicitly with match" + ], + AlgorithmComplexity(time_ms) => [ + "Consider more efficient algorithm", + "Execution took " ++ float_to_string(time_ms) ++ "ms (amplified)", + "Alternative: Use hash-based lookup or better data structure" + ], + MemoryLeak(bytes) => [ + "Resources must be freed explicitly", + "Leaked " ++ int_to_string(bytes) ++ " bytes", + "Alternative: Use 'with' statement for automatic cleanup" + ], + RaceCondition(conflicts) => [ + "Synchronize access to shared state", + int_to_string(conflicts) ++ " race conditions detected", + "Alternative: Use Mutex or atomic operations" + ] + } +} + +// Category label for a factor in the report breakdown (`generate_report`'s +// `Dict` key). Ported faithfully; usable by any caller that builds an +// assoc-list breakdown itself, even though `generate_report` (which would +// need a `Dict`) is not expressible — see header. +pub fn factor_category(factor: StabilityFactor) -> String { + match factor { + MutableState(_m, _r) => "mutability", + TypeInstability(_x) => "types", + NullPropagation(_d) => "null-handling", + GlobalState(_m, _d) => "global-state", + UnhandledError(_p) => "error-handling", + AlgorithmComplexity(_t) => "performance", + MemoryLeak(_b) => "memory", + RaceCondition(_c) => "concurrency" + } +} + +// NOTE: `generate_report` (Stability.res) is intentionally not ported — it +// constructs a `Types::StabilityReport`, whose `breakdown: Dict` +// field has no value-level introduction in AffineScript. See the header +// faithfulness note for the full rationale. The recommendation list it would +// build is available directly: `flat_map(recommend_stabilization, factors)`. + +// ============================================ +// Discovery System (Achievement Tracking) +// ============================================ + +pub enum Discovery { + FirstStabilization, + CacheDetective, + PureFunctionalConvert, + MemoryArchaeologist, + AsyncEnlightenment, + ParadoxDiscovered(String), + RuleUnlocked(String) +} + +pub fn check_discovery(state: RuntimeState, report: StabilityReport) -> Option { + // First time reaching 100 stability + if report.score >= 100 && !contains(state.historicalRuns, 100) { + Some(FirstStabilization) + } + // Discovered a paradox + else if len(state.discoveredRules) > len(state.historicalRuns) { + let n = len(state.discoveredRules); + let last = if n > 0 { state.discoveredRules[n - 1] } else { "unknown" }; + Some(RuleUnlocked(last)) + } + else { + None + } +} + +// ============================================ +// Visualization Helpers +// ============================================ + +pub fn stability_bar(score: Int) -> String { + let filled = score / 5; // 20 blocks for 100 score + let empty = 20 - filled; + let bar = repeat("█", filled) ++ repeat("░", empty); + "[" ++ bar ++ "] " ++ int_to_string(score) ++ "/100" +} + +pub fn consequence_emoji(factor: StabilityFactor) -> String { + match factor { + MutableState(_m, _r) => "🔴", + TypeInstability(_x) => "⚠️", + NullPropagation(_d) => "☠️", + GlobalState(_m, _d) => "🌍", + UnhandledError(_p) => "💥", + AlgorithmComplexity(_t) => "🐌", + MemoryLeak(_b) => "💧", + RaceCondition(_c) => "⚡" + } +} diff --git a/compiler/src/TokenStream.affine b/compiler/src/TokenStream.affine new file mode 100644 index 0000000..53520f5 --- /dev/null +++ b/compiler/src/TokenStream.affine @@ -0,0 +1,537 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// TokenStream.affine — token stream API for macro systems +// (ported from compiler/src/TokenStream.res). +// +// The foundational token-stream abstraction used by procedural/declarative +// macros. A token stream is an array of `TokenTree` values (Ident / Punct / +// Literal / Group), modelled on Rust's `proc_macro::TokenStream`. +// +// ── AffineScript port conventions ── +// * `open Types` -> `use Types::*;` (TokenStream defines its own surface and +// borrows nothing from Types except participating in the same module set; +// the glob import is harmless — none of its own constructors collide). +// * ReScript inline-record variants (`TtIdent({sym, span})`) -> positional +// constructors (`TtIdent(String, TsSpan)`), matched positionally. +// * `tokenStream = array` -> the type alias `TokenStream = +// [TokenTree]`; the recursive `TtGroup` carries a `[TokenTree]` directly +// (same self-reference shape as Cst.affine's CstNode/CstTree). +// * The `ofString` lexer is rendered functionally: every ReScript `ref` cell +// (`pos`/`stack`/`trees`/`error`/`done`/`depth`/…) becomes a `let mut` +// local threaded through the driver `while`. The nested-group stack +// `list{(delim,int,array)}` becomes a `[(Delimiter, Int, [TokenTree])]` +// used front-as-top (`xs[0]` / `xs[1:]`). +// * Single characters: dispatch is on the code point (`code_at` = -1 at EOF, +// mirroring the .res `peekAt`/`charAt` Option logic); where a one-char +// *string* is needed (`TtPunct` text, error messages) it is taken with +// `substring(s, i, i+1)`. `String.charCodeAt` -> `char_to_int(char_at)`. +// * `String.slice(~start,~end)` / `String.substring(~start,~end)` -> +// `substring(s, start, end)`; `Array.concat`/`Array.flatMap` -> +// `++` / `flat_map`; `result<…>` -> `Result<…>` from prelude. +// +// ── Faithfulness notes: documented gaps / simplifications vs TokenStream.res ── +// * No behaviour omitted. Constructors, accessors (`span_of`, `is_empty`, +// `length`, `stream_span`), stream ops (`concat`, `push`, `flatten`), +// source reconstruction (`tree_to_string`/`to_source` with `Joint`-aware +// spacing), and the full `of_string` lexer (identifiers, keywords-as- +// identifiers, decimal/hex/binary/octal/float numerics, string & char +// literals with escapes, boolean literals, punctuation spacing, delimiter +// grouping with mismatch/unclosed diagnostics, and line/block comments +// with nesting) are all ported with identical logic and messages. +// * No `runTests`/Console code exists in TokenStream.res, so nothing omitted. + +module TokenStream; + +use prelude::*; +use string::{substring, char_at}; +use collections::{flat_map}; +use Types::*; + +// --------------------------------------------------------------------------- +// Surface types +// --------------------------------------------------------------------------- + +pub enum Spacing { + Alone, // Followed by whitespace or a non-punctuation token. + Joint // Immediately followed by another punctuation character. +} + +pub enum LiteralKind { + LitInteger, // `42`, `0xFF`, `0b1010`, `0o77` + LitFloat, // `3.14`, `1.5e10` + LitString, // `"hello"` + LitChar, // `'a'`, `'\n'` + LitBool // `true`, `false` +} + +pub enum Delimiter { + Paren, // ( ... ) + Bracket, // [ ... ] + Brace, // { ... } + NoneDelim // implicit grouping from macro expansion +} + +// Byte-offset span (kept lightweight vs the full Types::Location). +pub struct TsSpan { + start: Int, + end_: Int, + file: String +} + +pub enum TokenTree { + TtIdent(String, TsSpan), // sym, span + TtPunct(String, Spacing, TsSpan), // ch, spacing, span + TtLiteral(LiteralKind, String, TsSpan), // kind, text, span + TtGroup(Delimiter, [TokenTree], TsSpan) // delimiter, stream, span +} + +pub type TokenStream = [TokenTree]; + +pub struct ParseError { + message: String, + offset: Int +} + +pub fn dummy_span() -> TsSpan { + #{ start: 0, end_: 0, file: "" } +} + +// --------------------------------------------------------------------------- +// Constructors +// --------------------------------------------------------------------------- + +pub fn empty() -> [TokenTree] { [] } + +pub fn ident(sym: String, span: TsSpan) -> TokenTree { TtIdent(sym, span) } + +pub fn punct(ch: String, spacing: Spacing, span: TsSpan) -> TokenTree { + TtPunct(ch, spacing, span) +} + +pub fn literal(kind: LiteralKind, text: String, span: TsSpan) -> TokenTree { + TtLiteral(kind, text, span) +} + +pub fn group(delimiter: Delimiter, stream: [TokenTree], span: TsSpan) -> TokenTree { + TtGroup(delimiter, stream, span) +} + +// --------------------------------------------------------------------------- +// Accessors +// --------------------------------------------------------------------------- + +pub fn span_of(tree: TokenTree) -> TsSpan { + match tree { + TtIdent(_sym, span) => span, + TtPunct(_ch, _sp, span) => span, + TtLiteral(_k, _t, span) => span, + TtGroup(_d, _s, span) => span + } +} + +pub fn is_empty(stream: [TokenTree]) -> Bool { len(stream) == 0 } + +pub fn length(stream: [TokenTree]) -> Int { len(stream) } + +pub fn stream_span(stream: [TokenTree]) -> TsSpan { + let n = len(stream); + if n == 0 { + dummy_span() + } else { + let first = span_of(stream[0]); + let last = span_of(stream[n - 1]); + #{ start: first.start, end_: last.end_, file: first.file } + } +} + +// --------------------------------------------------------------------------- +// Stream operations +// --------------------------------------------------------------------------- + +pub fn concat(a: [TokenTree], b: [TokenTree]) -> [TokenTree] { a ++ b } + +pub fn push(stream: [TokenTree], tree: TokenTree) -> [TokenTree] { stream ++ [tree] } + +fn id_stream(x: [TokenTree]) -> [TokenTree] { x } + +pub fn flatten(streams: [[TokenTree]]) -> [TokenTree] { + flat_map(id_stream, streams) +} + +// --------------------------------------------------------------------------- +// Pretty-printing — reconstruct source from token stream +// --------------------------------------------------------------------------- + +pub fn tree_to_string(tree: TokenTree) -> String { + match tree { + TtIdent(sym, _span) => sym, + TtPunct(ch, _sp, _span) => ch, + TtLiteral(_k, text, _span) => text, + TtGroup(delimiter, stream, _span) => { + let inner = to_source(stream); + match delimiter { + Paren => "(" ++ inner ++ ")", + Bracket => "[" ++ inner ++ "]", + Brace => "{" ++ inner ++ "}", + NoneDelim => inner + } + } + } +} + +// Reconstructs source text: single space between tokens, except `Joint` punct. +pub fn to_source(stream: [TokenTree]) -> String { + let mut buf = ""; + let mut first = true; + let mut i = 0; + let n = len(stream); + while i < n { + let tree = stream[i]; + if !first { + let prefix = match tree { + TtPunct(_ch, Joint, _span) => "", + _ => " " + }; + buf = buf ++ prefix; + } + buf = buf ++ tree_to_string(tree); + first = false; + i = i + 1; + } + buf +} + +// --------------------------------------------------------------------------- +// Parsing — lex source text into a token stream +// --------------------------------------------------------------------------- + +// Code point at index `i`, or -1 at/past end of input. +fn code_at(s: String, i: Int) -> Int { + match char_at(s, i) { + Some(c) => char_to_int(c), + None => -1 + } +} + +// Single-character string at `i` (empty string past end). +fn char_str(s: String, i: Int) -> String { + if i < len(s) { substring(s, i, i + 1) } else { "" } +} + +// True for a punctuation character recognised by the lexer (delimiters excluded). +fn is_punct_code(c: Int) -> Bool { + c == 43 || c == 45 || c == 42 || c == 47 || c == 37 || c == 61 || c == 33 || + c == 60 || c == 62 || c == 38 || c == 124 || c == 94 || c == 126 || c == 46 || + c == 44 || c == 59 || c == 58 || c == 64 || c == 35 || c == 63 || c == 92 +} + +fn is_alpha_code(c: Int) -> Bool { + (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c == 95 +} + +fn is_digit_code(c: Int) -> Bool { c >= 48 && c <= 57 } + +fn is_alnum_code(c: Int) -> Bool { is_alpha_code(c) || is_digit_code(c) } + +fn is_ws_code(c: Int) -> Bool { c == 32 || c == 9 || c == 13 || c == 10 } + +// A stack frame for a still-open delimiter group: (delim, startPos, parentTrees). +struct Frame { + delim: Delimiter, + startp: Int, + parent: [TokenTree] +} + +// Scanner result carrying the new cursor, the (possibly extended) trees, and an +// optional error. Threaded by the driver so the if-then-tuple bug is avoided. +struct LexStep { + pos: Int, + trees: [TokenTree], + err: Option +} + +fn close_char(d: Delimiter) -> String { + match d { + Paren => ")", + Bracket => "]", + Brace => "}", + NoneDelim => "?" + } +} + +fn open_char(d: Delimiter) -> String { + match d { + Paren => "(", + Bracket => "[", + Brace => "{", + NoneDelim => "?" + } +} + +/// Lexes source text into a token stream. `Ok(stream)` on success, else +/// `Err(ParseError)`. +pub fn of_string(source: String, file: String) -> Result<[TokenTree], ParseError> { + let glen = len(source); + let mut pos = 0; + // Stack of open groups (front = innermost). + let mut stack = []; + let mut trees = []; + let mut error = None; + let mut has_error = false; + + while pos < glen && !has_error { + let c = code_at(source, pos); + + if is_ws_code(c) { + pos = pos + 1; + } + // Line comment: // ... to end of line + else if c == 47 && code_at(source, pos + 1) == 47 { + pos = pos + 2; + while pos < glen && code_at(source, pos) != 10 { + pos = pos + 1; + } + } + // Block comment: /* ... */ (nesting) + else if c == 47 && code_at(source, pos + 1) == 42 { + let start = pos; + pos = pos + 2; + let mut depth = 1; + while (pos + 1 < glen) && (depth > 0) { + let c1 = code_at(source, pos); + let c2 = code_at(source, pos + 1); + if c1 == 47 && c2 == 42 { + depth = depth + 1; + pos = pos + 2; + } else if c1 == 42 && c2 == 47 { + depth = depth - 1; + pos = pos + 2; + } else { + pos = pos + 1; + } + } + if depth > 0 { + error = Some(#{ message: "unterminated block comment", offset: start }); + has_error = true; + } + } + // Opening delimiters + else if c == 40 || c == 91 || c == 123 { + let delim = if c == 40 { Paren } else if c == 91 { Bracket } else { Brace }; + stack = [#{ delim: delim, startp: pos, parent: trees }] ++ stack; + trees = []; + pos = pos + 1; + } + // Closing delimiters + else if c == 41 || c == 93 || c == 125 { + let expected = if c == 41 { Paren } else if c == 93 { Bracket } else { Brace }; + let step = close_delim(source, file, pos, stack, trees, expected, c); + pos = step.pos; + // close_delim returns updated trees/stack/error via a record; unpack: + trees = step.trees; + stack = step.stack; + match step.err { + Some(e) => { error = Some(e); has_error = true; }, + None => { } + } + } + // String literal + else if c == 34 { + let st = scan_string(source, file, pos, trees); + pos = st.pos; + trees = st.trees; + match st.err { + Some(e) => { error = Some(e); has_error = true; }, + None => { } + } + } + // Character literal + else if c == 39 { + let st = scan_char(source, file, pos, trees); + pos = st.pos; + trees = st.trees; + match st.err { + Some(e) => { error = Some(e); has_error = true; }, + None => { } + } + } + // Numeric literal + else if is_digit_code(c) { + let st = scan_number(source, file, pos, trees); + pos = st.pos; + trees = st.trees; + } + // Identifier or keyword (including true/false) + else if is_alpha_code(c) { + let start = pos; + let mut j = pos; + while j < glen && is_alnum_code(code_at(source, j)) { + j = j + 1; + } + let text = substring(source, start, j); + let span = #{ start: start, end_: j, file: file }; + let tree = if text == "true" || text == "false" { + TtLiteral(LitBool, text, span) + } else { + TtIdent(text, span) + }; + trees = trees ++ [tree]; + pos = j; + } + // Punctuation + else if is_punct_code(c) { + let start = pos; + pos = pos + 1; + let spacing = if pos < glen && is_punct_code(code_at(source, pos)) { + Joint + } else { + Alone + }; + let span = #{ start: start, end_: pos, file: file }; + trees = trees ++ [TtPunct(char_str(source, start), spacing, span)]; + } + // Unknown character + else { + error = Some(#{ message: "unexpected character: '" ++ char_str(source, pos) ++ "'", offset: pos }); + has_error = true; + } + } + + match error { + Some(err) => Err(err), + None => { + if len(stack) > 0 { + let f = stack[0]; + Err(#{ message: "unclosed delimiter '" ++ open_char(f.delim) ++ "'", offset: f.startp }) + } else { + Ok(trees) + } + } + } +} + +// Result of closing a delimiter: new cursor, trees, stack, optional error. +struct CloseStep { + pos: Int, + trees: [TokenTree], + stack: [Frame], + err: Option +} + +fn close_delim(source: String, file: String, pos0: Int, stack: [Frame], + trees: [TokenTree], expected: Delimiter, ch: Int) -> CloseStep { + if len(stack) == 0 { + #{ pos: pos0, trees: trees, stack: stack, + err: Some(#{ message: "unexpected closing delimiter '" ++ char_str(source, pos0) ++ "'", offset: pos0 }) } + } else { + let frame = stack[0]; + let rest = stack[1:]; + if frame.delim == expected { + let new_pos = pos0 + 1; + let grp = TtGroup(frame.delim, trees, #{ start: frame.startp, end_: new_pos, file: file }); + #{ pos: new_pos, trees: frame.parent ++ [grp], stack: rest, err: None } + } else { + #{ pos: pos0, trees: trees, stack: stack, + err: Some(#{ message: "mismatched delimiter: expected '" ++ close_char(frame.delim) ++ + "', found '" ++ char_str(source, pos0) ++ "'", offset: pos0 }) } + } + } +} + +fn scan_string(source: String, file: String, pos0: Int, trees: [TokenTree]) -> LexStep { + let glen = len(source); + let start = pos0; + let mut pos = pos0 + 1; + let mut done = false; + let mut unterminated = false; + while pos < glen && !done { + let c = code_at(source, pos); + if c == 92 { // backslash escape + pos = pos + 2; + } else if c == 34 { // closing quote + pos = pos + 1; + done = true; + } else { + pos = pos + 1; + } + } + if !done { + unterminated = true; + } + if unterminated { + #{ pos: pos, trees: trees, err: Some(#{ message: "unterminated string literal", offset: start }) } + } else { + let text = substring(source, start, pos); + let lit = TtLiteral(LitString, text, #{ start: start, end_: pos, file: file }); + #{ pos: pos, trees: trees ++ [lit], err: None } + } +} + +fn scan_char(source: String, file: String, pos0: Int, trees: [TokenTree]) -> LexStep { + let glen = len(source); + let start = pos0; + let mut pos = pos0 + 1; + if pos < glen && code_at(source, pos) == 92 { + pos = pos + 2; // escaped char + } else if pos < glen { + pos = pos + 1; + } + if pos >= glen || code_at(source, pos) != 39 { + #{ pos: pos, trees: trees, err: Some(#{ message: "unterminated character literal", offset: start }) } + } else { + let new_pos = pos + 1; // closing quote + let text = substring(source, start, new_pos); + let lit = TtLiteral(LitChar, text, #{ start: start, end_: new_pos, file: file }); + #{ pos: new_pos, trees: trees ++ [lit], err: None } + } +} + +fn scan_number(source: String, file: String, pos0: Int, trees: [TokenTree]) -> LexStep { + let glen = len(source); + let start = pos0; + let mut pos = pos0; + let mut is_float = false; + let c0 = code_at(source, pos); + + // Base-prefixed integer: 0x / 0b / 0o + let next = if pos + 1 < glen { code_at(source, pos + 1) } else { -1 }; + let is_prefixed = c0 == 48 && (next == 120 || next == 88 || next == 98 || next == 66 || next == 111 || next == 79); + + if is_prefixed { + pos = pos + 2; + while pos < glen && is_alnum_code(code_at(source, pos)) { + pos = pos + 1; + } + } else { + // integer part (allow underscores) + while pos < glen && (is_digit_code(code_at(source, pos)) || code_at(source, pos) == 95) { + pos = pos + 1; + } + // fractional part: '.' digit + if pos < glen && code_at(source, pos) == 46 && + pos + 1 < glen && is_digit_code(code_at(source, pos + 1)) { + is_float = true; + pos = pos + 1; + while pos < glen && (is_digit_code(code_at(source, pos)) || code_at(source, pos) == 95) { + pos = pos + 1; + } + } + // exponent + if pos < glen && (code_at(source, pos) == 101 || code_at(source, pos) == 69) { + is_float = true; + pos = pos + 1; + if pos < glen && (code_at(source, pos) == 43 || code_at(source, pos) == 45) { + pos = pos + 1; + } + while pos < glen && is_digit_code(code_at(source, pos)) { + pos = pos + 1; + } + } + } + + let text = substring(source, start, pos); + let kind = if is_float { LitFloat } else { LitInteger }; + let lit = TtLiteral(kind, text, #{ start: start, end_: pos, file: file }); + #{ pos: pos, trees: trees ++ [lit], err: None } +}