From 29f10975b2eb17e1379232c9abbe59371c06871c Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 2 May 2026 07:28:29 +0100 Subject: [PATCH 1/3] docs(lessons): add PlayerHP case study (aspect-decomposition + mut-vs-State) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds docs/guides/lessons/migrations/idaptik-player-hp.adoc — third migration case study, design-translated against AffineScript v0.1.0 following the Float→Int convention from idaptik-hitbox. Why this file: PlayerHP.res is a six-field mutable record bundling three orthogonal concerns (HP, i-frames, knockback) under one type. It stress-tests the playbook's central mut-vs-State decision criterion on a file with no external dependencies but interesting state — the gap between hitbox (pure leaf, no state) and UserSettings (heavy dependencies, design-only). The translation: - Fissions the monolithic record into three nominally distinct aspect structs (HP, IFrames, Knockback), composed in a parent Player. - Chooses `mut Player` over `State[Player]` effect because the mutators all run inside one tick under one owner — coupling is local to the frame. - Same-typed labelled args (~fromX, ~playerX) become a Pos struct, per the hitbox lesson's "labels-want-to-be-types" rule. - Float→Int with documented scaling (HP in tenths, timers in milliseconds) so this can compile against v0.1.0. Status is "design-translated, toolchain run pending" — the steps to verify are in the Reproducibility section. Honest about not having fabricated compiler output. Gap surfaced: aspect-decomposition (record with N orthogonal aspects bundled into one type) is the central pattern of game-state migration but is not named explicitly in the playbook's source-pattern index. The cardinal rule and the file-buffer anti-pattern point at it but a ReScript-table row would make it discoverable. Proposed for a v1.2 follow-up. Updates the playbook's lessons index to reference all three entries. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lessons/migrations/idaptik-player-hp.adoc | 327 ++++++++++++++++++ docs/guides/migration-playbook.adoc | 3 +- 2 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 docs/guides/lessons/migrations/idaptik-player-hp.adoc diff --git a/docs/guides/lessons/migrations/idaptik-player-hp.adoc b/docs/guides/lessons/migrations/idaptik-player-hp.adoc new file mode 100644 index 0000000..ea45a72 --- /dev/null +++ b/docs/guides/lessons/migrations/idaptik-player-hp.adoc @@ -0,0 +1,327 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) += Migration Lesson: idaptik `PlayerHP.res` → `PlayerHP.affine` +:revdate: 2026-05-02 +:icons: font +:source-highlighter: rouge + +[NOTE] +==== +**Status: design-translated, toolchain run pending.** The translation is +written to be compilable against AffineScript v0.1.0 (Int arithmetic, no +external module imports), following the same Float→Int convention +xref:idaptik-hitbox.adoc[idaptik-hitbox] established. A Reproducibility +block matching hitbox's will be filled in by the next translator who runs +this through `affinescript-host-shim.sh`. + +Picked because it stress-tests the migration playbook's central decision — +*`mut` vs `State` effect* — on a record with three orthogonal aspects +bundled into one struct. Unlike +xref:idaptik-user-settings.adoc[idaptik-user-settings], PlayerHP has no +external dependencies; unlike xref:idaptik-hitbox.adoc[idaptik-hitbox], it +has interesting state. +==== + +== The original (88 lines, ReScript) + +[source,rescript] +---- +type t = { + mutable current: float, + mutable max: float, + mutable invincibleTimer: float, + mutable knockbackVelX: float, + mutable knockbackVelY: float, + mutable knockbackTimer: float, +} + +let make = (~con: float): t => { + let maxHP = 80.0 +. 20.0 *. (con /. 100.0) + { current: maxHP, max: maxHP, invincibleTimer: 0.0, + knockbackVelX: 0.0, knockbackVelY: 0.0, knockbackTimer: 0.0 } +} + +let isInvincible = (hp: t): bool => hp.invincibleTimer > 0.0 +let isAlive = (hp: t): bool => hp.current > 0.0 + +let takeDamage = (hp: t, ~amount, ~fromX, ~playerX): unit => { + if !isInvincible(hp) { + hp.current = Math.max(0.0, hp.current -. amount) + hp.invincibleTimer = Knockback.iFrames + let dx = playerX -. fromX + let direction = if dx >= 0.0 { 1.0 } else { -1.0 } + hp.knockbackVelX = direction *. Knockback.speed + hp.knockbackVelY = -80.0 + hp.knockbackTimer = Knockback.duration + } +} + +let update = (hp: t, ~dt): (float, float) => { /* decay timers, return knockback velocity */ } +---- + +A single mutable record carrying three orthogonal concerns: **health** +(`current`, `max`), **invincibility** (`invincibleTimer`), and +**knockback** (`knockbackVelX`, `knockbackVelY`, `knockbackTimer`). Six +mutable fields; one type. The labelled args (`~con`, `~amount`, `~fromX`, +`~playerX`, `~dt`) are also doing work the type system isn't. + +== The decision the playbook forces you to make + +The playbook's +xref:../../migration-playbook.adoc#decision-criteria[`mut` vs `State` +effect] criterion gives two candidates: + +. **`mut PlayerHP`** — caller owns the value, mutation is local to each + function call, the HP record is threaded through call sites that need + it. +. **`State[Player]` effect** — the HP record lives inside an effect + handler, callers say `Player.take_damage(15)` without holding a + reference. + +But there's a third candidate the playbook doesn't name explicitly: it +falls out of the playbook's *cardinal rule* (re-decompose, do not +transliterate) plus the rejection of monolithic instances: + +[start=3] +. **Decompose the type by aspect first, then choose mutation discipline + per aspect.** `HP`, `IFrames`, `Knockback` are three independent + state-machines that happen to be triggered by the same event. + +The right answer depends on whether the three aspects are genuinely +independent or whether their coupling *is* the program. Two pieces of +evidence point at "genuinely independent": + +* `update(dt)` decays the i-frame timer and the knockback timer + separately. They share no state; they just both happen to decay. +* The renderer reads `current`/`max` for the HP bar, the physics code + reads `knockbackVelX`/`Y`, the damage code reads `invincibleTimer`. + No single consumer reads all three. + +The bundling in the original is not a design — it is a side-effect of +ReScript's "one record per gameplay subsystem" idiom. + +== The translation (aspect-decomposed, `mut`-based) + +[source,affinescript] +---- +// HP measured in tenths (max baseline = 1000 = 100.0 HP). +// Timers measured in milliseconds (16ms ≈ 60fps frame; 1000ms = 1.0s). +// Float arithmetic is unsupported in AffineScript v0.1.0 (see hitbox lesson). + +struct HP { current: Int, max: Int } +struct IFrames { remaining_ms: Int } +struct Knockback { vel_x: Int, vel_y: Int, remaining_ms: Int } + +struct Player { + hp: HP, + iframes: IFrames, + knockback: Knockback, +} + +// Constants — module-style namespacing +struct Damage { + static let robo_dog_contact: Int = 150 + static let guard_dog_bite: Int = 100 + static let assassin_strike: Int = 350 + static let guard_melee: Int = 100 +} + +struct Tuning { + static let knockback_speed: Int = 200 + static let knockback_duration_ms: Int = 300 + static let iframe_duration_ms: Int = 1000 + static let knockback_pop_y: Int = -80 +} + +// CON is a 0-200 scaling factor; baseline 100 → 1000 (= 100.0 HP in tenths). +fn make(con: Int) -> Player { + let max_hp = 800 + 2 * con + Player { + hp: HP { current: max_hp, max: max_hp }, + iframes: IFrames { remaining_ms: 0 }, + knockback: Knockback { vel_x: 0, vel_y: 0, remaining_ms: 0 }, + } +} + +fn is_invincible(p: ref Player) -> Bool { p.iframes.remaining_ms > 0 } +fn is_alive(p: ref Player) -> Bool { p.hp.current > 0 } + +// Position is a struct, not two same-typed labelled args: +struct Pos { x: Int } + +fn take_damage(p: mut Player, amount: Int, source: Pos, player: Pos) -> () { + if is_invincible(p) { return } + + // 1. HP — clamped subtract + p.hp.current = max(0, p.hp.current - amount) + + // 2. IFrames — start the invincibility window + p.iframes.remaining_ms = Tuning.iframe_duration_ms + + // 3. Knockback — direction is +1 right of source, -1 left of source + let direction = if player.x >= source.x { 1 } else { -1 } + p.knockback.vel_x = direction * Tuning.knockback_speed + p.knockback.vel_y = Tuning.knockback_pop_y + p.knockback.remaining_ms = Tuning.knockback_duration_ms +} + +struct Velocity { x: Int, y: Int } + +// Tick all three timers; return knockback velocity for this frame. +fn update(p: mut Player, dt_ms: Int) -> Velocity { + // IFrames decay + if p.iframes.remaining_ms > 0 { + p.iframes.remaining_ms = max(0, p.iframes.remaining_ms - dt_ms) + } + + // Knockback decay + velocity emission + if p.knockback.remaining_ms > 0 { + p.knockback.remaining_ms = max(0, p.knockback.remaining_ms - dt_ms) + let v = Velocity { x: p.knockback.vel_x, y: p.knockback.vel_y } + if p.knockback.remaining_ms <= 0 { + p.knockback.vel_x = 0 + p.knockback.vel_y = 0 + } + v + } else { + Velocity { x: 0, y: 0 } + } +} +---- + +== What changed + +=== 1. One monolithic struct → three aspect structs + +`HP`, `IFrames`, `Knockback` are nominally distinct. The compiler refuses +`is_invincible(some_hp)` — only a `Player` (or, with refactoring, a +`ref IFrames`) is acceptable. Each aspect has a clear lifetime, a clear +mutator, and a clear reader. A future contributor adding e.g. a *poison* +status effect drops in alongside the existing three rather than mutating +a six-field god-struct. + +=== 2. `mut Player`, not `State[Player]` effect + +This is the playbook's +xref:../../migration-playbook.adoc#decision-criteria[`mut` vs `State`] +decision applied carefully. Two consumers (combat and physics) need to +mutate `Player`; both are called from the same game-loop tick under a +single owner (the `Engine` or `World`). No third caller observes the +mutation across the tick boundary. **Coupling is local to one frame.** + +If a future system needed to react to "player got hit" *between* combat +and physics in the same tick, that would be the moment to lift to a +`State[Player]` effect (or, more honestly, to a `Damage` effect with +handlers in both subsystems). The point of `mut` here is that we get +explicit local mutation now without fabricating a global effect that +isn't doing any work. + +=== 3. Tuning constants in `static let` slots, not bare module values + +ReScript's `module Damage = { let roboDogContact = 15.0 }` works because +modules are namespaces. AffineScript's idiomatic equivalent (per the +hitbox lesson and the v0.1.0 surface) is `static let` inside a `struct`, +which gives the same `Damage.robo_dog_contact` access path with a +typed-WASM-friendly representation. + +=== 4. Same-typed labelled args → struct types + +`takeDamage(hp, ~amount, ~fromX, ~playerX)` becomes +`take_damage(p, amount, source: Pos, player: Pos)`. The labels `fromX` +and `playerX` were doing real work — disambiguating two `float` values — +which is exactly the case the +xref:idaptik-hitbox.adoc[hitbox lesson] flagged as the canonical +"labels-want-to-be-types" pattern. + +=== 5. Float → Int with documented precision compromise + +* HP: tenths (max baseline 1000 = 100.0 HP). Damage values rescale by 10 + (`15.0` → `150`). One-decimal precision is fine for game logic. +* Timers: milliseconds. 60fps `dt` is `~16ms`. The original `0.3`-second + knockback duration becomes `300`. The original `1.0`-second i-frame + duration becomes `1000`. + +This is the same compromise xref:idaptik-hitbox.adoc[idaptik-hitbox] +documented for collision rectangles. **Do not silently change the unit +of a public field** — the source-comment header should declare the +scaling factors. When AffineScript gains Float-arithmetic operators, the +file can be reverted to floats with a one-pass rewrite. + +== Playbook verdict + +=== Held up cleanly + +* xref:../../migration-playbook.adoc#anti-patterns[The "monolithic + instance" anti-pattern] — six mutable fields under one type *is* the + pattern. Aspect-decomposition follows directly from "smaller, more + declarations." +* xref:../../migration-playbook.adoc#decision-criteria[`mut` vs `State` + decision criterion] — gave a defensible answer once we asked "do + consumers observe the mutation across calls or within a tick?" +* The Float → Int compromise *and* the requirement to document the + scaling factors — flowed directly from xref:idaptik-hitbox.adoc[the + hitbox lesson's existing rule]. + +=== Gap surfaced + +**Aspect-decomposition vs single-record translation is not named in the +playbook.** The cardinal rule says "more, smaller declarations" and the +file-buffer anti-pattern shows two-types-from-one — but a *six-field +mutable record with three orthogonal concerns* is a distinct pattern +that wants its own row in the source-pattern index. + +*Proposed for a v1.2 follow-up:* a row in the ReScript pattern index for +"record with N orthogonal aspects bundled into one type" with the +decomposition "fission into N nominally distinct types whose lifetimes +and consumers differ; compose them in a parent type only when they +genuinely share a lifetime." This is *the* central pattern of game-state +migration and deserves explicit naming. + +=== Decision criterion not exercised + +The xref:../../migration-playbook.adoc#decision-criteria[coupled-writes +subsection added in v1.1] did not apply here — PlayerHP has no +persistence and no IO. That is information about the v1.1 addition's +scope, not a gap. + +== What this lesson covers, generalised + +When translating a `.res` file with **multi-field mutable state**: + +. **Look at the fields by consumer, not by name.** If different + consumers read disjoint subsets of the fields, the bundling is + incidental, not structural. Fission. +. **Choose `mut` vs `State` by call-site distance.** If all mutators + run inside one tick under one owner, `mut` is honest. If the + mutation must be observed across an asynchronous boundary, lift to + `State`. +. **Do not lift to `State` to *organise* the code.** Effects are for + observability across callers, not for namespace cleanliness. +. **Document Float→Int scaling factors at the file header.** The next + translator inherits the precision compromise; do not make them + rediscover it. + +== Reproducibility + +[NOTE] +==== +This lesson is design-translated; the toolchain run is pending. The +steps below match xref:idaptik-hitbox.adoc#reproducibility[the hitbox +lesson's Reproducibility section] and should produce the same shape of +output. +==== + +[source,bash] +---- +$ just affinescript-stage +$ ./scripts/affinescript-host-shim.sh check src/app/combat/PlayerHP.affine +# Expected: "Type checking passed" +$ ./scripts/affinescript-host-shim.sh compile src/app/combat/PlayerHP.affine -o src/app/combat/PlayerHP.wasm +# Expected: "Compiled src/app/combat/PlayerHP.affine -> src/app/combat/PlayerHP.wasm (WASM)" +---- + +When the toolchain run completes, replace this NOTE with the actual +output (matching hitbox's format) and link the resulting `.wasm` size in +the file header. If the run fails on a v0.1.0 limitation not anticipated +here, **edit the lesson** — that is exactly the case study a future +translator most needs. diff --git a/docs/guides/migration-playbook.adoc b/docs/guides/migration-playbook.adoc index 1d3b759..9006ca7 100644 --- a/docs/guides/migration-playbook.adoc +++ b/docs/guides/migration-playbook.adoc @@ -273,7 +273,8 @@ Case studies from in-flight migrations land in link:lessons/migrations/[`docs/gu Existing entries: * link:lessons/migrations/idaptik-hitbox.adoc[idaptik `Hitbox.res` → `Hitbox.affine`] — the first compilable translation; pure-leaf collision primitives. Surfaces the v0.1.0 Float-arithmetic typechecker gap. -* link:lessons/migrations/idaptik-user-settings.adoc[idaptik `UserSettings.res` (design-only)] — multi-pattern, dependency-heavy persistence layer. Stress-tests the playbook against five external module dependencies; surfaces three small playbook gaps. +* link:lessons/migrations/idaptik-user-settings.adoc[idaptik `UserSettings.res` (design-only)] — multi-pattern, dependency-heavy persistence layer. Stress-tests the playbook against five external module dependencies; surfaces three small playbook gaps (addressed in v1.1). +* link:lessons/migrations/idaptik-player-hp.adoc[idaptik `PlayerHP.res` (design-translated, toolchain run pending)] — six-field mutable record with three orthogonal aspects (HP, i-frames, knockback). Stress-tests the `mut` vs `State` decision criterion; surfaces the **aspect-decomposition** pattern as a v1.2 candidate addition. If you complete a non-trivial `.res → .affine` translation and the re-decomposition reveals something future translators would benefit from, write it up there. The format is loose: original snippet, faithful port, re-decomposed port, and what the second one buys you. Mark the lesson "design-only" if the destination toolchain cannot yet compile it — the design walk-through still pressures the playbook. From 74024c650cc8eb3b034b396992e849effab90031 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 2 May 2026 08:10:49 +0100 Subject: [PATCH 2/3] feat(faces): add Lucid + Cafe faces, unify on .affine + face pragma MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new faces — LucidScript (PureScript-style) and CafeScripto (CoffeeScript-style) — alongside the established Canonical / Rattle / Jaffa / Pseudo. Every face shares the .affine extension and is selected by an in-file `face:` pragma (or --face flag); deprecated per-face extensions still work with a stderr warning. - lib/face_pragma.ml: pragma detector covering all six brand names and their generic aliases. - lib/lucid_face.ml, lib/cafe_face.ml: text transformers at parity depth with python_face / js_face / pseudocode_face. - lib/face.ml: Lucid + Cafe variants plus error-vocabulary branches across all six format_*_for_face functions. - bin/main.ml: parse_with_face, face_arg, fmt_file, preview-* cmds extended; resolve_face now does pragma-first dispatch. - examples/faces/, tests/faces/, tools/run_face_transformer_tests.sh: six demo files, snapshot regression test, justfile recipes, and a CI step that catches transformer drift. - README.adoc: face listing now names all six. Known transformer gaps (each face's example sidesteps these and the examples/faces/README.adoc tracks them) are deferred follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 3 + README.adoc | 20 +- bin/main.ml | 232 +++++++-- examples/faces/README.adoc | 116 +++++ examples/faces/hello-cafe.affine | 23 + examples/faces/hello-canonical.affine | 15 + examples/faces/hello-jaffa.affine | 16 + examples/faces/hello-lucid.affine | 19 + examples/faces/hello-pseudo.affine | 17 + examples/faces/hello-rattle.affine | 14 + justfile | 12 + lib/cafe_face.ml | 519 ++++++++++++++++++++ lib/dune | 2 +- lib/face.ml | 238 +++++++++ lib/face_pragma.ml | 156 ++++++ lib/lucid_face.ml | 678 ++++++++++++++++++++++++++ tests/faces/README.md | 70 +++ tests/faces/hello-cafe.expected.txt | 24 + tests/faces/hello-jaffa.expected.txt | 16 + tests/faces/hello-lucid.expected.txt | 20 + tests/faces/hello-pseudo.expected.txt | 17 + tests/faces/hello-rattle.expected.txt | 17 + tools/run_face_transformer_tests.sh | 184 +++++++ 23 files changed, 2382 insertions(+), 46 deletions(-) create mode 100644 examples/faces/README.adoc create mode 100644 examples/faces/hello-cafe.affine create mode 100644 examples/faces/hello-canonical.affine create mode 100644 examples/faces/hello-jaffa.affine create mode 100644 examples/faces/hello-lucid.affine create mode 100644 examples/faces/hello-pseudo.affine create mode 100644 examples/faces/hello-rattle.affine create mode 100644 lib/cafe_face.ml create mode 100644 lib/face_pragma.ml create mode 100644 lib/lucid_face.ml create mode 100644 tests/faces/README.md create mode 100644 tests/faces/hello-cafe.expected.txt create mode 100644 tests/faces/hello-jaffa.expected.txt create mode 100644 tests/faces/hello-lucid.expected.txt create mode 100644 tests/faces/hello-pseudo.expected.txt create mode 100644 tests/faces/hello-rattle.expected.txt create mode 100755 tools/run_face_transformer_tests.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8714ef1..3c77a59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,9 @@ jobs: - name: Run codegen WASM tests run: opam exec -- ./tools/run_codegen_wasm_tests.sh + - name: Run face-transformer regression tests + run: opam exec -- ./tools/run_face_transformer_tests.sh + - name: Check formatting run: opam exec -- dune build @fmt diff --git a/README.adoc b/README.adoc index d20e7de..0a86adc 100644 --- a/README.adoc +++ b/README.adoc @@ -76,12 +76,16 @@ On top of that core, the language supports _faces_: sugared surface syntaxes tha A face is not a separate language. It is a different presentation layer over the same core model. -Examples include: +The established faces are: - *AffineScript* — the canonical face -- *JaffaScript* — a JavaScript-like face -- *RattleScript* — a Python-like face -- *PseudoScript* — a pseudocode-oriented face +- *JaffaScript* — a JavaScript / TypeScript-like face +- *RattleScript* — a Python-like face (also positioned as "Python for the web" via typed-wasm) +- *PseudoScript* — a pseudocode-oriented face for CS pedagogy +- *LucidScript* — a PureScript / Haskell-like face +- *CafeScripto* — a CoffeeScript-like face + +Every face shares the canonical `.affine` file extension; the active face is selected by an optional `face:` pragma on the first comment line of the file (e.g. `# face: rattlescript`, `// face: jaffascript`, `-- face: lucidscript`), or by `--face NAME` on the CLI. This means people can bring familiarity from a language family they already love, while still entering a system with stronger guarantees around ownership, effects, state, and resource usage. @@ -312,11 +316,15 @@ face syntax -> AffineScript core AST -> type/effect/ownership checks -> ba Examples: -- JaffaScript lowers JavaScript-like syntax into the AffineScript core +- JaffaScript lowers JavaScript / TypeScript-like syntax into the AffineScript core - RattleScript lowers Python-like syntax into the same core - PseudoScript lowers structured pedagogical pseudocode into the same core +- LucidScript lowers PureScript / Haskell-like syntax into the same core +- CafeScripto lowers CoffeeScript-like syntax into the same core + +Side-by-side examples for each face live under `examples/faces/`; you can preview the canonical lowering of any file with `affinescript preview-python` / `preview-js` / `preview-pseudocode` / `preview-lucid` / `preview-cafe`. -So the question is not “which face is the real language?” +So the question is not “which face is the real language?” The answer is: the core semantics are the language; faces are entrances. == Backends and Targets diff --git a/bin/main.ml b/bin/main.ml index d482ec0..67ab779 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -59,25 +59,44 @@ let lex_file path = Format.eprintf "@[%s:%d:%d: error: %s@]@." path pos.line pos.col msg; `Error (false, "Lexer error") -(** Resolve the effective face, promoting Canonical → face-from-extension when - the file extension implies a specific face. This means a user can run - [affinescript check hello.rattle] with no [--face] flag and get - Python-face automatically. An explicit [--face canonical] overrides. *) -let resolve_face face path = +(** Resolve the effective face. Priority order, highest first: + + 1. An explicit non-Canonical [--face] flag (always wins). + 2. A [face:] pragma in the leading comment lines of the source file + (see {!Affinescript.Face_pragma}). This is the recommended mechanism + once every source file shares the canonical [.affine] extension. + 3. A deprecated face-implying file extension ([.rattle], [.pyaff], + [.jsaff], [.pseudoaff]). Triggers a one-line stderr warning. + 4. Canonical default. + + [quiet] suppresses the deprecation notice for JSON and LSP commands + whose stderr is consumed by tools (LSP clients, CI, editors). *) +let resolve_face ?(quiet = false) face path = match face with | Affinescript.Face.Canonical -> - let ext = - try - let dot = String.rindex path '.' in - String.sub path (dot + 1) (String.length path - dot - 1) - with Not_found -> "" - in - (match ext with - | "rattle" -> Affinescript.Face.Python (* RattleScript *) - | "pyaff" -> Affinescript.Face.Python - | "jsaff" -> Affinescript.Face.Js - | "pseudoaff" -> Affinescript.Face.Pseudocode - | _ -> Affinescript.Face.Canonical) + (match Affinescript.Face_pragma.detect_in_file path with + | Some f -> f + | None -> + let ext = + try + let dot = String.rindex path '.' in + String.sub path (dot + 1) (String.length path - dot - 1) + with Not_found -> "" + in + let warn_deprecated alias = + if not quiet then + Format.eprintf + "@[warning: file extension .%s is deprecated; \ + rename to .affine and add a pragma '# face: %s' on the first \ + line (use --face %s to silence this warning)@]@." + ext alias alias + in + (match ext with + | "rattle" -> warn_deprecated "rattle"; Affinescript.Face.Python + | "pyaff" -> warn_deprecated "python"; Affinescript.Face.Python + | "jsaff" -> warn_deprecated "js"; Affinescript.Face.Js + | "pseudoaff" -> warn_deprecated "pseudocode"; Affinescript.Face.Pseudocode + | _ -> Affinescript.Face.Canonical)) | other -> other (* explicit --face flag always wins *) (** Parse a file using the requested face. *) @@ -87,6 +106,8 @@ let parse_with_face (face : Affinescript.Face.face) path = | Affinescript.Face.Python -> Affinescript.Python_face.parse_file_python path | Affinescript.Face.Js -> Affinescript.Js_face.parse_file_js path | Affinescript.Face.Pseudocode -> Affinescript.Pseudocode_face.parse_file_pseudocode path + | Affinescript.Face.Lucid -> Affinescript.Lucid_face.parse_file_lucid path + | Affinescript.Face.Cafe -> Affinescript.Cafe_face.parse_file_cafe path (** Preview the Python-face text transform (debug tool). *) let preview_python_transform path = @@ -112,6 +133,22 @@ let preview_pseudocode_transform path = print_string (Affinescript.Pseudocode_face.preview_transform s); `Ok () +(** Preview the Lucid-face (PureScript-style) text transform (debug tool). *) +let preview_lucid_transform path = + let ch = open_in_bin path in + let s = really_input_string ch (in_channel_length ch) in + close_in ch; + print_string (Affinescript.Lucid_face.preview_transform s); + `Ok () + +(** Preview the Cafe-face (CoffeeScript-style) text transform (debug tool). *) +let preview_cafe_transform path = + let ch = open_in_bin path in + let s = really_input_string ch (in_channel_length ch) in + close_in ch; + print_string (Affinescript.Cafe_face.preview_transform s); + `Ok () + (** Parse a file and print AST (no --json support). *) let parse_file (face : Affinescript.Face.face) path = let face = resolve_face face path in @@ -131,7 +168,7 @@ let parse_file (face : Affinescript.Face.face) path = (** Type-check a file. With [--json], emits a structured diagnostic report on stderr. *) let check_file face json path = - let face = resolve_face face path in + let face = resolve_face ~quiet:json face path in if json then begin let diags = ref [] in let add d = diags := d :: !diags in @@ -227,7 +264,7 @@ let check_file face json path = (** Evaluate a file with the interpreter. With [--json], emits diagnostics on stderr instead of human-readable error text. *) let eval_file face json path = - let face = resolve_face face path in + let face = resolve_face ~quiet:json face path in if json then begin let diags = ref [] in let add d = diags := d :: !diags in @@ -441,7 +478,7 @@ let repl_cmd_fn () = compilation errors. With [--wasm-gc], targets the WebAssembly GC proposal instead of WASM 1.0 linear memory. *) let compile_file face json wasm_gc path output = - let face = resolve_face face path in + let face = resolve_face ~quiet:json face path in if json then begin let diags = ref [] in let add d = diags := d :: !diags in @@ -467,6 +504,9 @@ let compile_file face json wasm_gc path output = add (Affinescript.Json_output.of_quantity_error (err, span)) | Ok () -> let is_julia = Filename.check_suffix output ".jl" in + let is_js = Filename.check_suffix output ".js" in + let is_c = Filename.check_suffix output ".c" in + let is_wgsl = Filename.check_suffix output ".wgsl" in if is_julia then begin match Affinescript.Julia_codegen.codegen_julia prog resolve_ctx.symbols with | Error msg -> @@ -477,6 +517,36 @@ let compile_file face json wasm_gc path output = let oc = open_out output in output_string oc julia_code; close_out oc + end else if is_js then begin + match Affinescript.Js_codegen.codegen_js prog resolve_ctx.symbols with + | Error msg -> + add { severity = Error; code = "E0803"; + message = Printf.sprintf "JS codegen error: %s" msg; + span = Affinescript.Span.dummy; help = None; labels = [] } + | Ok js_code -> + let oc = open_out output in + output_string oc js_code; + close_out oc + end else if is_c then begin + match Affinescript.C_codegen.codegen_c prog resolve_ctx.symbols with + | Error msg -> + add { severity = Error; code = "E0804"; + message = Printf.sprintf "C codegen error: %s" msg; + span = Affinescript.Span.dummy; help = None; labels = [] } + | Ok c_code -> + let oc = open_out output in + output_string oc c_code; + close_out oc + end else if is_wgsl then begin + match Affinescript.Wgsl_codegen.codegen_wgsl prog resolve_ctx.symbols with + | Error msg -> + add { severity = Error; code = "E0805"; + message = Printf.sprintf "WGSL codegen error: %s" msg; + span = Affinescript.Span.dummy; help = None; labels = [] } + | Ok wgsl_code -> + let oc = open_out output in + output_string oc wgsl_code; + close_out oc end else if wasm_gc then begin match Affinescript.Codegen_gc.generate_gc_module prog with | Error e -> @@ -536,6 +606,9 @@ let compile_file face json wasm_gc path output = | Ok () -> begin let is_julia = Filename.check_suffix output ".jl" in + let is_js = Filename.check_suffix output ".js" in + let is_c = Filename.check_suffix output ".c" in + let is_wgsl = Filename.check_suffix output ".wgsl" in if is_julia then (match Affinescript.Julia_codegen.codegen_julia prog resolve_ctx.symbols with | Error e -> @@ -547,6 +620,39 @@ let compile_file face json wasm_gc path output = close_out oc; Format.printf "Compiled %s -> %s (Julia)@." path output; `Ok ()) + else if is_js then + (match Affinescript.Js_codegen.codegen_js prog resolve_ctx.symbols with + | Error e -> + Format.eprintf "@[JS codegen error: %s@]@." e; + `Error (false, "JS codegen error") + | Ok js_code -> + let oc = open_out output in + output_string oc js_code; + close_out oc; + Format.printf "Compiled %s -> %s (JS)@." path output; + `Ok ()) + else if is_c then + (match Affinescript.C_codegen.codegen_c prog resolve_ctx.symbols with + | Error e -> + Format.eprintf "@[C codegen error: %s@]@." e; + `Error (false, "C codegen error") + | Ok c_code -> + let oc = open_out output in + output_string oc c_code; + close_out oc; + Format.printf "Compiled %s -> %s (C)@." path output; + `Ok ()) + else if is_wgsl then + (match Affinescript.Wgsl_codegen.codegen_wgsl prog resolve_ctx.symbols with + | Error e -> + Format.eprintf "@[WGSL codegen error: %s@]@." e; + `Error (false, "WGSL codegen error") + | Ok wgsl_code -> + let oc = open_out output in + output_string oc wgsl_code; + close_out oc; + Format.printf "Compiled %s -> %s (WGSL)@." path output; + `Ok ()) else if wasm_gc then (match Affinescript.Codegen_gc.generate_gc_module prog with | Error e -> @@ -599,6 +705,12 @@ let fmt_file face path = | Affinescript.Face.Pseudocode -> Format.eprintf "fmt --face pseudocode is not yet supported \ (reverse pseudocode transform is pending).@."; () + | Affinescript.Face.Lucid -> + Format.eprintf "fmt --face lucid is not yet supported \ + (reverse Lucid/PureScript transform is pending).@."; () + | Affinescript.Face.Cafe -> + Format.eprintf "fmt --face cafe is not yet supported \ + (reverse Cafe/CoffeeScript transform is pending).@."; () | Affinescript.Face.Canonical -> ()); try Affinescript.Formatter.format_file path; @@ -616,7 +728,7 @@ let fmt_file face path = (** Lint a file. With [--json], emits lint diagnostics as structured JSON on stderr. *) let lint_file face json path = - let face = resolve_face face path in + let face = resolve_face ~quiet:json face path in if json then begin let diags = ref [] in let add d = diags := d :: !diags in @@ -812,24 +924,55 @@ let wasm_gc_arg = (** Shared --face flag: select the parser surface-syntax face. *) let face_arg = let faces = Arg.enum [ - ("canonical", Affinescript.Face.Canonical); - ("python", Affinescript.Face.Python); - ("js", Affinescript.Face.Js); - ("javascript", Affinescript.Face.Js); - ("pseudocode", Affinescript.Face.Pseudocode); - ("pseudo", Affinescript.Face.Pseudocode); + (* Generic names. *) + ("canonical", Affinescript.Face.Canonical); + ("python", Affinescript.Face.Python); + ("js", Affinescript.Face.Js); + ("javascript", Affinescript.Face.Js); + ("pseudocode", Affinescript.Face.Pseudocode); + ("pseudo", Affinescript.Face.Pseudocode); + ("lucid", Affinescript.Face.Lucid); + ("purescript", Affinescript.Face.Lucid); + ("cafe", Affinescript.Face.Cafe); + ("coffee", Affinescript.Face.Cafe); + ("coffeescript", Affinescript.Face.Cafe); + (* Brand names from README.adoc — "Different faces, same cube". *) + ("affinescript", Affinescript.Face.Canonical); + ("rattle", Affinescript.Face.Python); + ("rattlescript", Affinescript.Face.Python); + ("jaffa", Affinescript.Face.Js); + ("jaffascript", Affinescript.Face.Js); + ("pseudoscript", Affinescript.Face.Pseudocode); + ("lucidscript", Affinescript.Face.Lucid); + ("cafescripto", Affinescript.Face.Cafe); ] in Arg.(value & opt faces Affinescript.Face.Canonical & info ["face"] ~docv:"FACE" - ~doc:"Parser face (surface-syntax variant). \ - $(b,canonical) (default) — standard AffineScript. \ - $(b,python) — Python-style syntax ($(b,def)/indentation/$(b,True)/$(b,None)/etc.). \ - $(b,js) or $(b,javascript) — JavaScript-style syntax \ + ~doc:"Parser face (surface-syntax variant). The established faces \ + (per README.adoc — 'different faces, same cube'): \ + $(b,canonical)/$(b,affinescript) (default) — the canonical face. \ + $(b,python)/$(b,rattle)/$(b,rattlescript) — Python-style \ + ($(b,def)/indentation/$(b,True)/$(b,None)/etc.). \ + $(b,js)/$(b,javascript)/$(b,jaffa)/$(b,jaffascript) — JavaScript-style \ ($(b,const)/$(b,let)/$(b,function)/$(b,=>)/$(b,null)/$(b,===)/import-from). \ - $(b,pseudocode) or $(b,pseudo) — natural-language pseudocode \ + $(b,pseudocode)/$(b,pseudo)/$(b,pseudoscript) — natural-language pseudocode \ ($(b,function)/$(b,set...to)/$(b,if...then)/$(b,end)/$(b,is)/$(b,and)/etc.). \ - All faces compile to the same canonical AST; errors are reported \ - in face-appropriate vocabulary.") + $(b,lucid)/$(b,lucidscript)/$(b,purescript) — PureScript-style \ + ($(b,module … where)/$(b,data)/$(b,class)/$(b,instance)/$(b,case … of)/\ + $(b,\\x ->)/$(b,let … in)/$(b,True)/$(b,--) comments). \ + $(b,cafe)/$(b,cafescripto)/$(b,coffee)/$(b,coffeescript) — CoffeeScript-style \ + ($(b,->)/$(b,=>)/$(b,@)/$(b,unless)/$(b,until)/postfix-if/postfix-unless/\ + $(b,Yes)/$(b,No)/$(b,On)/$(b,Off)/$(b,#) comments/$(b,###) blocks). \ + All faces compile to the same canonical AST and produce identical \ + typed-wasm output; errors are reported in face-appropriate \ + vocabulary. Resolution order when this flag is omitted: \ + (1) a $(b,face:) pragma in the source file's leading comment lines \ + (e.g. $(b,'# face: rattlescript') or $(b,'-- face: lucidscript')); \ + (2) a deprecated face-implying extension \ + ($(b,.rattle), $(b,.pyaff), $(b,.jsaff), $(b,.pseudoaff)) — \ + warns and is scheduled for removal; \ + (3) Canonical. The recommended layout is $(b,.affine) everywhere \ + with a pragma on the first line of non-canonical files.") let lex_cmd = let doc = "Lex a file and print tokens" in @@ -1121,7 +1264,7 @@ let repl_cmd = Cmd.v info Term.(ret (const repl_cmd_fn $ const ())) let compile_cmd = - let doc = "Compile a file to WebAssembly (1.0 or GC proposal) or Julia" in + let doc = "Compile a file to WebAssembly (1.0 or GC proposal), Julia (.jl), JavaScript (.js), C (.c), or a WGSL kernel (.wgsl)" in let info = Cmd.info "compile" ~doc in Cmd.v info Term.(ret (const compile_file $ face_arg $ json_arg $ wasm_gc_arg $ path_arg $ output_arg)) @@ -1150,6 +1293,16 @@ let preview_pseudocode_cmd = let info = Cmd.info "preview-pseudocode" ~doc in Cmd.v info Term.(ret (const preview_pseudocode_transform $ path_arg)) +let preview_lucid_cmd = + let doc = "Preview the Lucid-face (PureScript) text transform (debug)" in + let info = Cmd.info "preview-lucid" ~doc in + Cmd.v info Term.(ret (const preview_lucid_transform $ path_arg)) + +let preview_cafe_cmd = + let doc = "Preview the Cafe-face (CoffeeScript) text transform (debug)" in + let info = Cmd.info "preview-cafe" ~doc in + Cmd.v info Term.(ret (const preview_cafe_transform $ path_arg)) + (** {1 Phase B: hover and goto-definition subcommands} Both commands run the full pipeline (parse → resolve → typecheck) @@ -1201,7 +1354,7 @@ let run_pipeline_for_query face path = (** Hover subcommand handler. *) let hover_file face path line col = - let face = resolve_face face path in + let face = resolve_face ~quiet:true face path in (match run_pipeline_for_query face path with | None -> Affinescript.Json_output.emit_hover None @@ -1212,7 +1365,7 @@ let hover_file face path line col = (** Goto-definition subcommand handler. *) let goto_def_file face path line col = - let face = resolve_face face path in + let face = resolve_face ~quiet:true face path in (match run_pipeline_for_query face path with | None -> Affinescript.Json_output.emit_goto_def None @@ -1290,7 +1443,7 @@ let server_cmd = of completion candidates on stdout. Emits an empty array on pipeline failure so the editor doesn't break. *) let complete_file face path line col = - let face = resolve_face face path in + let face = resolve_face ~quiet:true face path in let source = read_file path in (match run_pipeline_for_query face path with | None -> @@ -1328,7 +1481,8 @@ let default_cmd = tea_bridge_cmd; router_bridge_cmd; cs_bridge_cmd; verify_cmd; interface_cmd; verify_bridge_cmd; verify_boundary_cmd; hover_cmd; goto_def_cmd; complete_cmd; server_cmd; - preview_python_cmd; preview_js_cmd; preview_pseudocode_cmd + preview_python_cmd; preview_js_cmd; preview_pseudocode_cmd; + preview_lucid_cmd; preview_cafe_cmd ] let () = exit (Cmd.eval default_cmd) diff --git a/examples/faces/README.adoc b/examples/faces/README.adoc new file mode 100644 index 0000000..a88c0e8 --- /dev/null +++ b/examples/faces/README.adoc @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell + += Faces — Different Surfaces, Same Cube + +This directory contains the same logical "hello" program written once in each of the six established AffineScript faces. Every file: + +* uses the canonical `.affine` extension, +* declares its face with a pragma on the first non-blank comment line (`# face: …`, `// face: …`, or `-- face: …`), +* compiles to the same canonical AST and the same typed-wasm output. + +== The six files + +[cols="1,2,3"] +|=== +| File | Face | Demonstrates + +| `hello-canonical.affine` +| AffineScript (canonical) +| The reference syntax — all other faces lower to this shape. + +| `hello-rattle.affine` +| RattleScript (Python) +| `def`, indentation blocks, `#` comments, `True`/`False`/`None`, `and`/`or`/`not`, `import a.b`. + +| `hello-jaffa.affine` +| JaffaScript (JavaScript / TypeScript) +| `function`, `const`/`let`, `import { x } from "m"`, `null`/`undefined`, `===`/`!==`. + +| `hello-pseudo.affine` +| PseudoScript (pseudocode) +| `function … do … end`, `set X to Y`, `output X`, `is`/`is not`, `yes`/`no`, `and`/`or`. + +| `hello-lucid.affine` +| LucidScript (PureScript / Haskell) +| `module … where`, dotted imports with `(name)` lists, `name :: Type` signatures, equation-style `f x = …`, `\x -> …` lambdas, `--` comments. + +| `hello-cafe.affine` +| CafeScripto (CoffeeScript) +| `#` and `###` comments, `->` / `=>` arrows, `@` shorthand, `unless`/`until`, postfix-if, `Yes`/`No`/`On`/`Off`. +|=== + +== See the lowering + +The `preview-*` debug commands run a face through its transformer and print the canonical AffineScript text it produces — no parsing, no type-checking, just the string-to-string transform. + +[source,bash] +---- +# Show how RattleScript lowers +affinescript preview-python examples/faces/hello-rattle.affine + +# Show how JaffaScript lowers +affinescript preview-js examples/faces/hello-jaffa.affine + +# Show how PseudoScript lowers +affinescript preview-pseudocode examples/faces/hello-pseudo.affine + +# Show how LucidScript lowers +affinescript preview-lucid examples/faces/hello-lucid.affine + +# Show how CafeScripto lowers +affinescript preview-cafe examples/faces/hello-cafe.affine +---- + +The output of each `preview-*` command is the same logical program in canonical AffineScript syntax, modulo whitespace and comment placement. Diffing two previews is a good way to see what each transformer actually does. + +== Parse-only verification + +To confirm a face file lowers to syntactically valid canonical AffineScript: + +[source,bash] +---- +affinescript parse examples/faces/hello-rattle.affine +---- + +(The face is auto-detected from the pragma; pass `--face NAME` to force a specific face.) + +== Caveats + +* These examples demonstrate *surface syntax*. They are written to round-trip through their respective transformers (preview → canonical → parse) so the regression test in `tests/faces/` can catch drift, not to be full type-correct, runnable programs. That depends on what's in scope from `stdlib/`. +* Span fidelity: error messages from non-canonical faces refer to the canonical-text form (post-transform), not the original face source. This is a known limitation of all face transformers, including the original three. +* The examples deliberately avoid features each transformer can't yet handle. Known transformer gaps that the simpler examples sidestep: ++ +[cols="1,3"] +|=== +| Face | Pending transformer work + +| Python (Rattle) +| Bare assignment `x = y` is not yet auto-lifted to `let x = y` (the example uses explicit `let`); `import a.b` lowering does not produce the canonical `use a::b::{…};` brace form. + +| JS (Jaffa) +| `import { x } from "module";` lowering has a string-stripping bug when the trailing `;` is present (the example avoids `import` entirely for now). + +| Pseudocode (Pseudo) +| No automatic `;` between non-tail statements — examples use single-statement function bodies. Token substitutions can bleed into comment text (e.g. `or` → `\|\|` inside a `//` comment). `output X` lowers to `IO.println(X)` rather than canonical `println(X)`. + +| Lucid (PureScript) +| Haskell-style currying calls `f x` are NOT converted to canonical `f(x)` — the example uses canonical paren syntax. Multi-clause definitions, `do`-notation, and `where`-block hoisting are deferred to AST-level rewrites. + +| Cafe (CoffeeScript) +| List comprehensions, no-paren calls, splat / destructuring, and string interpolation are deferred to AST-level rewrites. +|=== ++ +These gaps are tracked as follow-up work; the regression test in this directory will catch drift in *what does work today*, and additional examples can be added once the corresponding transformer feature lands. + +== Adding a new face + +The architecture (ADR-010) makes adding a face mechanical: + +1. Add a variant to `lib/face.ml`'s `face` type. +2. Write a `lib/_face.ml` text transformer (model on `python_face.ml`, `js_face.ml`, `pseudocode_face.ml`, `lucid_face.ml`, or `cafe_face.ml`). +3. Add error-vocabulary branches in `lib/face.ml`'s six `format_*_for_face` functions. +4. Register in `bin/main.ml`: `parse_with_face` arm, `face_arg` enum entry, optional `preview-*` debug command. +5. Add name aliases to `lib/face_pragma.ml`. + +No compiler internals change: the transformer outputs canonical text, which the existing pipeline (resolve → typecheck → borrow → quantity → codegen → tw_verify) processes unchanged. diff --git a/examples/faces/hello-cafe.affine b/examples/faces/hello-cafe.affine new file mode 100644 index 0000000..c967af8 --- /dev/null +++ b/examples/faces/hello-cafe.affine @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# +# CafeScripto face. Distinctive features exercised: `#` line comments, +# `###` block comments, paren-arrow `(args) -> body`, bare-arrow `-> body`, +# Yes/No/On/Off literal aliases. +# face: cafescripto + +### + All other CoffeeScript surface conveniences — postfix-if, @ shorthand, + `unless`/`until`, list comprehensions, no-paren calls — are documented + in examples/faces/README.adoc with their current support status. +### + +effect IO { + fn println(s: String) -> (); +} + +fn main() -{IO}-> () { + let greeting = "Hello, CafeScripto!"; + let ready = Yes; + println(greeting); +} diff --git a/examples/faces/hello-canonical.affine b/examples/faces/hello-canonical.affine new file mode 100644 index 0000000..67844de --- /dev/null +++ b/examples/faces/hello-canonical.affine @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Canonical face — the AffineScript reference syntax. All other faces in +// this directory lower to this same shape via their respective transformers. +// face: canonical + +effect IO { + fn println(s: String) -> (); +} + +fn main() -{IO}-> () { + let greeting = "Hello, AffineScript!"; + println(greeting); +} diff --git a/examples/faces/hello-jaffa.affine b/examples/faces/hello-jaffa.affine new file mode 100644 index 0000000..3e4aedb --- /dev/null +++ b/examples/faces/hello-jaffa.affine @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// JaffaScript face. Distinctive features exercised: `function` keyword, +// `const`/`let` bindings (let lowers to let mut), brace-delimited +// blocks, `===` equality. +// face: jaffascript + +effect IO { + fn println(s: String) -> (); +} + +function main() -{IO}-> () { + const greeting = "Hello, JaffaScript!"; + println(greeting); +} diff --git a/examples/faces/hello-lucid.affine b/examples/faces/hello-lucid.affine new file mode 100644 index 0000000..7476ec0 --- /dev/null +++ b/examples/faces/hello-lucid.affine @@ -0,0 +1,19 @@ +-- SPDX-License-Identifier: AGPL-3.0-or-later +-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +-- +-- LucidScript face. Distinctive features exercised: module declaration +-- with `where`, type signatures kept as comments, equation-style +-- function definitions, `--` line comments. (The unit-parameter idiom +-- `main () = …` lowers to canonical `fn main() { … }`. Function +-- application uses canonical paren syntax `f(x)` rather than Haskell +-- currying `f x` — see examples/faces/README.adoc.) +-- face: lucidscript + +module Hello where + +effect IO { + fn println(s: String) -> (); +} + +main :: -{IO}-> () +main () = println("Hello, LucidScript!") diff --git a/examples/faces/hello-pseudo.affine b/examples/faces/hello-pseudo.affine new file mode 100644 index 0000000..24ea6f0 --- /dev/null +++ b/examples/faces/hello-pseudo.affine @@ -0,0 +1,17 @@ +-- SPDX-License-Identifier: AGPL-3.0-or-later +-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +-- +-- PseudoScript face. Distinctive features exercised: +-- function ... do ... end blocks, `--` Haskell/SQL-style comments. +-- (Note: pseudocode-face does not yet auto-insert statement separators, +-- so this minimal demo keeps each function body to a single expression. +-- See examples/faces/README.adoc for a list of pending transformer gaps.) +-- face: pseudoscript + +effect IO { + fn println(s: String) -> (); +} + +function main() -{IO}-> () do + println("Hello, PseudoScript!") +end diff --git a/examples/faces/hello-rattle.affine b/examples/faces/hello-rattle.affine new file mode 100644 index 0000000..c8a68d0 --- /dev/null +++ b/examples/faces/hello-rattle.affine @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# +# RattleScript face. Distinctive features exercised: `def` keyword, +# indentation as block delimiter, `#` line comments, `True/False/None` +# literals, `and/or/not` boolean keywords, `:` block opener. +# face: rattlescript + +effect IO: + fn println(s: String) -> (); + +def main() -{IO}-> (): + let greeting = "Hello, RattleScript!" + println(greeting) diff --git a/justfile b/justfile index 194c7ab..9165c6d 100644 --- a/justfile +++ b/justfile @@ -35,6 +35,18 @@ test: conformance: dune runtest conformance +# Run face-transformer regression tests (snapshot diff + round-trip parse) +test-faces: + ./tools/run_face_transformer_tests.sh + +# Record any missing face-transformer snapshots (first-run / new face) +test-faces-record: + ./tools/run_face_transformer_tests.sh --record-missing + +# Update all face-transformer snapshots (intentional transformer change) +test-faces-update: + ./tools/run_face_transformer_tests.sh --update + # ── Format / Lint ───────────────────────────────────────────────────────────── # Run format check (lint) diff --git a/lib/cafe_face.ml b/lib/cafe_face.ml new file mode 100644 index 0000000..ddd71cd --- /dev/null +++ b/lib/cafe_face.ml @@ -0,0 +1,519 @@ +(* SPDX-License-Identifier: PMPL-1.0-or-later *) +(* SPDX-FileCopyrightText: 2024-2026 Jonathan D.A. Jewell (hyperpolymath) *) + +(** CafeScripto-face: source-level transformer for CoffeeScript-style + AffineScript. + + Cafe is the catchment face for CoffeeScript developers. It marries + significant indentation with arrow-function literals and the + postfix-if / unless / until / @-prefix conveniences that make + CoffeeScript feel like CoffeeScript. + + Surface mappings: + {v + # comment → // comment + ### block ### → /* block */ + class Foo extends Bar → type Foo /* extends Bar */ + class Foo → type Foo + @x → self.x + @method() → self.method() + (x) -> body → (x) => body + (x) => body → (x) => body (CoffeeScript bound; canonical lambda) + -> body → () => body + unless cond → if !(cond) + until cond → while !(cond) + expr if cond → if cond { expr } + expr unless cond → if !(cond) { expr } + if x then y else z → if x { y } else { z } + loop → loop { + Yes / On / true → true + No / Off / false → false + null / undefined → () + return expr (last in block) → expr (last-expression semantics) + INDENT → (block opened by preceding {) + DEDENT → } + v} + + Limitations (deferred to AST-level rewrites): + - List comprehensions [(x*2 for x in xs when cond)] are NOT lowered; + use [.map] / [.filter] explicitly. + - No-paren calls [f x, y] are NOT lowered; write [f(x, y)]. + - Implicit object literals [a: 1, b: 2] without braces are NOT lowered + where they cross indentation boundaries. + - Splats and destructuring [...] are NOT lowered. + - Span fidelity: errors refer to the canonical text, not Cafe source. +*) + +(* ─── Character helpers ──────────────────────────────────────────────── *) + +let is_id_char c = + (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || c = '_' + +let starts_with s prefix = + let sl = String.length s and pl = String.length prefix in + sl >= pl && String.sub s 0 pl = prefix + +let ends_with s suffix = + let sl = String.length s and tl = String.length suffix in + sl >= tl && String.sub s (sl - tl) tl = suffix + +let indent_of line = + let len = String.length line in + let i = ref 0 in + while !i < len && (line.[!i] = ' ' || line.[!i] = '\t') do incr i done; + !i + +(* ─── Comment handling ───────────────────────────────────────────────── *) + +(** Split a line at a Cafe-style [#] comment, respecting string literals + (including the backtick form CoffeeScript inherits from JS). Returns + [(code_part, comment_text option)]. *) +let strip_hash_comment line = + let len = String.length line in + let in_str = ref false in + let str_delim = ref '"' in + let cut = ref (-1) in + let i = ref 0 in + while !i < len && !cut < 0 do + let c = line.[!i] in + if !in_str then begin + if c = !str_delim && (!i = 0 || line.[!i - 1] <> '\\') then in_str := false + end else begin + if c = '"' || c = '\'' || c = '`' then begin in_str := true; str_delim := c end + else if c = '#' then cut := !i + end; + incr i + done; + if !cut < 0 then (line, None) + else (String.sub line 0 !cut, + Some (String.sub line (!cut + 1) (len - !cut - 1))) + +(** Convert a [###] block-comment delimiter line to canonical [/*] / [*/]. *) +let convert_block_comment_delim line = + let trimmed = String.trim line in + if trimmed = "###" then + let indent_len = String.length line - String.length trimmed in + let indent = String.sub line 0 indent_len in + Some (indent, trimmed) + else None + +(* ─── Word substitution ──────────────────────────────────────────────── *) + +let replace_word ~from_w ~to_w s = + let flen = String.length from_w in + let slen = String.length s in + let buf = Buffer.create slen in + let i = ref 0 in + while !i < slen do + if slen - !i >= flen && String.sub s !i flen = from_w then begin + let before_ok = !i = 0 || not (is_id_char s.[!i - 1]) in + let after_ok = !i + flen >= slen || not (is_id_char s.[!i + flen]) in + if before_ok && after_ok then begin + Buffer.add_string buf to_w; + i := !i + flen + end else begin + Buffer.add_char buf s.[!i]; + incr i + end + end else begin + Buffer.add_char buf s.[!i]; + incr i + end + done; + Buffer.contents buf + +(** CoffeeScript literal aliases. *) +let apply_literal_subs s = + let s = replace_word ~from_w:"Yes" ~to_w:"true" s in + let s = replace_word ~from_w:"yes" ~to_w:"true" s in + let s = replace_word ~from_w:"On" ~to_w:"true" s in + let s = replace_word ~from_w:"on" ~to_w:"true" s in + let s = replace_word ~from_w:"No" ~to_w:"false" s in + let s = replace_word ~from_w:"no" ~to_w:"false" s in + let s = replace_word ~from_w:"Off" ~to_w:"false" s in + let s = replace_word ~from_w:"off" ~to_w:"false" s in + let s = replace_word ~from_w:"null" ~to_w:"()" s in + let s = replace_word ~from_w:"undefined" ~to_w:"()" s in + s + +(** Logical-keyword aliases. CoffeeScript accepts both [and]/[or]/[not] + and the symbol forms. *) +let apply_logic_subs s = + let s = replace_word ~from_w:"and" ~to_w:"&&" s in + let s = replace_word ~from_w:"or" ~to_w:"||" s in + let s = replace_word ~from_w:"not" ~to_w:"!" s in + let s = replace_word ~from_w:"isnt" ~to_w:"!=" s in + s + +(* ─── @-shorthand ────────────────────────────────────────────────────── *) + +(** Replace bare [@] (when followed by an id char or end) with [self.] or + [self] respectively. Skips occurrences inside string literals. *) +let expand_at_shorthand s = + let len = String.length s in + let buf = Buffer.create len in + let in_str = ref false in + let str_delim = ref '"' in + let i = ref 0 in + while !i < len do + let c = s.[!i] in + if !in_str then begin + Buffer.add_char buf c; + if c = !str_delim && (!i = 0 || s.[!i - 1] <> '\\') then in_str := false; + incr i + end else if c = '"' || c = '\'' || c = '`' then begin + in_str := true; str_delim := c; + Buffer.add_char buf c; incr i + end else if c = '@' then begin + let prev_ok = !i = 0 || not (is_id_char s.[!i - 1]) in + if prev_ok then begin + if !i + 1 < len && is_id_char s.[!i + 1] then + Buffer.add_string buf "self." + else + Buffer.add_string buf "self" + end else + Buffer.add_char buf c; + incr i + end else begin + Buffer.add_char buf c; + incr i + end + done; + Buffer.contents buf + +(* ─── Arrow function bodies ──────────────────────────────────────────── *) + +(** [(args) -> body] / [-> body] / [(args) => body] → canonical [(args) => body]. + Only handles arrows that appear after a complete parameter parenthesis + or as a leading [->] (parameter-less arrow). *) +let transform_arrow s = + let len = String.length s in + let buf = Buffer.create len in + let in_str = ref false in + let str_delim = ref '"' in + let i = ref 0 in + while !i < len do + let c = s.[!i] in + if !in_str then begin + Buffer.add_char buf c; + if c = !str_delim && (!i = 0 || s.[!i - 1] <> '\\') then in_str := false; + incr i + end else if c = '"' || c = '\'' || c = '`' then begin + in_str := true; str_delim := c; + Buffer.add_char buf c; incr i + end else if !i + 1 < len && c = '-' && s.[!i + 1] = '>' then begin + (* Skip back past whitespace to find the previous non-blank char. + For [(x) -> body] the layout is [)] [SP] [-] [>], so we must + walk past the space to see the [)]. *) + let prev_idx = ref (!i - 1) in + while !prev_idx >= 0 + && (s.[!prev_idx] = ' ' || s.[!prev_idx] = '\t') do + decr prev_idx + done; + let prev_char = if !prev_idx < 0 then None else Some s.[!prev_idx] in + (* Don't touch a [-> T] that is part of a function return type: + that pattern is preceded by some identifier or [)] that's part + of a type ascription [s: T] context — heuristic: if the + preceding non-blank char is an identifier char (lowercase + letter, common in type names), it's likely a type arrow, not a + lambda. We treat only [)] (paren-arrow) and start-of-line / + post-[=] (bare arrow) as lambda introducers. *) + (match prev_char with + | Some ')' -> + Buffer.add_string buf "=>"; + i := !i + 2 + | None | Some '=' -> + Buffer.add_string buf "() =>"; + i := !i + 2 + | _ -> + (* Likely a type arrow [-> T] — leave intact. *) + Buffer.add_char buf c; + incr i) + end else begin + Buffer.add_char buf c; + incr i + end + done; + Buffer.contents buf + +(* ─── Postfix conditionals ───────────────────────────────────────────── *) + +(** Move [expr if cond] / [expr unless cond] to canonical prefix form. *) +let transform_postfix_conditional stripped = + let if_re = Str.regexp_string " if " in + let unless_re = Str.regexp_string " unless " in + (* Postfix-if first; only if [if] doesn't start the line. *) + let try_split kw_pos kw_len negate = + let body = String.trim (String.sub stripped 0 kw_pos) in + let cond = String.trim (String.sub stripped (kw_pos + kw_len) + (String.length stripped - kw_pos - kw_len)) in + if negate then + Printf.sprintf "if !(%s) { %s }" cond body + else + Printf.sprintf "if %s { %s }" cond body + in + if not (starts_with stripped "if ") then begin + try Some (try_split (Str.search_forward if_re stripped 0) 4 false) + with Not_found -> + try Some (try_split (Str.search_forward unless_re stripped 0) 8 true) + with Not_found -> None + end else begin + try Some (try_split (Str.search_forward unless_re stripped 0) 8 true) + with Not_found -> None + end + +(* ─── Block-opener detection ─────────────────────────────────────────── *) + +(** A line is a block-opener if it's a control structure that introduces + an indented body, where CoffeeScript would emit no brace but Cafe + needs one. We trigger on the family head and let the indent stack + insert [}] on dedent. *) +let block_opening_prefixes = [ + "if "; "while "; "for "; "switch "; "loop"; "try"; "catch"; "finally"; + "class "; "->"; "=>"; +] + +(** Lines that are already canonical AffineScript and should pass through + untouched — emitted without a trailing semicolon since they introduce + or close a block on their own. *) +let is_canonical_passthrough stripped = + let prefixes = ["effect "; "trait "; "impl "; "type "; "fn "; "module "; + "use "; "pub "; "struct "; "enum "] in + List.exists (fun p -> starts_with stripped p) prefixes + || stripped = "}" || stripped = "};" + +(** True if [stripped] is a control-flow block opener whose body is + indented (no inline [then …]). *) +let is_indent_block_opener stripped = + if stripped = "loop" || stripped = "try" || stripped = "finally" then true + else if List.exists (fun p -> starts_with stripped p) block_opening_prefixes then begin + (* If the line contains "then" inline, it's a single-line if and not + an indent opener. *) + let has_inline_then = + try ignore (Str.search_forward (Str.regexp_string " then ") stripped 0); true + with Not_found -> false + in + not has_inline_then + end else false + +(** Render the head of a block-opener as canonical syntax. *) +let render_block_head stripped = + if stripped = "loop" then "loop {" + else if stripped = "try" then "try {" + else if stripped = "finally" then "} finally {" + else if starts_with stripped "else if " then begin + let rest = String.sub stripped 8 (String.length stripped - 8) in + "} else if " ^ rest ^ " {" + end + else if stripped = "else" then "} else {" + else if starts_with stripped "catch " then + let rest = String.sub stripped 6 (String.length stripped - 6) in + "} catch (" ^ rest ^ ") {" + else if starts_with stripped "class " then begin + let rest = String.sub stripped 6 (String.length stripped - 6) in + let rest = + try + let i = Str.search_forward (Str.regexp_string " extends ") rest 0 in + let name = String.sub rest 0 i in + let parent = String.sub rest (i + 9) (String.length rest - i - 9) in + Printf.sprintf "%s /* extends %s */" name parent + with Not_found -> rest + in + "type " ^ rest ^ " {" + end + else stripped ^ " {" + +(* ─── Inline if/then/else, unless, until ─────────────────────────────── *) + +(** [if c then a else b] → braced form. [unless], [until] are preprocessed + into [if !(...)] / [while !(...)] in [normalise_negated_keywords]. *) +let transform_if_then_else stripped = + if not (starts_with stripped "if ") then None + else begin + let then_re = Str.regexp_string " then " in + let else_re = Str.regexp_string " else " in + try + let then_pos = Str.search_forward then_re stripped 0 in + let cond = String.trim (String.sub stripped 3 (then_pos - 3)) in + try + let else_pos = Str.search_forward else_re stripped (then_pos + 5) in + let t_branch = String.trim + (String.sub stripped (then_pos + 6) (else_pos - then_pos - 6)) in + let e_branch = String.trim + (String.sub stripped (else_pos + 6) (String.length stripped - else_pos - 6)) in + Some (Printf.sprintf "if %s { %s } else { %s }" cond t_branch e_branch) + with Not_found -> + let body = String.trim + (String.sub stripped (then_pos + 6) (String.length stripped - then_pos - 6)) in + Some (Printf.sprintf "if %s { %s }" cond body) + with Not_found -> None + end + +(** Replace leading [unless cond] / [until cond] with negated forms. *) +let normalise_negated_keywords stripped = + if starts_with stripped "unless " then + "if !(" ^ String.sub stripped 7 (String.length stripped - 7) ^ ")" + else if starts_with stripped "until " then + "while !(" ^ String.sub stripped 6 (String.length stripped - 6) ^ ")" + else stripped + +(* ─── return stripping (last-expression) ─────────────────────────────── *) + +let strip_return stripped = + if starts_with stripped "return " then + String.sub stripped 7 (String.length stripped - 7) + else if stripped = "return" then "()" + else stripped + +(* ─── Blank-line check ───────────────────────────────────────────────── *) + +let is_blank_line raw = + let (code, _) = strip_hash_comment (String.trim raw) in + String.trim code = "" + +(* ─── Main transformer ───────────────────────────────────────────────── *) + +let transform_source source = + let lines = Array.of_list (String.split_on_char '\n' source) in + let n = Array.length lines in + let out = Buffer.create (String.length source + 256) in + let stack = ref [0] in + let in_block_comment = ref false in + (* Tracks how many canonical-passthrough lines ending in [{] are + currently open. While [brace_depth > 0] we don't push indent + levels for body lines — the explicit braces handle scope and any + [}] in the source closes them, so synthetic indent-driven [}] + would double-close. *) + let brace_depth = ref 0 in + + let top () = match !stack with h :: _ -> h | [] -> 0 in + + let emit_dedents target = + while top () > target do + Buffer.add_string out "}\n"; + stack := List.tl !stack + done + in + + let next_meaningful_indent i = + let j = ref (i + 1) in + while !j < n && is_blank_line lines.(!j) do incr j done; + if !j >= n then -1 else indent_of lines.(!j) + in + + for i = 0 to n - 1 do + let raw_line = lines.(i) in + + (* ### block-comment delimiters: convert and pass through. *) + (match convert_block_comment_delim raw_line with + | Some (indent, _) when not !in_block_comment -> + in_block_comment := true; + Buffer.add_string out (indent ^ "/*\n") + | Some (indent, _) when !in_block_comment -> + in_block_comment := false; + Buffer.add_string out (indent ^ "*/\n") + | _ -> + if !in_block_comment then begin + Buffer.add_string out raw_line; + Buffer.add_char out '\n' + end else begin + let ind = indent_of raw_line in + let (code_part, comment_opt) = strip_hash_comment (String.trim raw_line) in + let stripped = String.trim code_part in + + let with_comment line_text = + match comment_opt with + | None -> line_text ^ "\n" + | Some c -> line_text ^ " // " ^ String.trim c ^ "\n" + in + + if stripped = "" then begin + (match comment_opt with + | Some c -> Buffer.add_string out ("// " ^ String.trim c ^ "\n") + | None -> Buffer.add_char out '\n') + end else if is_canonical_passthrough stripped then begin + (* Canonical AffineScript declaration line. The user is + embedding a canonical block ([effect], [fn], [trait], etc.) + directly. Pass through verbatim — don't run the arrow / + literal / logic pipeline (it would mangle [-> T] return + types and other canonical syntax). We DO emit any pending + dedents at this indent level so a closing canonical [}] + at zero indent properly drops indented Cafe-block scopes + that were pushed earlier. *) + emit_dedents ind; + let n_chars = String.length stripped in + if n_chars > 0 && stripped.[n_chars - 1] = '{' then + incr brace_depth + else if (stripped = "}" || stripped = "};") && !brace_depth > 0 then + decr brace_depth; + let indent_str = String.make ind ' ' in + Buffer.add_string out (indent_str ^ with_comment stripped) + end else begin + (* Inside an explicit-brace canonical block we suppress the + indent push — the user's [{ … }] handles scope, and any + indent-driven [}] would double-close. *) + if !brace_depth = 0 then begin + emit_dedents ind; + if ind > top () then stack := ind :: !stack + end; + + let indent_str = String.make ind ' ' in + let next_ind = next_meaningful_indent i in + let is_tail = next_ind < ind in + + (* Pipeline of expression-level rewrites. *) + let prepared = + stripped + |> normalise_negated_keywords + |> apply_literal_subs + |> apply_logic_subs + |> expand_at_shorthand + |> transform_arrow + |> strip_return + in + + (* Add [;] only when the line doesn't already end with one + (CoffeeScript users may write the [;] explicitly when + embedding canonical-style statements). *) + let with_semi s = + if ends_with (String.trim s) ";" then s else s ^ ";" + in + let line_text = + match transform_postfix_conditional prepared with + | Some s -> + if is_tail then s else with_semi s + | None -> + match transform_if_then_else prepared with + | Some s -> + if is_tail then s else with_semi s + | None -> + if is_indent_block_opener prepared then + render_block_head prepared + else if is_tail then + prepared + else + with_semi prepared + in + Buffer.add_string out (indent_str ^ with_comment line_text) + end + end) + done; + emit_dedents 0; + Buffer.contents out + +(* ─── Entry points ───────────────────────────────────────────────────── *) + +let parse_string_cafe ~file content = + let canonical = transform_source content in + Parse_driver.parse_string ~file canonical + +let parse_file_cafe path = + let chan = open_in_bin path in + Fun.protect + ~finally:(fun () -> close_in chan) + (fun () -> + let content = really_input_string chan (in_channel_length chan) in + parse_string_cafe ~file:path content) + +let preview_transform source = transform_source source diff --git a/lib/dune b/lib/dune index 2e45b03..3200c5f 100644 --- a/lib/dune +++ b/lib/dune @@ -2,7 +2,7 @@ (name affinescript) (public_name affinescript) (modes byte native) - (modules ast borrow codegen codegen_gc desugar_traits effect error error_collector error_formatter face formatter interp js_face julia_codegen json_output lexer linter lsp_server module_loader opt parse_driver parse parser parser_errors pseudocode_face python_face quantity resolve span symbol tea_bridge tea_cs_bridge tea_router token trait tw_interface tw_verify typecheck types unify value wasm wasm_encode wasm_gc wasm_gc_encode wasi_runtime) + (modules ast borrow c_codegen cafe_face codegen codegen_gc desugar_traits effect error error_collector error_formatter face face_pragma formatter interp js_codegen js_face julia_codegen json_output lexer linter lsp_server lucid_face module_loader opt parse_driver parse parser parser_errors pseudocode_face python_face quantity resolve span symbol tea_bridge tea_cs_bridge tea_router token trait tw_interface tw_verify typecheck types unify value wasm wasm_encode wasm_gc wasm_gc_encode wasi_runtime wgsl_codegen) (libraries str unix sedlex fmt menhirLib yojson) (preprocess (pps ppx_deriving.show ppx_deriving.eq ppx_deriving.ord sedlex.ppx))) diff --git a/lib/face.ml b/lib/face.ml index 10b918c..5be23c7 100644 --- a/lib/face.ml +++ b/lib/face.ml @@ -23,6 +23,8 @@ type face = | Python (** Python-style surface syntax; Python-friendly messages. *) | Js (** JavaScript/TypeScript-style syntax; JS-friendly messages. *) | Pseudocode (** Natural-language-adjacent pseudocode; beginner-friendly messages. *) + | Lucid (** PureScript/Haskell-style surface; Haskell-family vocabulary. *) + | Cafe (** CoffeeScript-style surface; concise JS-family vocabulary. *) (* ─── Helpers ────────────────────────────────────────────────────────── *) @@ -48,6 +50,19 @@ let render_ty (face : face) (ty : Types.ty) : string = let s = if s = "Unit" then "nothing" else s in let s = if s = "Bool" then "Boolean" else s in s + | Lucid -> + (* PureScript/Haskell vocabulary: Unit stays Unit, Bool → Boolean, + Option[T] → Maybe T (PureScript spelling). *) + let s = if s = "Bool" then "Boolean" else s in + let s = Str.global_replace (Str.regexp {|Option\[\(.*\)\]|}) {|Maybe \1|} s in + s + | Cafe -> + (* CoffeeScript inherits JS types; render in JS-style with the + optional postfix [?] for Option (CoffeeScript existential). *) + let s = if s = "Unit" then "null" else s in + let s = if s = "Bool" then "Boolean" else s in + let s = Str.global_replace (Str.regexp {|Option\[\(.*\)\]|}) {|\1?|} s in + s (* ─── Quantity / ownership errors ────────────────────────────────────── *) @@ -138,6 +153,59 @@ let format_quantity_error (face : face) (err : Quantity.quantity_error) : string in Printf.sprintf "ERROR: '%s' was declared as %s but used in an inconsistent way." id.name decl) + | Lucid -> + (match err with + | Quantity.LinearVariableUnused id -> + Printf.sprintf + "Linearity error: linear variable '%s' is never consumed.\n\ + A linear value must be consumed exactly once. Bind it to '_' \ + or pass it to a function that takes ownership." + id.name + | Quantity.LinearVariableUsedMultiple id -> + Printf.sprintf + "Linearity error: linear variable '%s' is used more than once.\n\ + A linear value cannot be duplicated. Use Data.Clone (clone) to \ + produce an independent copy, or refactor so it flows through a \ + single consumer." + id.name + | Quantity.ErasedVariableUsed id -> + Printf.sprintf + "Erasure error: '%s' has multiplicity 0 and exists only at compile time. \ + It cannot appear in a runtime expression." + id.name + | Quantity.QuantityMismatch (id, q, _u) -> + let decl = match q with + | Ast.QZero -> "Erased (multiplicity 0)" + | Ast.QOne -> "Linear (multiplicity 1)" + | Ast.QOmega -> "Unrestricted (multiplicity ω)" + in + Printf.sprintf + "Multiplicity error: '%s' was declared %s but is used at a different multiplicity." + id.name decl) + | Cafe -> + (match err with + | Quantity.LinearVariableUnused id -> + Printf.sprintf + "Resource leak: '%s' is a one-shot value but was never used.\n\ + hint: pass it to something that consumes it, or rename to '_%s'." + id.name id.name + | Quantity.LinearVariableUsedMultiple id -> + Printf.sprintf + "Resource error: '%s' is a one-shot value — you used it more than once.\n\ + hint: clone before the second use, or restructure so it's consumed only once." + id.name + | Quantity.ErasedVariableUsed id -> + Printf.sprintf + "Compile-time value '%s' cannot show up at runtime." + id.name + | Quantity.QuantityMismatch (id, q, _u) -> + let decl = match q with + | Ast.QZero -> "compile-time" + | Ast.QOne -> "one-shot" + | Ast.QOmega -> "shareable" + in + Printf.sprintf "Resource error: '%s' was declared %s but used inconsistently." + id.name decl) (* ─── Unification errors ─────────────────────────────────────────────── *) @@ -204,6 +272,46 @@ let format_unify_error (face : face) (ue : Unify.unify_error) : string = "TYPE ERROR: wrong kind of type argument" | Unify.LabelNotFound (label, _) -> Printf.sprintf "FIELD ERROR: the record has no field named '%s'" label) + | Lucid -> + (match ue with + | Unify.TypeMismatch (expected, got) -> + Printf.sprintf "Could not match type '%s' with type '%s'" + (render_ty face got) + (render_ty face expected) + | Unify.OccursCheck _ -> + "Cannot construct the infinite type — a type variable refers to itself" + | Unify.RowMismatch _ -> + "Could not match record type — labels differ" + | Unify.RowOccursCheck _ -> + "Cannot construct the infinite record type" + | Unify.EffectMismatch _ -> + "Could not match effect rows — function performs effects not in its signature" + | Unify.EffectOccursCheck _ -> + "Cannot construct the infinite effect row" + | Unify.KindMismatch _ -> + "Kind mismatch — type-level argument has the wrong kind" + | Unify.LabelNotFound (label, _) -> + Printf.sprintf "No field '%s' in record" label) + | Cafe -> + (match ue with + | Unify.TypeMismatch (expected, got) -> + Printf.sprintf "Type doesn't fit: expected %s, got %s" + (render_ty face expected) + (render_ty face got) + | Unify.OccursCheck _ -> + "Type loops back on itself" + | Unify.RowMismatch _ -> + "Object shape doesn't match — missing or extra fields" + | Unify.RowOccursCheck _ -> + "Object type loops back on itself" + | Unify.EffectMismatch _ -> + "Effect mismatch: function does effects that weren't declared" + | Unify.EffectOccursCheck _ -> + "Effect type loops back on itself" + | Unify.KindMismatch _ -> + "Type argument is the wrong kind" + | Unify.LabelNotFound (label, _) -> + Printf.sprintf "No field '%s' on this object" label) (* ─── Type errors ────────────────────────────────────────────────────── *) @@ -328,6 +436,82 @@ let format_type_error (face : face) (err : Typecheck.type_error) : string = (render_ty face else_ty) | Typecheck.QuantityError (qerr, _span) -> format_quantity_error face qerr) + | Lucid -> + (match err with + | Typecheck.UnboundVariable v -> + Printf.sprintf "Variable not in scope: '%s'\n\ + hint: bring it into scope with 'import' or define it with '%s = ...'." + v v + | Typecheck.TypeMismatch { expected; got } -> + Printf.sprintf "Could not match expected type '%s' with actual type '%s'" + (render_ty face expected) + (render_ty face got) + | Typecheck.OccursCheck (v, ty) -> + Printf.sprintf "Cannot construct the infinite type: %s ~ %s" + v (render_ty face ty) + | Typecheck.NotImplemented msg -> + Printf.sprintf "Compiler limitation: %s" msg + | Typecheck.ArityMismatch { name; expected; got } -> + Printf.sprintf "'%s' takes %d argument%s but was applied to %d" + name expected (if expected = 1 then "" else "s") got + | Typecheck.NotAFunction ty -> + Printf.sprintf "This is not a function: it has type %s" (render_ty face ty) + | Typecheck.FieldNotFound { field; record_ty } -> + Printf.sprintf "No field '%s' in record of type '%s'" + field (render_ty face record_ty) + | Typecheck.TupleIndexOutOfBounds { index; length } -> + Printf.sprintf "Tuple index %d out of range (tuple has %d element%s)" + index length (if length = 1 then "" else "s") + | Typecheck.DuplicateField f -> + Printf.sprintf "Duplicate label '%s' in record" f + | Typecheck.UnificationError ue -> + format_unify_error face ue + | Typecheck.PatternTypeMismatch msg -> + Printf.sprintf "Pattern match error: %s" msg + | Typecheck.BranchTypeMismatch { then_ty; else_ty } -> + Printf.sprintf + "Branches of 'if' have different types: then-branch returns %s, \ + else-branch returns %s — both branches must agree." + (render_ty face then_ty) + (render_ty face else_ty) + | Typecheck.QuantityError (qerr, _span) -> + format_quantity_error face qerr) + | Cafe -> + (match err with + | Typecheck.UnboundVariable v -> + Printf.sprintf "Can't find '%s'.\n\ + hint: declare it first with '%s = ...'." v v + | Typecheck.TypeMismatch { expected; got } -> + Printf.sprintf "Type doesn't fit: expected %s, got %s." + (render_ty face expected) + (render_ty face got) + | Typecheck.OccursCheck (v, ty) -> + Printf.sprintf "Type '%s' loops back on itself: %s = %s" + v v (render_ty face ty) + | Typecheck.NotImplemented msg -> + Printf.sprintf "Compiler limitation: %s" msg + | Typecheck.ArityMismatch { name; expected; got } -> + Printf.sprintf "'%s' wants %d argument%s but got %d." + name expected (if expected = 1 then "" else "s") got + | Typecheck.NotAFunction ty -> + Printf.sprintf "This isn't callable — it's a %s." (render_ty face ty) + | Typecheck.FieldNotFound { field; record_ty } -> + Printf.sprintf "No field '%s' on type %s." field (render_ty face record_ty) + | Typecheck.TupleIndexOutOfBounds { index; length } -> + Printf.sprintf "Tuple index %d out of range (length %d)." index length + | Typecheck.DuplicateField f -> + Printf.sprintf "Field '%s' appears twice." f + | Typecheck.UnificationError ue -> + format_unify_error face ue + | Typecheck.PatternTypeMismatch msg -> + Printf.sprintf "Pattern error: %s" msg + | Typecheck.BranchTypeMismatch { then_ty; else_ty } -> + Printf.sprintf + "Branches don't match: then→%s, else→%s. Both branches need the same type." + (render_ty face then_ty) + (render_ty face else_ty) + | Typecheck.QuantityError (qerr, _span) -> + format_quantity_error face qerr) (* ─── Borrow errors ──────────────────────────────────────────────────── *) @@ -347,6 +531,21 @@ let format_borrow_error (face : face) (err : Borrow.borrow_error) : string = | _ -> "Ownership error: " ^ msg) | Js -> Borrow.format_borrow_error err | Pseudocode -> Borrow.format_borrow_error err + | Lucid -> + (* Linear-Haskell vocabulary: borrowing is ownership transfer. *) + let msg = Borrow.format_borrow_error err in + (match err with + | Borrow.UseAfterMove _ -> + "Linearity error (use-after-move): " ^ msg + | Borrow.CannotBorrowAsMutable _ -> + "Aliasing error (mutable borrow blocked): " ^ msg + | _ -> "Linearity error: " ^ msg) + | Cafe -> + let msg = Borrow.format_borrow_error err in + (match err with + | Borrow.UseAfterMove _ -> "Use-after-move: " ^ msg + | Borrow.CannotBorrowAsMutable _ -> "Cannot borrow as mutable: " ^ msg + | _ -> "Borrow error: " ^ msg) (* ─── Resolve errors ─────────────────────────────────────────────────── *) @@ -412,3 +611,42 @@ let format_resolve_error (face : face) (err : Resolve.resolve_error) : string = Printf.sprintf "ERROR: '%s' is not accessible here: %s" id.name msg | Resolve.ImportError msg -> Printf.sprintf "IMPORT ERROR: %s" msg) + | Lucid -> + (match err with + | Resolve.UndefinedVariable id -> + Printf.sprintf "Variable not in scope: '%s'\n\ + hint: import the module that exports it, or define it with '%s = ...'." + id.name id.name + | Resolve.UndefinedType id -> + Printf.sprintf "Type constructor not in scope: '%s'\n\ + hint: declare 'data %s = ...' or import it." + id.name id.name + | Resolve.UndefinedEffect id -> + Printf.sprintf "Effect '%s' not in scope" id.name + | Resolve.UndefinedModule id -> + Printf.sprintf "Could not find module '%s'\n\ + hint: 'import %s' at the top of the file." + id.name id.name + | Resolve.DuplicateDefinition id -> + Printf.sprintf "Multiple declarations of '%s'" id.name + | Resolve.VisibilityError (id, msg) -> + Printf.sprintf "'%s' is not exported from its module: %s" id.name msg + | Resolve.ImportError msg -> + Printf.sprintf "Import error: %s" msg) + | Cafe -> + (match err with + | Resolve.UndefinedVariable id -> + Printf.sprintf "Can't find '%s'.\n\ + hint: assign to it first with '%s = ...'." id.name id.name + | Resolve.UndefinedType id -> + Printf.sprintf "Can't find type '%s' — declare or import it." id.name + | Resolve.UndefinedEffect id -> + Printf.sprintf "Effect '%s' isn't in scope." id.name + | Resolve.UndefinedModule id -> + Printf.sprintf "No module '%s' — require it first." id.name + | Resolve.DuplicateDefinition id -> + Printf.sprintf "'%s' is already defined here." id.name + | Resolve.VisibilityError (id, msg) -> + Printf.sprintf "'%s' isn't accessible: %s" id.name msg + | Resolve.ImportError msg -> + Printf.sprintf "Import error: %s" msg) diff --git a/lib/face_pragma.ml b/lib/face_pragma.ml new file mode 100644 index 0000000..05ced6c --- /dev/null +++ b/lib/face_pragma.ml @@ -0,0 +1,156 @@ +(* SPDX-License-Identifier: PMPL-1.0-or-later *) +(* SPDX-FileCopyrightText: 2024-2026 Jonathan D.A. Jewell (hyperpolymath) *) + +(** Face pragma detector. + + Reads a face declaration from the first non-blank, non-shebang lines of a + source file. The pragma is the canonical mechanism for selecting a parser + face when every source file shares the [.affine] extension; it is consulted + after an explicit [--face] CLI flag and before any extension-based hint. + + Recognised forms (case-insensitive on the face name; leading whitespace + permitted; separator is [:], [=], or whitespace): + {v + # face: rattle + // face: js + (* face: canonical *) + -- face: pseudocode + ; face = python + v} + + Recognised face names and aliases: + - canonical + - python | py | rattle | rattlescript + - js | javascript + - pseudocode | pseudo + + The detector scans up to {!max_lines} leading lines and stops at the first + non-comment, non-blank line so a stray ["face:"] later in the file is not + mistaken for a pragma. A leading shebang ([#!...]) is skipped. +*) + +(** Maximum number of leading lines scanned for a pragma. *) +let max_lines = 16 + +(** Default cap on bytes read from a source file when probing for a pragma. *) +let default_max_bytes = 4096 + +(** Split [s] into at most [n] lines on ['\n'], without including the + terminating newline. Stops scanning once [n] lines have been collected. *) +let split_lines (s : string) (n : int) : string list = + let len = String.length s in + let acc = ref [] in + let count = ref 0 in + let start = ref 0 in + let i = ref 0 in + while !i < len && !count < n do + if s.[!i] = '\n' then begin + acc := String.sub s !start (!i - !start) :: !acc; + start := !i + 1; + incr count + end; + incr i + done; + if !start < len && !count < n then + acc := String.sub s !start (len - !start) :: !acc; + List.rev !acc + +(** If [line] starts with a recognised comment marker, return the body of the + comment with the marker stripped. Returns [None] for blank or non-comment + lines (the caller must handle blank lines separately). *) +let strip_comment_marker (line : string) : string option = + let s = String.trim line in + let n = String.length s in + if n = 0 then None + else if n >= 2 && String.sub s 0 2 = "//" then + Some (String.sub s 2 (n - 2)) + else if n >= 2 && String.sub s 0 2 = "(*" then begin + let body = String.sub s 2 (n - 2) in + let bn = String.length body in + let body = + if bn >= 2 && String.sub body (bn - 2) 2 = "*)" + then String.sub body 0 (bn - 2) + else body + in + Some body + end + else if n >= 2 && String.sub s 0 2 = "--" then + Some (String.sub s 2 (n - 2)) + else if s.[0] = '#' then + Some (String.sub s 1 (n - 1)) + else if s.[0] = ';' then + Some (String.sub s 1 (n - 1)) + else None + +(** Resolve a face name (case-insensitive) to a {!Face.face}. Established + faces are accepted under both their generic names ([python], [js], + [pseudocode], [lucid], [cafe]) and their brand names ([rattlescript], + [jaffascript], [pseudoscript], [lucidscript], [cafescripto]). *) +let parse_face_name (name : string) : Face.face option = + match String.lowercase_ascii (String.trim name) with + | "affinescript" | "canonical" -> Some Face.Canonical + | "python" | "py" | "rattle" | "rattlescript" -> Some Face.Python + | "js" | "javascript" | "jaffa" | "jaffascript" -> Some Face.Js + | "pseudocode" | "pseudo" | "pseudoscript" -> Some Face.Pseudocode + | "lucid" | "lucidscript" | "purescript" | "ps" -> Some Face.Lucid + | "cafe" | "cafescripto" | "coffee" | "coffeescript" -> Some Face.Cafe + | _ -> None + +(** Try to interpret [body] (a comment body) as a [face: NAME] directive. + Tolerates [face:NAME], [face=NAME], or [face NAME] with arbitrary + surrounding whitespace. *) +let parse_directive (body : string) : Face.face option = + let body = String.trim body in + let lc = String.lowercase_ascii body in + if String.length lc < 4 || String.sub lc 0 4 <> "face" then None + else begin + let after = String.sub body 4 (String.length body - 4) in + let after = String.trim after in + let after = + if after = "" then after + else match after.[0] with + | ':' | '=' -> String.trim (String.sub after 1 (String.length after - 1)) + | _ -> after + in + let token = + try + let i = String.index after ' ' in + String.sub after 0 i + with Not_found -> after + in + parse_face_name token + end + +(** Detect a face pragma in the in-memory source [s]. *) +let detect_in_source (s : string) : Face.face option = + let lines = split_lines s max_lines in + let rec scan = function + | [] -> None + | line :: rest -> + let trimmed = String.trim line in + if trimmed = "" then scan rest + else if String.length trimmed >= 2 && String.sub trimmed 0 2 = "#!" then + scan rest + else begin + match strip_comment_marker line with + | None -> None (* hit code; stop *) + | Some body -> + begin match parse_directive body with + | Some _ as f -> f + | None -> scan rest (* non-pragma comment *) + end + end + in + scan lines + +(** Detect a face pragma in the file at [path]. Reads at most [max_bytes] + bytes from the head of the file and never raises: any I/O error yields + [None] so callers can fall through to the next dispatch step. *) +let detect_in_file ?(max_bytes = default_max_bytes) (path : string) : Face.face option = + try + let ic = open_in_bin path in + let len = min max_bytes (in_channel_length ic) in + let s = really_input_string ic len in + close_in ic; + detect_in_source s + with _ -> None diff --git a/lib/lucid_face.ml b/lib/lucid_face.ml new file mode 100644 index 0000000..165e3a4 --- /dev/null +++ b/lib/lucid_face.ml @@ -0,0 +1,678 @@ +(* SPDX-License-Identifier: PMPL-1.0-or-later *) +(* SPDX-FileCopyrightText: 2024-2026 Jonathan D.A. Jewell (hyperpolymath) *) + +(** LucidScript-face: source-level transformer for PureScript-style AffineScript. + + Lucid is the catchment face for PureScript / Haskell developers. The + semantic core (row polymorphism, ADTs, effects, type classes) already + aligns with AffineScript, so the face is mostly aesthetic translation + plus indentation-to-brace lowering. + + Surface mappings: + {v + module Foo where → module Foo { + import Data.X → use Data::X; + import Data.X (a, b) → use Data::X::{a, b}; + import Data.X (a, b) as M → use Data::X::{a, b} as M; + data Maybe a = Just a | Nothing + → type Maybe[a] = Just(a) | Nothing + class Eq a where → trait Eq[a] { + instance Eq Foo where → impl Eq for Foo { + f :: Int -> Int → // f :: Int -> Int (signature kept as comment) + f x y = expr → fn f(x, y) = expr + \x -> expr → (x) => expr + if c then a else b → if c { a } else { b } + case e of → match e { + Just x -> body → Just(x) => body + Nothing -> other → Nothing => other + let x = 1 in expr → { let x = 1; expr } + True / False → true / false + Unit → () + -- comment → // comment + INDENT (where/of/let body) → (block opened by preceding {) + DEDENT → } + v} + + Indentation handling mirrors {!Python_face}: a block-opening keyword + (module/where/of/do/let-in/case-of/class-where/instance-where) emits + [{], a stricter dedent emits [}], and the last meaningful line of a + block is emitted without a trailing [;] so its value becomes the + block's value (matching Haskell/PureScript layout semantics). + + Limitations (deferred to AST-level rewrites): + - Multi-clause function definitions ([fact 0 = 1; fact n = ...]) + are not auto-folded into a single [match]; write the [case] form. + - [do]-notation desugaring to handler form is partial: a [do] block + lowers to [{ ...; }] and individual [<-] arrows lower to [let bind]. + The user is expected to wire [Async] / domain effects explicitly. + - [where] post-bindings are not hoisted into the function body; use a + [let ... in ...] in front instead. + - Span fidelity: errors refer to the canonical text, not Lucid source. +*) + +(* ─── Character helpers ──────────────────────────────────────────────── *) + +let is_id_char c = + (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || c = '_' || c = '\'' + +let starts_with s prefix = + let sl = String.length s and pl = String.length prefix in + sl >= pl && String.sub s 0 pl = prefix + +let ends_with s suffix = + let sl = String.length s and tl = String.length suffix in + sl >= tl && String.sub s (sl - tl) tl = suffix + +let indent_of line = + let len = String.length line in + let i = ref 0 in + while !i < len && (line.[!i] = ' ' || line.[!i] = '\t') do incr i done; + !i + +(* ─── Comment handling ───────────────────────────────────────────────── *) + +(** Convert a leading [-- comment] to [// comment] at the original indent. *) +let convert_dashdash_comment line = + let trimmed = String.trim line in + if starts_with trimmed "--" then + let indent_len = String.length line - String.length trimmed in + let indent = String.sub line 0 indent_len in + indent ^ "//" ^ String.sub trimmed 2 (String.length trimmed - 2) + else line + +(** Strip a trailing [-- ...] comment (respecting string literals) and + return [(code_part, comment_text option)]. *) +let strip_dashdash_comment line = + let len = String.length line in + let in_str = ref false in + let str_delim = ref '"' in + let cut = ref (-1) in + let i = ref 0 in + while !i < len && !cut < 0 do + let c = line.[!i] in + if !in_str then begin + if c = !str_delim && (!i = 0 || line.[!i - 1] <> '\\') then + in_str := false + end else begin + if c = '"' then begin in_str := true; str_delim := c end + else if !i + 1 < len && c = '-' && line.[!i + 1] = '-' then + cut := !i + end; + incr i + done; + if !cut < 0 then (line, None) + else (String.sub line 0 !cut, + Some (String.sub line (!cut + 2) (len - !cut - 2))) + +(* ─── Word-level keyword substitution ────────────────────────────────── *) + +let replace_word ~from_w ~to_w s = + let flen = String.length from_w in + let slen = String.length s in + let buf = Buffer.create slen in + let i = ref 0 in + while !i < slen do + if slen - !i >= flen && String.sub s !i flen = from_w then begin + let before_ok = !i = 0 || not (is_id_char s.[!i - 1]) in + let after_ok = !i + flen >= slen || not (is_id_char s.[!i + flen]) in + if before_ok && after_ok then begin + Buffer.add_string buf to_w; + i := !i + flen + end else begin + Buffer.add_char buf s.[!i]; + incr i + end + end else begin + Buffer.add_char buf s.[!i]; + incr i + end + done; + Buffer.contents buf + +(** Boolean / unit literal substitutions. Applied to code-only text. *) +let apply_literal_subs s = + let s = replace_word ~from_w:"True" ~to_w:"true" s in + let s = replace_word ~from_w:"False" ~to_w:"false" s in + let s = replace_word ~from_w:"Unit" ~to_w:"()" s in + s + +(** Logical operator substitutions. PureScript uses [&&], [||] natively; + [not] is a function but lower it to the prefix [!] for parser symmetry + when written as a unary keyword. *) +let apply_logic_subs s = + let s = replace_word ~from_w:"not" ~to_w:"!" s in + s + +(* ─── Module path helpers ────────────────────────────────────────────── *) + +(** [Data.Map.Strict] → [Data::Map::Strict]. *) +let dots_to_colons s = + let len = String.length s in + let buf = Buffer.create (len + 4) in + String.iter (fun c -> + if c = '.' then Buffer.add_string buf "::" + else Buffer.add_char buf c + ) s; + Buffer.contents buf + +(* ─── Import translation ─────────────────────────────────────────────── *) + +(** Try to transform a PureScript [import …] line. *) +let transform_import_line stripped = + if not (starts_with stripped "import ") then None + else begin + let rest = String.trim (String.sub stripped 7 (String.length stripped - 7)) in + (* Split at " as " to detect alias. *) + let alias, rest = + let idx = + let len = String.length rest in + let found = ref (-1) in + let i = ref 0 in + while !i + 3 < len && !found < 0 do + if rest.[!i] = ' ' + && !i + 3 < len + && rest.[!i + 1] = 'a' && rest.[!i + 2] = 's' && rest.[!i + 3] = ' ' + then found := !i; + incr i + done; + !found + in + if idx >= 0 then + (Some (String.trim (String.sub rest (idx + 4) (String.length rest - idx - 4))), + String.trim (String.sub rest 0 idx)) + else (None, rest) + in + (* Split at " hiding (...)" — drop the hiding clause; we cannot express + hiding in the canonical use form. *) + let rest = + try + let i = Str.search_forward (Str.regexp_string " hiding ") rest 0 in + String.trim (String.sub rest 0 i) + with Not_found -> rest + in + (* Detect explicit name list: [Mod (a, b, c)] *) + if String.contains rest '(' then begin + let lparen = String.index rest '(' in + let mod_part = String.trim (String.sub rest 0 lparen) in + let after = String.sub rest (lparen + 1) (String.length rest - lparen - 1) in + let names_raw = + try String.sub after 0 (String.index after ')') + with Not_found -> after + in + let names = + String.split_on_char ',' names_raw + |> List.map String.trim + |> List.filter (fun s -> s <> "") + |> String.concat ", " + in + let alias_clause = match alias with + | Some a -> " as " ^ a + | None -> "" + in + Some (Printf.sprintf "use %s::{%s}%s;" + (dots_to_colons mod_part) names alias_clause) + end else begin + let alias_clause = match alias with + | Some a -> " as " ^ a + | None -> "" + in + Some (Printf.sprintf "use %s%s;" (dots_to_colons rest) alias_clause) + end + end + +(* ─── Module declaration ─────────────────────────────────────────────── *) + +(** [module Foo where] / [module Foo (exports) where] → [module Foo;]. + Canonical AffineScript uses [module] as a file-level header (no + braces); the body is everything that follows in the same file. *) +let transform_module_line stripped = + if not (starts_with stripped "module ") then None + else begin + let rest = String.sub stripped 7 (String.length stripped - 7) in + (* Drop optional (export, list) and the trailing "where". *) + let rest = + if String.contains rest '(' then begin + let lparen = String.index rest '(' in + try + let rparen = String.index_from rest lparen ')' in + String.sub rest 0 lparen + ^ String.sub rest (rparen + 1) (String.length rest - rparen - 1) + with Not_found -> String.sub rest 0 lparen + end else rest + in + let rest = String.trim rest in + let rest = + if ends_with rest " where" then + String.trim (String.sub rest 0 (String.length rest - 6)) + else if rest = "where" then "" + else rest + in + let mod_path = dots_to_colons rest in + Some (Printf.sprintf "module %s;" mod_path) + end + +(* ─── Type signature handling ────────────────────────────────────────── *) + +(** A line of the form [name :: type ...] is a type signature. Lucid keeps + it as a comment so the canonical type inferer is the source of truth. + Class-method signatures ([class Eq a where] body) get the same treatment. *) +let is_type_signature stripped = + (* Must contain " :: " and start with a lowercase identifier. *) + let len = String.length stripped in + let has_dcolon = + try ignore (Str.search_forward (Str.regexp_string " :: ") stripped 0); true + with Not_found -> false + in + has_dcolon && len > 0 + && (stripped.[0] >= 'a' && stripped.[0] <= 'z' || stripped.[0] = '_') + +(* ─── Data / class / instance declarations ───────────────────────────── *) + +(** [data Foo a b = Ctor1 a | Ctor2] → [type Foo[a, b] = Ctor1(a) | Ctor2]. + Best-effort: parameterised constructor arguments wrapped in parens. *) +let transform_data_decl stripped = + if not (starts_with stripped "data ") then None + else begin + let body = String.sub stripped 5 (String.length stripped - 5) in + let eq_idx = + try Some (String.index body '=') with Not_found -> None + in + match eq_idx with + | None -> Some ("type " ^ body) (* abstract data type *) + | Some eq -> + let lhs = String.trim (String.sub body 0 eq) in + let rhs = String.trim (String.sub body (eq + 1) (String.length body - eq - 1)) in + (* Convert "Foo a b" → "Foo[a, b]" *) + let lhs = + match String.split_on_char ' ' lhs with + | [single] -> single + | name :: ps -> Printf.sprintf "%s[%s]" name (String.concat ", " ps) + | [] -> lhs + in + (* Convert each constructor "Ctor a b" → "Ctor(a, b)". *) + let rhs = + String.split_on_char '|' rhs + |> List.map String.trim + |> List.map (fun ctor -> + match String.split_on_char ' ' ctor with + | [single] -> single + | name :: args -> Printf.sprintf "%s(%s)" name (String.concat ", " args) + | [] -> ctor) + |> String.concat " | " + in + Some (Printf.sprintf "type %s = %s" lhs rhs) + end + +(** [class Eq a where] → [trait Eq[a] {]. *) +let transform_class_decl stripped = + if not (starts_with stripped "class ") then None + else begin + let body = String.sub stripped 6 (String.length stripped - 6) in + let body = + if ends_with body " where" then + String.trim (String.sub body 0 (String.length body - 6)) + else body + in + (* "Eq a" → "Eq[a]"; "Functor f" → "Functor[f]". *) + let body = + match String.split_on_char ' ' body with + | [single] -> single + | name :: ps -> Printf.sprintf "%s[%s]" name (String.concat ", " ps) + | [] -> body + in + Some (Printf.sprintf "trait %s {" body) + end + +(** [instance Eq Foo where] / [instance eqFoo :: Eq Foo where] + → [impl Eq for Foo {]. *) +let transform_instance_decl stripped = + if not (starts_with stripped "instance ") then None + else begin + let body = String.sub stripped 9 (String.length stripped - 9) in + let body = + if ends_with body " where" then + String.trim (String.sub body 0 (String.length body - 6)) + else body + in + (* PureScript allows an optional "name :: " prefix. *) + let body = + try + let i = Str.search_forward (Str.regexp_string " :: ") body 0 in + String.trim (String.sub body (i + 4) (String.length body - i - 4)) + with Not_found -> body + in + (* Now "Eq Foo" — split into trait and target. *) + match String.split_on_char ' ' body with + | trait_name :: target_parts when target_parts <> [] -> + Some (Printf.sprintf "impl %s for %s {" + trait_name (String.concat " " target_parts)) + | _ -> Some (Printf.sprintf "impl %s {" body) + end + +(* ─── Function equations ─────────────────────────────────────────────── *) + +(** [f x y = expr] — wrap parameters and emit canonical [fn]. + Returns [None] when the line isn't a recognisable equation. *) +let transform_equation stripped = + match String.index_opt stripped '=' with + | None -> None + | Some eq when eq = 0 -> None + | Some eq -> + (* Skip operator-equality forms like [==], [<=], [>=], [/=], [:=]. *) + let len = String.length stripped in + let next_ok = eq + 1 >= len || stripped.[eq + 1] <> '=' in + let prev_ok = eq = 0 + || (let c = stripped.[eq - 1] in + c <> '<' && c <> '>' && c <> '/' && c <> '=' && c <> ':' && c <> '!') in + if not (next_ok && prev_ok) then None + else begin + let lhs = String.trim (String.sub stripped 0 eq) in + let rhs = String.trim (String.sub stripped (eq + 1) (len - eq - 1)) in + (* lhs must start with a lowercase ident. *) + if String.length lhs = 0 then None + else if not (lhs.[0] >= 'a' && lhs.[0] <= 'z' || lhs.[0] = '_') then None + else begin + match String.split_on_char ' ' lhs with + | [name] -> + (* Bare value: [x = expr] → [let x = expr] *) + Some (Printf.sprintf "let %s = %s" name rhs) + | name :: params -> + (* Drop a leading [()] unit-param idiom (PureScript [main () = …]) + so the canonical signature is just [fn main()] with no params. *) + let params = + match params with + | "()" :: rest -> rest + | _ -> params + in + let params_str = String.concat ", " params in + if rhs = "" then + Some (Printf.sprintf "fn %s(%s) {" name params_str) + else + (* Canonical AffineScript requires a braced body, not [= expr]. *) + Some (Printf.sprintf "fn %s(%s) { %s }" name params_str rhs) + | [] -> None + end + end + +(* ─── Expression-level substitutions ─────────────────────────────────── *) + +(** [\x -> body] / [\x y -> body] → [(x, y) => body]. *) +let transform_lambda_inline s = + let len = String.length s in + let buf = Buffer.create len in + let in_str = ref false in + let str_delim = ref '"' in + let i = ref 0 in + while !i < len do + let c = s.[!i] in + if !in_str then begin + Buffer.add_char buf c; + if c = !str_delim && (!i = 0 || s.[!i - 1] <> '\\') then in_str := false; + incr i + end else if c = '"' then begin + in_str := true; str_delim := c; + Buffer.add_char buf c; incr i + end else if c = '\\' && !i + 1 < len && s.[!i + 1] <> '\\' then begin + (* Found a lambda. Read ident-list up to "->". *) + let j = ref (!i + 1) in + let arrow_start = ref (-1) in + while !j + 1 < len && !arrow_start < 0 do + if s.[!j] = '-' && s.[!j + 1] = '>' then arrow_start := !j + else incr j + done; + if !arrow_start < 0 then begin + Buffer.add_char buf c; incr i + end else begin + let params = + String.sub s (!i + 1) (!arrow_start - !i - 1) + |> String.trim + |> String.split_on_char ' ' + |> List.filter (fun p -> p <> "") + |> String.concat ", " + in + Buffer.add_string buf (Printf.sprintf "(%s) =>" params); + i := !arrow_start + 2 + end + end else begin + Buffer.add_char buf c; + incr i + end + done; + Buffer.contents buf + +(** [if c then a else b] with [then]/[else] inline → braced form. *) +let transform_if_inline s = + if not (starts_with (String.trim s) "if ") then s + else begin + let then_re = Str.regexp_string " then " in + let else_re = Str.regexp_string " else " in + try + let then_pos = Str.search_forward then_re s 0 in + try + let else_pos = Str.search_forward else_re s (then_pos + 5) in + let cond = String.trim (String.sub s 3 (then_pos - 3)) in + let then_branch = + String.trim (String.sub s (then_pos + 6) (else_pos - then_pos - 6)) + in + let else_branch = + String.trim (String.sub s (else_pos + 6) (String.length s - else_pos - 6)) + in + Printf.sprintf "if %s { %s } else { %s }" cond then_branch else_branch + with Not_found -> + let cond = String.trim (String.sub s 3 (then_pos - 3)) in + let body = + String.trim (String.sub s (then_pos + 6) (String.length s - then_pos - 6)) + in + Printf.sprintf "if %s { %s }" cond body + with Not_found -> s + end + +(* ─── Block-opener detection (Haskell-style "where" / "of" / "do") ───── *) + +let block_openers = [ + " where"; " of"; " do"; +] + +let is_block_opener_lucid stripped = + List.exists (fun suffix -> ends_with stripped suffix) block_openers + || ends_with stripped " =" (* multi-line equations *) + || ends_with stripped " ->" (* case arms with indented body *) + +(** Lines that are already canonical AffineScript and must skip the + equation / lambda / literal pipeline. These typically introduce or + close a brace-delimited block of their own. *) +let is_canonical_passthrough stripped = + let prefixes = ["effect "; "trait "; "impl "; "type "; "fn "; + "use "; "pub "; "struct "; "enum "] in + List.exists (fun p -> starts_with stripped p) prefixes + || stripped = "}" || stripped = "};" + (* Treat any line that ends with [{] and isn't an equation as a + declaration header. *) + || (let n = String.length stripped in + n > 0 && stripped.[n - 1] = '{' && not (String.contains stripped '=')) + +let strip_block_marker stripped = + let try_suffix suf = + if ends_with stripped suf then + Some (String.trim (String.sub stripped 0 (String.length stripped - String.length suf))) + else None + in + match try_suffix " where" with + | Some r -> (r, "where") + | None -> + match try_suffix " of" with + | Some r -> (r, "of") + | None -> + match try_suffix " do" with + | Some r -> (r, "do") + | None -> + match try_suffix " =" with + | Some r -> (r, "=") + | None -> + match try_suffix " ->" with + | Some r -> (r, "->") + | None -> (stripped, "") + +(** Decide how to render the head of a block-opening line. *) +let render_block_head head marker = + match marker with + | "of" -> + if starts_with head "case " then + "match " ^ String.sub head 5 (String.length head - 5) ^ " {" + else head ^ " {" + | "where" -> + (* module/class/instance/data already handled upstream; + [where] anywhere else is post-binding scope — render as block. *) + head ^ " {" + | "do" -> + head ^ " {" + | "->" -> + (* Case arm body; emit "head =>" with "{" so the indented body + becomes the arm body. *) + head ^ " => {" + | "=" -> + head ^ " = {" + | _ -> head + +(* ─── Main transformer ───────────────────────────────────────────────── *) + +let is_blank_line raw = + let (code, _) = strip_dashdash_comment (String.trim raw) in + String.trim code = "" + +let transform_source source = + let lines = Array.of_list (String.split_on_char '\n' source) in + let n = Array.length lines in + let out = Buffer.create (String.length source + 256) in + let stack = ref [0] in + (* Tracks how many top-level [module/class/instance] / [data … =] heads + have opened a brace at the same indent as the keyword (PureScript + layout allows the body to sit at the same indent as the head). The + normal indent-based dedent never closes these; we emit one [}] per + opened head at EOF. *) + let toplevel_braces = ref 0 in + + let top () = match !stack with h :: _ -> h | [] -> 0 in + + let emit_dedents target = + while top () > target do + Buffer.add_string out "}\n"; + stack := List.tl !stack + done + in + + let next_meaningful_indent i = + let j = ref (i + 1) in + while !j < n && is_blank_line lines.(!j) do incr j done; + if !j >= n then -1 else indent_of lines.(!j) + in + + for i = 0 to n - 1 do + let raw_line = lines.(i) in + let ind = indent_of raw_line in + let line = convert_dashdash_comment raw_line in + let (code_part, comment_opt) = strip_dashdash_comment (String.trim line) in + let stripped = String.trim code_part in + + let with_comment line_text = + match comment_opt with + | None -> line_text ^ "\n" + | Some c -> line_text ^ " // " ^ String.trim c ^ "\n" + in + + if stripped = "" then begin + (match comment_opt with + | Some c -> Buffer.add_string out ("// " ^ String.trim c ^ "\n") + | None -> Buffer.add_char out '\n') + end else if is_type_signature stripped then begin + (* Keep type signatures as a comment so the inferer drives types. *) + let indent_str = String.make ind ' ' in + Buffer.add_string out (indent_str ^ "// " ^ stripped ^ "\n") + end else if is_canonical_passthrough stripped then begin + (* Canonical declaration line embedded inside a Lucid file + (e.g. an [effect …] block, a [fn …] header, a closing [}]). + Pass through without the equation / lambda / literal pipeline, + which would otherwise mangle [-> T] return types and append + a stray [;] after a [{]. *) + let indent_str = String.make ind ' ' in + Buffer.add_string out (indent_str ^ with_comment stripped) + end else begin + emit_dedents ind; + if ind > top () then stack := ind :: !stack; + + let indent_str = String.make ind ' ' in + let next_ind = next_meaningful_indent i in + let is_tail = next_ind < ind in + + (* Specific structural transforms first. [class] / [instance] + declarations open a brace at top-level indent; record them so + we can close at EOF (the body sits at the same indent as the + head in PureScript layout, so the indent-stack dedent never + fires). [module] in canonical AffineScript is a file header + with no body braces, so we don't track it. *) + let track_toplevel s = + if ind = 0 && String.length s > 0 + && s.[String.length s - 1] = '{' + then incr toplevel_braces + in + let line_text = + match transform_module_line stripped with + | Some s -> s + | None -> + match transform_import_line stripped with + | Some s -> s + | None -> + match transform_data_decl stripped with + | Some s -> s + | None -> + match transform_class_decl stripped with + | Some s -> track_toplevel s; s + | None -> + match transform_instance_decl stripped with + | Some s -> track_toplevel s; s + | None -> + if is_block_opener_lucid stripped then begin + let (head, marker) = strip_block_marker stripped in + render_block_head (apply_logic_subs (apply_literal_subs head)) marker + end else begin + (* General expression / equation handling. *) + let prepared = stripped + |> apply_literal_subs + |> apply_logic_subs + |> transform_lambda_inline + |> transform_if_inline + in + match transform_equation prepared with + | Some eq when is_tail -> eq (* tail: no `;` *) + | Some eq -> eq ^ ";" + | None when is_tail -> prepared + | None -> prepared ^ ";" + end + in + Buffer.add_string out (indent_str ^ with_comment line_text) + end + done; + emit_dedents 0; + (* Close any module / class / instance heads that opened a brace at + top-level indent. *) + for _ = 1 to !toplevel_braces do + Buffer.add_string out "}\n" + done; + Buffer.contents out + +(* ─── Entry points ───────────────────────────────────────────────────── *) + +let parse_string_lucid ~file content = + let canonical = transform_source content in + Parse_driver.parse_string ~file canonical + +let parse_file_lucid path = + let chan = open_in_bin path in + Fun.protect + ~finally:(fun () -> close_in chan) + (fun () -> + let content = really_input_string chan (in_channel_length chan) in + parse_string_lucid ~file:path content) + +let preview_transform source = transform_source source diff --git a/tests/faces/README.md b/tests/faces/README.md new file mode 100644 index 0000000..4d3830d --- /dev/null +++ b/tests/faces/README.md @@ -0,0 +1,70 @@ +# Face transformer regression tests + +This directory holds **canonical-text snapshots** for each non-canonical face's +output, plus a tiny harness for confirming they keep parsing. + +## What's tested + +For every example under `examples/faces/`: + +1. **Snapshot diff** — the script runs the corresponding `preview-*` subcommand + and diffs its stdout against a committed `*.expected.txt` here. If the + transformer's output for the same source ever changes, the diff fails. +2. **Round-trip parse** — the example file is parsed via the normal pipeline + (which auto-detects face from the pragma). If a transformer change ever + produces canonical text the parser rejects, this catches it. +3. **Canonical baseline** — `examples/faces/hello-canonical.affine` is parsed + directly to confirm the reference shape stays valid. + +## Files + +| File | Source | Captured by | +|---|---|---| +| `hello-rattle.expected.txt` | `examples/faces/hello-rattle.affine` | `affinescript preview-python` | +| `hello-jaffa.expected.txt` | `examples/faces/hello-jaffa.affine` | `affinescript preview-js` | +| `hello-pseudo.expected.txt` | `examples/faces/hello-pseudo.affine` | `affinescript preview-pseudocode` | +| `hello-lucid.expected.txt` | `examples/faces/hello-lucid.affine` | `affinescript preview-lucid` | +| `hello-cafe.expected.txt` | `examples/faces/hello-cafe.affine` | `affinescript preview-cafe` | + +## Workflow + +### First-time setup (snapshots not yet captured) + +```bash +just build +just test-faces-record # captures any missing snapshot, then diffs +git diff tests/faces/ # review what was captured +git add tests/faces/*.expected.txt +git commit +``` + +### Routine CI / local check + +```bash +just test-faces # diffs against committed snapshots; fails on drift +``` + +### Intentional transformer change + +Edit a face transformer (e.g. `lib/python_face.ml`), then: + +```bash +just test-faces-update # overwrites the affected snapshot +git diff tests/faces/ # review the lowering change +git add tests/faces/*.expected.txt +git commit +``` + +The diff in the PR shows reviewers exactly how the canonical lowering changed, +which is more useful than just "transformer modified". + +## Why snapshot-test the transformers + +The transformers are pure text-to-text. Bugs typically show up as drifted +output rather than crashes — a missing comma, a wrong keyword swap, a broken +indent rule. Snapshot diffs catch those instantly. Combined with the +round-trip parse, this gives a regression net that: + +- runs in seconds (no codegen, no wasm), +- has zero false positives (output is deterministic), +- doubles as a side-by-side reference for "different faces, same cube". diff --git a/tests/faces/hello-cafe.expected.txt b/tests/faces/hello-cafe.expected.txt new file mode 100644 index 0000000..a44670a --- /dev/null +++ b/tests/faces/hello-cafe.expected.txt @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// CafeScripto face. Distinctive features exercised: `#` line comments, +// `###` block comments, paren-arrow `(args) -> body`, bare-arrow `-> body`, +// Yes/No/On/Off literal aliases. +// face: cafescripto + +/* + All other CoffeeScript surface conveniences — postfix-if, @ shorthand, + `unless`/`until`, list comprehensions, no-paren calls — are documented + in examples/faces/README.adoc with their current support status. +*/ + +effect IO { + fn println(s: String) -> (); +} + +fn main() -{IO}-> () { + let greeting = "Hello, CafeScripto!"; + let ready = true; + println(greeting); +} + diff --git a/tests/faces/hello-jaffa.expected.txt b/tests/faces/hello-jaffa.expected.txt new file mode 100644 index 0000000..709f531 --- /dev/null +++ b/tests/faces/hello-jaffa.expected.txt @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// JaffaScript face. Distinctive features exercised: `function` keyword, +// `const`/`let` bindings (let lowers to let mut), brace-delimited +// blocks, `==` equality. +// face: jaffascript + +effect IO { +fn println(s: String) -> (); +} + +fn main() -{IO}-> () { +let greeting = "Hello, JaffaScript!"; +println(greeting); +} diff --git a/tests/faces/hello-lucid.expected.txt b/tests/faces/hello-lucid.expected.txt new file mode 100644 index 0000000..7f583d1 --- /dev/null +++ b/tests/faces/hello-lucid.expected.txt @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later; +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell; +//; +// LucidScript face. Distinctive features exercised: module declaration; +// with `where`, type signatures kept as comments, equation-style; +// function definitions, `; // ` line comments. (The unit-parameter idiom +// `main () = …` lowers to canonical `fn main() { … }`. Function; +// application uses canonical paren syntax `f(x)` rather than Haskell; +// currying `f x` — see examples/faces/README.adoc.); +// face: lucidscript; + +module Hello; + +effect IO { + fn println(s: String) -> (); +} + +// main :: -{IO}-> () +fn main() { println("Hello, LucidScript!") } + diff --git a/tests/faces/hello-pseudo.expected.txt b/tests/faces/hello-pseudo.expected.txt new file mode 100644 index 0000000..f1dd396 --- /dev/null +++ b/tests/faces/hello-pseudo.expected.txt @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: AGPL-3.0-||-later +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// PseudoScript face. Distinctive features exercised: +// function ... do ... end blocks, `--` Haskell/SQL-style comments. +// (Note: pseudocode-face does not yet auto-insert statement separators, +// so this minimal demo keeps each function body to a single expression. +// See examples/faces/README.adoc for a list of pending transformer gaps.) +// face: pseudoscript + +effect IO { + fn println(s: String) -> (); +} + +fn main() -{IO}-> () { + println("Hello, PseudoScript!") +} diff --git a/tests/faces/hello-rattle.expected.txt b/tests/faces/hello-rattle.expected.txt new file mode 100644 index 0000000..ba3feb7 --- /dev/null +++ b/tests/faces/hello-rattle.expected.txt @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// RattleScript face. Distinctive features exercised: `def` keyword, +// indentation as block delimiter, `#` line comments, `True/False/None` +// literals, `and/or/not` boolean keywords, `:` block opener. +// face: rattlescript + +effect IO { + fn println(s: String) -> (); + +} +fn main() -{IO}-> () { + let greeting = "Hello, RattleScript!"; + println(greeting) + +} diff --git a/tools/run_face_transformer_tests.sh b/tools/run_face_transformer_tests.sh new file mode 100755 index 0000000..3237357 --- /dev/null +++ b/tools/run_face_transformer_tests.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: PMPL-1.0-or-later +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell + +# Face-transformer regression tests. +# +# For each of the five non-canonical face examples in examples/faces/, run +# the corresponding `preview-*` command to capture the canonical lowering, +# then diff it against a committed snapshot in tests/faces/. Also run +# `parse` on every example (canonical included) to confirm the resulting +# canonical text is syntactically valid. +# +# Modes: +# default compare against committed snapshot; fail on diff. +# --update overwrite snapshots with current output (intentional change). +# --record-missing record any missing snapshot; never fail on missing. +# Useful for the first run after introducing a face. +# +# Exit 0 on success, 1 on any diff/parse failure (default mode). + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +EXAMPLES_DIR="$ROOT_DIR/examples/faces" +SNAPSHOT_DIR="$ROOT_DIR/tests/faces" + +mode="check" +for arg in "$@"; do + case "$arg" in + --update) mode="update" ;; + --record-missing) mode="record-missing" ;; + -h|--help) + sed -n '3,/^$/p' "$0" + exit 0 + ;; + *) + echo "unknown flag: $arg" >&2 + exit 2 + ;; + esac +done + +# Resolve the compiler entry point in the same priority order as +# tools/run_codegen_wasm_tests.sh so this works locally and in CI. +if [ -x "$ROOT_DIR/_build/default/bin/main.exe" ]; then + AS=("$ROOT_DIR/_build/default/bin/main.exe") +elif command -v affinescript >/dev/null 2>&1; then + AS=("affinescript") +else + AS=(dune exec affinescript --) +fi + +mkdir -p "$SNAPSHOT_DIR" + +# face,example_basename,preview_subcommand +SPECS=( + "rattle:hello-rattle:preview-python" + "jaffa:hello-jaffa:preview-js" + "pseudo:hello-pseudo:preview-pseudocode" + "lucid:hello-lucid:preview-lucid" + "cafe:hello-cafe:preview-cafe" +) + +failures=0 +recorded=0 +checked=0 + +run_preview() { + local subcmd="$1" path="$2" + "${AS[@]}" "$subcmd" "$path" +} + +run_parse() { + local path="$1" + # Suppress AST output; we only care about exit status. + "${AS[@]}" parse "$path" >/dev/null +} + +# Canonical baseline: just confirm it parses. +echo "[parse] hello-canonical.affine" +if ! run_parse "$EXAMPLES_DIR/hello-canonical.affine"; then + echo " FAIL: canonical example does not parse" >&2 + failures=$((failures + 1)) +else + checked=$((checked + 1)) +fi + +for spec in "${SPECS[@]}"; do + IFS=":" read -r face base subcmd <<< "$spec" + src="$EXAMPLES_DIR/${base}.affine" + expected="$SNAPSHOT_DIR/${base}.expected.txt" + + echo + echo "[${face}] $base.affine (subcommand: $subcmd)" + + if [ ! -f "$src" ]; then + echo " FAIL: example missing: $src" >&2 + failures=$((failures + 1)) + continue + fi + + # 1. Capture the canonical lowering via preview-*. + actual="$(mktemp)" + if ! run_preview "$subcmd" "$src" > "$actual" 2>/dev/null; then + echo " FAIL: preview command exited non-zero" >&2 + rm -f "$actual" + failures=$((failures + 1)) + continue + fi + + # 2. Snapshot logic. + case "$mode" in + update) + cp "$actual" "$expected" + echo " recorded snapshot: $expected" + recorded=$((recorded + 1)) + ;; + record-missing) + if [ ! -f "$expected" ]; then + cp "$actual" "$expected" + echo " recorded missing snapshot: $expected" + recorded=$((recorded + 1)) + else + if diff -u "$expected" "$actual" >/dev/null; then + echo " snapshot OK" + checked=$((checked + 1)) + else + echo " FAIL: snapshot diff (use --update to accept changes)" >&2 + diff -u "$expected" "$actual" || true + failures=$((failures + 1)) + fi + fi + ;; + check) + if [ ! -f "$expected" ]; then + echo " FAIL: missing snapshot $expected" >&2 + echo " run with --record-missing or --update to create it" >&2 + failures=$((failures + 1)) + elif diff -u "$expected" "$actual" >/dev/null; then + echo " snapshot OK" + checked=$((checked + 1)) + else + echo " FAIL: snapshot diff (use --update to accept changes)" >&2 + diff -u "$expected" "$actual" || true + failures=$((failures + 1)) + fi + ;; + esac + + # 3. Parse the example (auto-detects face via pragma) — confirms the + # transformer output is syntactically valid canonical AffineScript. + if ! run_parse "$src"; then + echo " FAIL: lowered text did not parse as canonical AffineScript" >&2 + failures=$((failures + 1)) + fi + + rm -f "$actual" +done + +echo +echo "─────────────────────────────────────────────" +case "$mode" in + update) + echo "Recorded $recorded snapshot(s); review and commit." + ;; + record-missing) + if [ "$recorded" -gt 0 ]; then + echo "Recorded $recorded missing snapshot(s); checked $checked." + echo "Review the new snapshots and commit them." + else + echo "Checked $checked snapshot(s); no missing snapshots." + fi + ;; + check) + echo "Checked $checked snapshot(s)." + ;; +esac + +if [ "$failures" -gt 0 ]; then + echo "FAILED: $failures issue(s)" >&2 + exit 1 +fi + +echo "All face transformer tests passed." From 9f6617f6d474ab6ca4beb18a81aca1da5e448670 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 2 May 2026 08:26:26 +0100 Subject: [PATCH 3/3] =?UTF-8?q?docs(lessons):=20PlayerHP=20=E2=80=94=20pro?= =?UTF-8?q?mote=20to=20compiled=20(952B=20WASM,=20v0.1.0=20verified)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status: design-translated → compiled. The PlayerHP.affine translation typechecks and compiles cleanly via the containerized v0.1.0 toolchain (`./scripts/affinescript-host-shim.sh`); resulting WASM module is 952 bytes, validates as MVP, lives at idaptik/src/app/combat/PlayerHP.wasm. Reproducibility section now contains actual command output (not placeholder) matching idaptik-hitbox's format. Surfaced a second v0.1.0 expedient alongside Float→Int: `mut Player` parameter mode is real (per examples/ownership.affine) but the `p.field = ...` in-body assignment idiom for struct fields through a mut borrow is not demonstrated by any v0.1.0 example. The shipped PlayerHP.affine substitutes immutable returning-new — same shape of expedient as Float→Int, preserves the design semantics, gets reverted when the upstream surface closes the gap. New "What changed" subsection 6 documents this fully. Updates the playbook's lesson-index entry to drop the "design-translated, toolchain run pending" qualifier and add the WASM-size and second-expedient notes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lessons/migrations/idaptik-player-hp.adoc | 79 ++++++++++++++----- docs/guides/migration-playbook.adoc | 2 +- 2 files changed, 59 insertions(+), 22 deletions(-) diff --git a/docs/guides/lessons/migrations/idaptik-player-hp.adoc b/docs/guides/lessons/migrations/idaptik-player-hp.adoc index ea45a72..7dbae21 100644 --- a/docs/guides/lessons/migrations/idaptik-player-hp.adoc +++ b/docs/guides/lessons/migrations/idaptik-player-hp.adoc @@ -7,12 +7,13 @@ [NOTE] ==== -**Status: design-translated, toolchain run pending.** The translation is -written to be compilable against AffineScript v0.1.0 (Int arithmetic, no -external module imports), following the same Float→Int convention -xref:idaptik-hitbox.adoc[idaptik-hitbox] established. A Reproducibility -block matching hitbox's will be filled in by the next translator who runs -this through `affinescript-host-shim.sh`. +**Status: compiled. 952-byte WASM MVP module produced 2026-05-02 via the +containerized v0.1.0 toolchain.** Two v0.1.0 expedients applied: Float → +Int with documented scaling (per xref:idaptik-hitbox.adoc[idaptik-hitbox]); +and `mut Player` with field assignment substituted for an immutable +*returning-new* style, because the `mut`-borrow-with-struct-field-assignment +idiom is not yet verified in the v0.1.0 surface (`examples/ownership.affine` +shows `mut` as a parameter mode but no in-body assignment example). Picked because it stress-tests the migration playbook's central decision — *`mut` vs `State` effect* — on a record with three orthogonal aspects @@ -247,6 +248,41 @@ of a public field** — the source-comment header should declare the scaling factors. When AffineScript gains Float-arithmetic operators, the file can be reverted to floats with a one-pass rewrite. +=== 6. `mut Player` design intent → immutable returning-new (v0.1.0 expedient) + +The design above shows `take_damage(p: mut Player, ...)` and `update(p: mut +Player, dt_ms: Int) -> Velocity`. The shipped `PlayerHP.affine` does +**not** use that signature. It uses an immutable returning-new style: + +[source,affinescript] +---- +fn take_damage(p: Player, amount: Int, source: Pos, player: Pos) -> Player +fn update(p: Player, dt_ms: Int) -> UpdateResult // { player, velocity } +---- + +Why: `mut` is a real parameter mode in v0.1.0 +(`examples/ownership.affine` shows `fn takes_mut(x: mut Bool) -> () = ();`) +but the in-body idiom for *assigning to a struct field through a `mut` +borrow* — the natural way to write `p.hp.current = ...` in the lesson's +design — is not demonstrated by any v0.1.0 example I could find, and +attempting it without verification risked the kind of speculative-syntax +bug a lesson should not propagate. + +The immutable returning-new form preserves the *semantics* of the +re-decomposition (every mutator decision is still made; aspects are +still fissioned; the Player record is still composed) at the cost of +each mutator allocating a fresh struct. This is acceptable for a small +record and a 60fps game tick; it is not the long-term design. + +**This is the same shape of expedient as Float → Int**: a v0.1.0 +compiler limitation forces a syntactic compromise that does not change +the design. Document it at the file header; rewrite when the upstream +gap closes. + +The `mut` vs `State` *decision* the lesson is about — local mutation in +one tick under one owner — is unaffected. It just gets expressed +immutably for now. + == Playbook verdict === Held up cleanly @@ -303,25 +339,26 @@ When translating a `.res` file with **multi-field mutable state**: == Reproducibility -[NOTE] -==== -This lesson is design-translated; the toolchain run is pending. The -steps below match xref:idaptik-hitbox.adoc#reproducibility[the hitbox -lesson's Reproducibility section] and should produce the same shape of -output. -==== +The translation, the typecheck, the compile, and the WASM emission can +be reproduced from the IDApTIK repository at HEAD as of 2026-05-02: [source,bash] ---- -$ just affinescript-stage $ ./scripts/affinescript-host-shim.sh check src/app/combat/PlayerHP.affine -# Expected: "Type checking passed" +Type checking passed + $ ./scripts/affinescript-host-shim.sh compile src/app/combat/PlayerHP.affine -o src/app/combat/PlayerHP.wasm -# Expected: "Compiled src/app/combat/PlayerHP.affine -> src/app/combat/PlayerHP.wasm (WASM)" +Compiled src/app/combat/PlayerHP.affine -> src/app/combat/PlayerHP.wasm (WASM) + +$ ls -la src/app/combat/PlayerHP.wasm +-rw-r--r-- 1 hyperpolymath hyperpolymath 952 May 2 08:12 src/app/combat/PlayerHP.wasm + +$ file src/app/combat/PlayerHP.wasm +src/app/combat/PlayerHP.wasm: WebAssembly (wasm) binary module version 0x1 (MVP) ---- -When the toolchain run completes, replace this NOTE with the actual -output (matching hitbox's format) and link the resulting `.wasm` size in -the file header. If the run fails on a v0.1.0 limitation not anticipated -here, **edit the lesson** — that is exactly the case study a future -translator most needs. +The IDApTIK side of this lesson lives at `idaptik/src/app/combat/PlayerHP.affine` +(translation, 144 lines) and `idaptik/src/app/combat/PlayerHP.res` (original, +88 lines). The compiled `.wasm` is 952 bytes — roughly 2.5× hitbox's 384 +bytes, accounting for the larger function surface (`make`, `is_invincible`, +`is_alive`, `take_damage`, `update`, plus the four tuning fns and `max`). diff --git a/docs/guides/migration-playbook.adoc b/docs/guides/migration-playbook.adoc index 9006ca7..3654baf 100644 --- a/docs/guides/migration-playbook.adoc +++ b/docs/guides/migration-playbook.adoc @@ -274,7 +274,7 @@ Existing entries: * link:lessons/migrations/idaptik-hitbox.adoc[idaptik `Hitbox.res` → `Hitbox.affine`] — the first compilable translation; pure-leaf collision primitives. Surfaces the v0.1.0 Float-arithmetic typechecker gap. * link:lessons/migrations/idaptik-user-settings.adoc[idaptik `UserSettings.res` (design-only)] — multi-pattern, dependency-heavy persistence layer. Stress-tests the playbook against five external module dependencies; surfaces three small playbook gaps (addressed in v1.1). -* link:lessons/migrations/idaptik-player-hp.adoc[idaptik `PlayerHP.res` (design-translated, toolchain run pending)] — six-field mutable record with three orthogonal aspects (HP, i-frames, knockback). Stress-tests the `mut` vs `State` decision criterion; surfaces the **aspect-decomposition** pattern as a v1.2 candidate addition. +* link:lessons/migrations/idaptik-player-hp.adoc[idaptik `PlayerHP.res` → `PlayerHP.affine`] — six-field mutable record with three orthogonal aspects (HP, i-frames, knockback). Compiled to a 952-byte WASM module via the v0.1.0 toolchain. Stress-tests the `mut` vs `State` decision criterion; surfaces the **aspect-decomposition** pattern as a v1.2 candidate addition, and the v0.1.0 `mut`-with-struct-field-assignment gap as a second expedient (alongside Float→Int). If you complete a non-trivial `.res → .affine` translation and the re-decomposition reveals something future translators would benefit from, write it up there. The format is loose: original snippet, faithful port, re-decomposed port, and what the second one buys you. Mark the lesson "design-only" if the destination toolchain cannot yet compile it — the design walk-through still pressures the playbook.