LANG: no module-level mutable binding — `const mut` / `let mut` at module scope unrepresentable · Issue #548 · hyperpolymath/affinescript · GitHub
Skip to content

LANG: no module-level mutable binding — const mut / let mut at module scope unrepresentable #548

Description

@hyperpolymath

Is your feature request related to a problem? Please describe.

AffineScript has no surface syntax for a mutable binding at module scope. const X: T = expr; works for immutable module-level constants (stdlib/math.affine lines 31, 34, 37, 40, 43 are the precedent); let mut x = …; works inside a function body (stdlib/Http.affine:163, stdlib/AlibSchema.affine:158); but neither const mut, let mut, nor any other spelling reaches the intersection of mutable and module scope.

This blocks every consumer port that needs a module-level singleton with in-place mutation — paradigmatic case: lazy / on-demand global handles (let coprocessor: ref<option<bridge>> = ref(None) plus an ensureCoprocessor() initialiser that flips it from None to Some(...)). idaptik alone has ~14 of these patterns across src/shared/*.res, src/app/devices/*.res, src/app/utils/*.res, src/engine/utils/*.res. The Coprocessor.backends: dict<backend> registry in src/shared/Coprocessor.res is the structural canonical (one global mutable Dict, populated by register(backend) at app boot).

Adjacent gap surfaced in the same probes: even the immutable let x = …; form fails to parse at module scope — const X = …; is the only spelling that compiles. The decision to make immutable module-level state explicit-only (via const) is consistent and not a bug; this issue is purely about the mutable case.

Grounded evidence (oracle probes against HEAD)

Probe Result
const pi: Float = 3.14159; (immutable, module scope) ✅ parses (matches stdlib/math.affine:31 precedent)
pub const pi: Float = 3.14159; ✅ parses
pub fn count_to(n: Int) -> Int { let mut c = 0; while c < n { c = c + 1; } c } (mutable, function scope) ✅ parses
let mut counter = 0; (mutable, module scope) parse error at col 1
let mut counter: Int = 0; (mutable + type, module scope) parse error at col 1
const mut counter: Int = 0; (const mut candidate spelling) parse error at col 1
let pi: Float = 3.14159; (bare let at module scope, for comparison) parse error at col 1

So the language admits immutable module-level state via const, mutable function-local state via let mut, but has no production for mutable module-level state.

Describe the solution you'd like

Add a module-level mutable-binding form, with the mutation tracked through the Mut effect (already declared in stdlib/effects.affine:8). Two production-shape options:

  1. pub? const mut <name>: T = expr; — minimal surface change; reuses the existing const keyword + the existing mut qualifier. Reads of mut-marked top-level bindings are Mut-effectful; writes (e.g. name = new_value;) become an effectful statement.
  2. pub? let mut <name>: T = expr; at module scope — analogous to function-scope let mut; one more arm in the top-level decl grammar.

Either way, the binding sits in the resulting prog_decls and is reachable from any function whose effect row admits Mut.

The owner call on canonical spelling (the const mut vs let mut choice) belongs in a SETTLED-DECISIONS.adoc line; this issue tracks the grammar+lowering gap, not the canonical choice.

Describe alternatives considered

  • Wrap every singleton in an ML-style Ref value passed explicitly. Possible if every consumer threads the registry through every call site; not viable for idaptik's coprocessor registry which is genuinely global (host-side wasm dispatch needs O(1) lookup from arbitrary call sites).

  • Always-eager initialisation. Some singletons (e.g. let language = LanguageSettings.getLanguage();) could be const if initialisation were pure; in idaptik they're not (async wasm load, async feature-flag check, deferred-to-frame-1 patterns).

  • Use stdlib dict::set to register backends into a passed-in dict. Same objection as the explicit-Ref alternative — the registry is genuinely module-scoped and called from N consumer call sites.

Additional context

  • Mirrors #228 and #547 in shape: a language facility AS almost admits (immutable module-level via const, mutable function-local via let mut) but is missing the diagonal — exactly the seam this issue closes.
  • Surfaced during the type-surface ports in hyperpolymath/idaptik#153. The 3 ports landed (DeviceType / Coprocessor / PuzzleFormat) deliberately ported only the immutable type surface; the mutable-registry / mutable-coprocessor-handle layer of each module stayed in ReScript because this gap is open.
  • Estate impact: idaptik ~14 sites is the count I personally walked; the same pattern is general across any host with on-demand wasm loading (panll's plugin loader, gitbot-fleet's tool registry, neurophone's hardware shims).
  • Oracle / build: dune build bin/main.exe, AFFINESCRIPT_STDLIB=$PWD/stdlib, main.exe check <file>; classify on stdout (Type checking passed vs parse error).

Refs

Refs #228 (precedent — language-side grammar gap surfacing during the estate port)
Refs #229 (estate-wide ReScript-surface elimination — this is the residual blocker for the runtime layer of every Coprocessor wrapper)
Refs #547 (sibling — the other parser-asymmetry gap surfaced by the same port batch)
Refs hyperpolymath/idaptik#153 (the port batch that surfaced this)

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions