diff --git a/lib/codegen_deno.ml b/lib/codegen_deno.ml index 9d6de7e..78c5089 100644 --- a/lib/codegen_deno.ml +++ b/lib/codegen_deno.ml @@ -454,6 +454,13 @@ let () = (* `new Date().toISOString()` — UTC ISO-8601 timestamp string. Distinct from `dateNow()` which returns epoch millis as Int. *) b "dateNowIso" (fun _ -> "(new Date().toISOString())"); + (* `performance.now()` — high-resolution sub-millisecond timer. *) + b "performance_now" (fun _ -> "performance.now()"); + (* Math.random + derived integer-range helpers (campaign #239 STEP + 4-B / standards#327). Non-crypto PRNG. *) + b "math_random" (fun _ -> "Math.random()"); + b "random_u32" (fun _ -> "((Math.random() * 4294967296) >>> 0)"); + b "random_in_range" (fun a -> Printf.sprintf "(Math.floor(Math.random() * ((%s) - (%s))) + (%s))" (arg 1 a) (arg 0 a) (arg 0 a)); (* `Deno.args` — CLI argument vector (excludes argv[0]). *) b "args" (fun _ -> "Deno.args"); (* `Deno.exit(code)` — terminate process. Never returns. *) diff --git a/stdlib/Deno.affine b/stdlib/Deno.affine index a69c0b8..c538514 100644 --- a/stdlib/Deno.affine +++ b/stdlib/Deno.affine @@ -121,6 +121,30 @@ pub extern fn dateNow() -> Int; /// returns epoch millis as `Int`. pub extern fn dateNowIso() -> String; +/// `performance.now()` — high-resolution sub-millisecond timer in +/// milliseconds since process start. Use for fine-grained timings (the +/// existing `dateNow()` returns coarse epoch-millis as Int). Standard +/// JS performance API, present in Deno, Node ≥ 16, and modern browsers. +pub extern fn performance_now() -> Float; + +// ── Randomness (PRNG, non-crypto) ────────────────────────────────── +// +// Bound on `Math.random()` — the JS PRNG, **not cryptographically +// secure**. Sufficient for property-test input generation, sampling, +// and simulations. For cryptographic random bytes, a separate +// `crypto_random_bytes` binding routing to `crypto.getRandomValues()` +// belongs in a different sub-issue (different host call, different +// threat model). + +/// `Math.random()` — uniform pseudo-random Float in `[0, 1)`. +pub extern fn math_random() -> Float; + +/// Uniform u32 in `[0, 2^32)`, derived from `math_random()`. +pub extern fn random_u32() -> Int; + +/// Uniform Int in `[lo, hi)`. Undefined behaviour if `hi <= lo`. +pub extern fn random_in_range(lo: Int, hi: Int) -> Int; + // ── CLI ──────────────────────────────────────────────────────────── /// `Deno.args` — command-line arguments (excludes argv[0]). diff --git a/tests/codegen-deno/random_smoke.affine b/tests/codegen-deno/random_smoke.affine new file mode 100644 index 0000000..43f0064 --- /dev/null +++ b/tests/codegen-deno/random_smoke.affine @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MPL-2.0 +// campaign #239 STEP 4-B — randomness + perf.now smoke +// +// Exercises math_random / random_u32 / random_in_range bindings + the +// performance_now monotone-clock binding. The harness verifies +// distribution sanity (not crypto-grade): u32 spans a reasonable +// fraction of its range, random_in_range stays inside [lo, hi), and +// performance_now is non-decreasing. + +use Deno::{ math_random, random_u32, random_in_range, performance_now }; + +pub fn draw_u32() -> Int = random_u32(); + +pub fn draw_unit() -> Float = math_random(); + +pub fn draw_in_range(lo: Int, hi: Int) -> Int = random_in_range(lo, hi); + +pub fn perf_tick() -> Float = performance_now(); diff --git a/tests/codegen-deno/random_smoke.deno.js b/tests/codegen-deno/random_smoke.deno.js new file mode 100644 index 0000000..47be074 --- /dev/null +++ b/tests/codegen-deno/random_smoke.deno.js @@ -0,0 +1,302 @@ +// Generated by AffineScript compiler (Deno-ESM target, issue #122) +// SPDX-License-Identifier: MPL-2.0 +// ---- AffineScript Deno-ESM runtime ---- +const Some = (value) => ({ tag: "Some", value }); +const None = { tag: "None" }; +const Ok = (value) => ({ tag: "Ok", value }); +const Err = (error) => ({ tag: "Err", error }); +const Unit = null; +const print = (s) => { Deno.stdout.writeSync(new TextEncoder().encode(String(s))); }; +const println = (s) => { console.log(String(s)); }; +// ---- Deno host shims (extern fn lowering targets, issue #122) ---- +// Kept tiny + inlined so emitted modules are genuinely drop-in (no extra +// package to publish or resolve). The same surface is mirrored, for +// standalone `deno test`, by packages/affine-deno/mod.js. +const __as_ensureDir = (p) => { + try { Deno.mkdirSync(p, { recursive: true }); } + catch (e) { if (!(e instanceof Deno.errors.AlreadyExists)) throw e; } +}; +const __as_pathJoin = (a, b) => { + if (a.length === 0) return b; + const sep = a.endsWith("/") || a.endsWith("\\") ? "" : "/"; + return a + sep + b; +}; +const __as_readDirNames = (p) => { + const names = []; + for (const entry of Deno.readDirSync(p)) { + if (entry.isFile) names.push(entry.name); + } + return names; +}; +const __as_isNotFound = (e) => (e instanceof Deno.errors.NotFound); +const __as_walkRecursive = (root) => { + const out = []; + const rec = (dir) => { + for (const entry of Deno.readDirSync(dir)) { + const full = (dir.endsWith("/") ? dir : dir + "/") + entry.name; + if (entry.isFile) out.push(full); + else if (entry.isDirectory) rec(full); + } + }; + rec(root); + return out; +}; +const __as_regexMatch = (s, pat) => new RegExp(pat).test(String(s)); +const __as_wasmInstance = (bytes) => + new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports; +const __as_wasmCall = (exports, name, args) => Number(exports[name](...(args || []))); +// ---- WasmValue (Deno.affine #455 — Tier 1 #5, Option B) ---- +// Opaque tagged value crossing the AS/JS boundary as `{ kind, v }`. +// `kind` is one of "i32" | "i64" | "f32" | "f64". The `v` payload is +// `BigInt` for i64 (preserves precision beyond 2^53), `Number` otherwise. +const __as_wv_i32 = (n) => ({ kind: "i32", v: (Number(n) | 0) }); +const __as_wv_i64 = (n) => ({ kind: "i64", v: BigInt(n) }); +const __as_wv_f32 = (f) => ({ kind: "f32", v: Math.fround(Number(f)) }); +const __as_wv_f64 = (f) => ({ kind: "f64", v: Number(f) }); +const __as_wv_as_int = (v) => { + if (v == null) return 0; + if (typeof v.v === "bigint") { + // i64: truncate to safe-integer Number; caller's responsibility for + // precision-sensitive paths (use wv_kind to detect). + return Number(v.v); + } + // i32 / f32 / f64: truncate toward zero per AS Int semantics. + return (Number(v.v) | 0); +}; +const __as_wv_as_float = (v) => { + if (v == null) return 0; + return typeof v.v === "bigint" ? Number(v.v) : Number(v.v); +}; +const __as_wv_kind = (v) => (v && typeof v.kind === "string") ? v.kind : ""; +const __as_wasm_export_call = (exports, name, args) => { + // Unmarshal AS-side [WasmValue] to raw JS scalars for the wasm call. + const rawArgs = (args || []).map((wv) => { + if (wv == null) return 0; + // i64 payload is BigInt; wasm i64 imports accept BigInt directly. + return wv.v; + }); + const result = exports[name](...rawArgs); + // Wrap return as f64 (lossless for any numeric; callers expecting i32/i64 + // can rebuild via wv_i32(wv_as_int(result)) or inspect wv_kind). + if (typeof result === "bigint") { + return { kind: "i64", v: result }; + } + return { kind: "f64", v: Number(result) }; +}; +// ---- motion (bindings #4): consumer-provided import ---- +// Host JS environment must expose globalThis.__as_motion (the motion +// library or a compatible mock). Tests set it in the harness before +// importing the generated module; production consumers typically do +// `import * as m from "motion"; globalThis.__as_motion = m;` once at +// module-init time. The AffineScript-side externs (stdlib/Motion.affine) +// don't see this indirection — they call __as_motion* helpers directly. +const __as_motionAnimate = (target, keyframes, options) => + globalThis.__as_motion.animate(target, keyframes, options); +const __as_motionAwait = (controls) => + Promise.resolve(controls).then(() => 0); +const __as_motionCancel = (controls) => { + if (controls && typeof controls.cancel === "function") controls.cancel(); + return 0; +}; +// `animateMini` / `tween` / `spring` / `ease` — bindings #4 follow-up +// surface. Each helper resolves the host method on globalThis.__as_motion +// at call time so a mock that only stubs a subset still works for the +// rest (the smoke harness exercises every variant). +const __as_motionAnimateMini = (target, keyframes, options) => + globalThis.__as_motion.animateMini(target, keyframes, options); +const __as_motionTween = (target, from, to, options) => + globalThis.__as_motion.tween(target, from, to, options); +const __as_motionSpring = (target, keyframes, springConfig) => + globalThis.__as_motion.spring(target, keyframes, springConfig); +const __as_motionEase = (name) => + globalThis.__as_motion.ease(name); +// ---- pixi.js (bindings #1): consumer-provided import ---- +// Host JS environment exposes globalThis.__as_pixi (the PIXI namespace +// from `import * as PIXI from "pixi.js"`). Tests set it in the harness +// before importing the generated module. +const __as_pixiAppInit = async (options) => { + const app = new globalThis.__as_pixi.Application(); + await app.init(options); + return app; +}; +const __as_pixiAppCanvas = (app) => app.canvas; +const __as_pixiAppStage = (app) => app.stage; +const __as_pixiAppTicker = (app) => app.ticker; +const __as_pixiAppDestroy = (app) => { app.destroy(); return 0; }; +const __as_pixiContainerNew = () => new globalThis.__as_pixi.Container(); +const __as_pixiContainerAddChild = (p, c) => { p.addChild(c); return 0; }; +const __as_pixiContainerRemoveChild = (p, c) => { p.removeChild(c); return 0; }; +const __as_pixiContainerSetPosition = (c, x, y) => { c.x = x; c.y = y; return 0; }; +const __as_pixiContainerSetVisible = (c, v) => { c.visible = v; return 0; }; +const __as_pixiContainerDestroy = (c) => { c.destroy(); return 0; }; +const __as_pixiSpriteFrom = (t) => new globalThis.__as_pixi.Sprite(t); +// Upcasts are identity — PIXI's class hierarchy makes Sprite/Graphics/ +// Text actual Container subclasses, so the JS object is the same. +const __as_pixiSpriteAsContainer = (s) => s; +const __as_pixiTextureFromUrl = (url) => globalThis.__as_pixi.Texture.from(url); +const __as_pixiGraphicsNew = () => new globalThis.__as_pixi.Graphics(); +const __as_pixiGraphicsRect = (g, x, y, w, h) => { g.rect(x, y, w, h); return 0; }; +const __as_pixiGraphicsFill = (g, color) => { g.fill({ color }); return 0; }; +const __as_pixiGraphicsClear = (g) => { g.clear(); return 0; }; +const __as_pixiGraphicsAsContainer = (g) => g; +const __as_pixiTextNew = (options) => new globalThis.__as_pixi.Text(options); +const __as_pixiTextSetText = (t, content) => { t.text = content; return 0; }; +const __as_pixiTextAsContainer = (t) => t; +const __as_pixiTickerAdd = (t, cb) => { t.add(cb); return 0; }; +const __as_pixiTickerStart = (t) => { t.start(); return 0; }; +const __as_pixiTickerStop = (t) => { t.stop(); return 0; }; +// ---- @pixi/ui (bindings #3): consumer-provided import ---- +// Host JS environment exposes globalThis.__as_pixi_ui (the namespace +// from `import * as PixiUI from "@pixi/ui"`). Tests set it in the +// harness before importing the generated module; production +// consumers typically do once at module-init time. The +// AffineScript-side externs (stdlib/PixiUI.affine) don't see this +// indirection — they call __as_pixiUi* helpers directly. +// +// Upcasts to Container are identity — @pixi/ui's Button / +// FancyButton / Slider / Switch are all real PIXI.Container +// subclasses, so the JS object is the same. +const __as_pixiUiButtonNew = (options) => new globalThis.__as_pixi_ui.Button(options); +const __as_pixiUiButtonOnPress = (b, cb) => { b.onPress.connect(cb); return 0; }; +const __as_pixiUiButtonAsContainer = (b) => b; +const __as_pixiUiFancyButtonNew = (options) => new globalThis.__as_pixi_ui.FancyButton(options); +const __as_pixiUiFancyButtonAsContainer = (b) => b; +const __as_pixiUiSliderNew = (options) => new globalThis.__as_pixi_ui.Slider(options); +const __as_pixiUiSliderOnUpdate = (s, cb) => { s.onUpdate.connect(cb); return 0; }; +const __as_pixiUiSliderAsContainer = (s) => s; +const __as_pixiUiSwitchNew = (options) => new globalThis.__as_pixi_ui.Switch(options); +const __as_pixiUiSwitchOnChange = (sw, cb) => { sw.onChange.connect(cb); return 0; }; +const __as_pixiUiSwitchAsContainer = (sw) => sw; +// ---- @pixi/sound (bindings #2): consumer-provided import ---- +// Host JS environment exposes globalThis.__as_pixi_sound (the `Sound` +// named export from `@pixi/sound`). Tests set it in the harness before +// importing the generated module; production consumers typically do +// `import { Sound } from "@pixi/sound"; globalThis.__as_pixi_sound = Sound;` +// once at module-init time. The AffineScript-side externs +// (stdlib/PixiSound.affine) don't see this indirection — they call +// __as_pixiSound* helpers directly. +const __as_pixiSoundFrom = (url) => globalThis.__as_pixi_sound.from(url); +const __as_pixiSoundPlay = (s) => { s.play(); return 0; }; +const __as_pixiSoundStop = (s) => { s.stop(); return 0; }; +const __as_pixiSoundPause = (s) => { s.pause(); return 0; }; +const __as_pixiSoundResume = (s) => { s.resume(); return 0; }; +const __as_pixiSoundSetVolume = (s, vol) => { s.volume = vol; return 0; }; +const __as_pixiSoundSetLoop = (s, loop) => { s.loop = loop; return 0; }; +// `++` is overloaded (string concat / array concat); `a + b` would +// stringify arrays. Dispatch on shape so stdlib/string.affine's +// `result ++ [x]` and `a ++ b` are both correct. +const __as_concat = (a, b) => Array.isArray(a) ? a.concat(b) : (a + b); +// Honest host/runtime primitives underpinning the AffineScript-level +// stdlib/string.affine (its is_empty/starts_with/ends_with/split/join/ +// replace/... are real AffineScript on top of these). +const __as_strSub = (s, start, n) => String(s).slice(start, start + n); +const __as_strGet = (s, i) => String(s)[i]; +const __as_strFind = (s, n) => String(s).indexOf(n); +const __as_charToInt = (c) => String(c).codePointAt(0); +const __as_intToChar = (n) => String.fromCodePoint(n); +const __as_parseInt = (s) => { + const n = parseInt(String(s), 10); + return Number.isNaN(n) ? None : Some(n); +}; +const __as_parseFloat = (s) => { + const n = parseFloat(String(s)); + return Number.isNaN(n) ? None : Some(n); +}; +const __as_show = (v) => (typeof v === "string" ? v : JSON.stringify(v)); +// ---- Http (issue #160): portable fetch round-trip ---- +// `headers` crosses the boundary as an AffineScript [(String, String)] +// assoc list == JS array of [name, value] pairs. `body` is an +// AffineScript Option == { tag: "Some", value } | { tag: "None" }. +// The result is the `Response` record shape { status, headers, body }. +const __as_httpHeadersToObject = (pairs) => { + const o = {}; + for (const kv of (pairs || [])) o[kv[0]] = kv[1]; + return o; +}; +const __as_httpHeadersFromResponse = (res) => { + const out = []; + res.headers.forEach((value, key) => out.push([key, value])); + return out; +}; +// ---- hpm-json-rsr Zig FFI shims (stdlib/json.affine v0.3) ---- +// `HpmJsonValue` is opaque to AffineScript; on Deno-ESM it's just the +// underlying JS value from JSON.parse. The shims mirror the sentinel +// conventions of the Zig exports so the AffineScript-side wrappers +// (`to_json`, `parse`) behave identically across backends. +const __as_hpmJsonParse = (s) => { + try { return Some(JSON.parse(String(s))); } catch (_e) { return None; } +}; +const __as_hpmJsonFree = (_v) => 0; +const __as_hpmJsonType = (v) => { + if (v === null || v === undefined) return 0; + if (typeof v === "boolean") return 1; + if (typeof v === "number") return Number.isInteger(v) ? 2 : 3; + if (typeof v === "string") return 4; + if (Array.isArray(v)) return 5; + if (typeof v === "object") return 6; + return -1; +}; +const __as_hpmJsonBool = (v) => (typeof v === "boolean" ? (v ? 1 : 0) : -1); +const __as_hpmJsonInt = (v) => + (typeof v === "number" ? Math.trunc(v) : Number.MIN_SAFE_INTEGER); +const __as_hpmJsonFloat = (v) => (typeof v === "number" ? v : NaN); +const __as_hpmJsonString = (v) => (typeof v === "string" ? v : ""); +const __as_hpmJsonObjectGet = (v, k) => { + if (v === null || typeof v !== "object" || Array.isArray(v)) return None; + return Object.prototype.hasOwnProperty.call(v, String(k)) + ? Some(v[String(k)]) : None; +}; +const __as_hpmJsonArrayLen = (v) => (Array.isArray(v) ? v.length : 0); +const __as_hpmJsonArrayGet = (v, i) => { + if (!Array.isArray(v)) return None; + const idx = Number(i); + return (idx >= 0 && idx < v.length) ? Some(v[idx]) : None; +}; +const __as_hpmJsonEscapeString = (s) => { + let out = ""; + const src = String(s); + for (let i = 0; i < src.length; i++) { + const c = src.charCodeAt(i); + if (c === 0x22) out += "\\\""; + else if (c === 0x5c) out += "\\\\"; + else if (c === 0x0a) out += "\\n"; + else if (c === 0x0d) out += "\\r"; + else if (c === 0x09) out += "\\t"; + else if (c === 0x08) out += "\\b"; + else if (c === 0x0c) out += "\\f"; + else if (c < 0x20) out += "\\u00" + c.toString(16).padStart(2, "0"); + else out += src[i]; + } + return out; +}; +const __as_httpFetch = async (url, method, headers, bodyOpt) => { + const init = { method, headers: __as_httpHeadersToObject(headers) }; + if (bodyOpt && bodyOpt.tag === "Some") init.body = bodyOpt.value; + // `globalThis.fetch` explicitly: the stdlib `Http.fetch` compiles to a + // module-level `function fetch`, which would otherwise shadow the host. + const res = await globalThis.fetch(url, init); + const text = await res.text(); + return { + status: res.status, + headers: __as_httpHeadersFromResponse(res), + body: text, + }; +}; +// ---- end runtime ---- + +export function draw_u32() { + return ((Math.random() * 4294967296) >>> 0); +} + +export function draw_unit() { + return Math.random(); +} + +export function draw_in_range(lo, hi) { + return (Math.floor(Math.random() * ((hi) - (lo))) + (lo)); +} + +export function perf_tick() { + return performance.now(); +} + diff --git a/tests/codegen-deno/random_smoke.harness.mjs b/tests/codegen-deno/random_smoke.harness.mjs new file mode 100644 index 0000000..d560b79 --- /dev/null +++ b/tests/codegen-deno/random_smoke.harness.mjs @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MPL-2.0 +// campaign #239 STEP 4-B — Node-ESM harness for random + perf.now bindings. + +import assert from "node:assert/strict"; + +const { + draw_u32, + draw_unit, + draw_in_range, + perf_tick, +} = await import("./random_smoke.deno.js"); + +// math_random — every draw is in [0, 1). +for (let i = 0; i < 1000; i++) { + const x = draw_unit(); + assert.ok(x >= 0 && x < 1, `math_random must be in [0, 1), got ${x}`); +} + +// random_u32 — uniform sanity over 10000 draws: at least 1000 distinct +// values + all draws fit in u32. (A degenerate PRNG that always returned +// the same value would fail the distinct-count check; a buggy one that +// over-spilled u32 would fail the range check.) +const u32_draws = new Set(); +for (let i = 0; i < 10000; i++) { + const v = draw_u32(); + assert.ok(v >= 0 && v <= 0xFFFFFFFF, `random_u32 must fit in u32, got ${v}`); + u32_draws.add(v); +} +assert.ok(u32_draws.size >= 1000, `random_u32 distribution: only ${u32_draws.size} distinct values in 10000`); + +// random_in_range — must stay in [lo, hi) for 1000 draws. +for (let i = 0; i < 1000; i++) { + const v = draw_in_range(0, 100); + assert.ok(v >= 0 && v < 100, `random_in_range(0, 100) must be in [0, 100), got ${v}`); +} + +// random_in_range — non-zero lo / non-100 hi. +const window_draws = new Set(); +for (let i = 0; i < 500; i++) { + const v = draw_in_range(50, 60); + assert.ok(v >= 50 && v < 60, `random_in_range(50, 60) must be in [50, 60), got ${v}`); + window_draws.add(v); +} +assert.ok(window_draws.size >= 5, `random_in_range(50, 60) should cover most of [50, 60); got ${window_draws.size} distinct values`); + +// performance_now — monotone non-decreasing across consecutive calls. +const t0 = perf_tick(); +const t1 = perf_tick(); +const t2 = perf_tick(); +assert.ok(t1 >= t0, `performance_now monotone: t1 (${t1}) >= t0 (${t0})`); +assert.ok(t2 >= t1, `performance_now monotone: t2 (${t2}) >= t1 (${t1})`); +assert.ok(typeof t0 === "number" && !isNaN(t0), "performance_now returns a finite number"); + +console.log("random_smoke.harness.mjs OK");