diff --git a/lib/codegen_deno.ml b/lib/codegen_deno.ml index 55deb8d..6bf77af 100644 --- a/lib/codegen_deno.ml +++ b/lib/codegen_deno.ml @@ -155,6 +155,19 @@ const __as_readDirNames = (p) => { 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 || []))); @@ -400,6 +413,22 @@ let () = b "kbString" (fun a -> Printf.sprintf "(Number(%s) / 1024).toFixed(2)" (arg 0 a)); (* ---- misc host ---- *) b "dateNow" (fun _ -> "Date.now()"); + (* `new Date().toISOString()` — UTC ISO-8601 timestamp string. Distinct + from `dateNow()` which returns epoch millis as Int. *) + b "dateNowIso" (fun _ -> "(new Date().toISOString())"); + (* `Deno.args` — CLI argument vector (excludes argv[0]). *) + b "args" (fun _ -> "Deno.args"); + (* `Deno.exit(code)` — terminate process. Never returns. *) + b "exit" (fun a -> Printf.sprintf "Deno.exit(%s)" (arg 0 a)); + (* `console.error(s)` — stderr write. Returns 0 for chaining; the + comma-expression preserves the AffineScript Int contract. *) + b "consoleError" (fun a -> Printf.sprintf "(console.error(%s), 0)" (arg 0 a)); + (* Recursive file walk — depth-first, returns every file path under + `root`. Filtering by extension is the caller's responsibility. *) + b "walkRecursive" (fun a -> Printf.sprintf "__as_walkRecursive(%s)" (arg 0 a)); + (* `new RegExp(pat).test(s)` — minimal regex surface. Invalid `pat` + throws at call time (RegExp constructor error). *) + b "regexMatch" (fun a -> Printf.sprintf "__as_regexMatch(%s, %s)" (arg 0 a) (arg 1 a)); b "wasmInstance" (fun a -> Printf.sprintf "__as_wasmInstance(%s)" (arg 0 a)); b "wasmCall" (fun a -> Printf.sprintf "__as_wasmCall(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a)); (* ---- motion (bindings #4) ---- *) diff --git a/stdlib/Deno.affine b/stdlib/Deno.affine index 4e4f556..d0711ca 100644 --- a/stdlib/Deno.affine +++ b/stdlib/Deno.affine @@ -68,6 +68,11 @@ pub extern fn readDirNames(path: String) -> [String]; /// `Deno.statSync(path).size` in bytes. pub extern fn statSize(path: String) -> Int; +/// Recursive walk under `root` — every file path beneath it, depth-first. +/// Mirrors `std/fs/walk` for the common case (no glob filter; callers +/// filter by extension). Throws on a missing root via `Deno.readDirSync`. +pub extern fn walkRecursive(root: String) -> [String]; + // ── Path ─────────────────────────────────────────────────────────── /// Single-segment join with a `/` separator (idempotent on a trailing @@ -111,6 +116,34 @@ pub extern fn kbString(bytes: Int) -> String; /// `Date.now()` — epoch millis (used for timestamped report names). pub extern fn dateNow() -> Int; +/// `new Date().toISOString()` — UTC ISO-8601 timestamp string +/// (e.g. `"2026-05-30T12:34:56.789Z"`). Distinct from `dateNow()` which +/// returns epoch millis as `Int`. +pub extern fn dateNowIso() -> String; + +// ── CLI ──────────────────────────────────────────────────────────── + +/// `Deno.args` — command-line arguments (excludes argv[0]). +pub extern fn args() -> [String]; + +/// `Deno.exit(code)` — terminate the process with `code`. Never returns; +/// the `Int` return type is for type-level compatibility with `if/else` +/// arms that flow through `exit` in their non-returning branch. +pub extern fn exit(code: Int) -> Int; + +// ── Diagnostics ──────────────────────────────────────────────────── + +/// `console.error(s)` — write to stderr. (Use `print`/`println` for +/// stdout.) Returns 0 for chaining. +pub extern fn consoleError(s: String) -> Int; + +// ── Regex ────────────────────────────────────────────────────────── + +/// `new RegExp(pat).test(s)` — true iff `s` matches the JS regex source +/// `pat`. Minimal regex surface; for extraction or replace, add a +/// specialised extern. Invalid `pat` throws at call time. +pub extern fn regexMatch(s: String, pat: String) -> Bool; + /// `(Number(bytes) / 1024).toFixed(2)` — kilobyte display string. pub extern fn numToFixed2(bytes: Int) -> String; diff --git a/tests/codegen-deno/deno_scripting.affine b/tests/codegen-deno/deno_scripting.affine new file mode 100644 index 0000000..d3a6f0e --- /dev/null +++ b/tests/codegen-deno/deno_scripting.affine @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MPL-2.0 +// issue #122 follow-up — Deno-scripting surface (campaign #239 STEP 3 first-cut). +// +// Exercises the new externs added to stdlib/Deno.affine to unblock +// estate TS-script ports: recursive walk, args, exit, ISO timestamp, +// stderr, and minimal regex. The harness stubs `Deno.readDirSync` (for +// walkRecursive), `Deno.args`, `Deno.exit`, and captures `console.error` +// so nothing touches the real process or filesystem. +// +// Signatures stay in primitives (String/[String]/Int/Bool) so the +// fixture is self-contained and consumes only the new lowerings. + +use Deno::{ walkRecursive, args, exit, dateNowIso, consoleError, regexMatch }; + +pub fn count_walked(root: String) -> Int { + let entries = walkRecursive(root); + len(entries) +} + +pub fn first_walked(root: String) -> String { + let entries = walkRecursive(root); + if len(entries) == 0 { + return ""; + } + return entries[0]; +} + +pub fn arg_count() -> Int { + let av = args(); + len(av) +} + +pub fn iso_starts_with_year() -> Bool { + let s = dateNowIso(); + // ISO-8601 timestamps lead with a four-digit year, then `-`. + regexMatch(s, "^[0-9]{4}-") +} + +pub fn is_pa_code(cat: String) -> Bool { + regexMatch(cat, "^PA[0-9]{3}") +} + +pub fn warn_then_zero(msg: String) -> Int { + let _ = consoleError(msg); + return 0; +} + +pub fn exit_with(code: Int) -> Int = exit(code); diff --git a/tests/codegen-deno/deno_scripting.harness.mjs b/tests/codegen-deno/deno_scripting.harness.mjs new file mode 100644 index 0000000..d2e3d6c --- /dev/null +++ b/tests/codegen-deno/deno_scripting.harness.mjs @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MPL-2.0 +// issue #122 follow-up — Node-ESM harness for new Deno-scripting externs. +// +// Stubs only what the new surface needs: a tiny in-memory FS for +// `Deno.readDirSync` (so `walkRecursive` traverses it deterministically), +// `Deno.args` / `Deno.exit`, and captures `console.error`. + +import assert from "node:assert/strict"; + +// ── In-memory FS stub for walkRecursive ───────────────────────────── +// Shape: { "/root": ["a", "b/", ".hidden"], "/root/b": ["c"] } +const fs = { + "/root": [{ name: "a.txt", isFile: true, isDirectory: false }, + { name: "sub", isFile: false, isDirectory: true }], + "/root/sub": [{ name: "b.txt", isFile: true, isDirectory: false }, + { name: "deeper", isFile: false, isDirectory: true }], + "/root/sub/deeper": [{ name: "c.txt", isFile: true, isDirectory: false }], + "/empty": [], +}; + +globalThis.Deno = globalThis.Deno || {}; +globalThis.Deno.readDirSync = (path) => { + const entries = fs[path]; + if (!entries) throw new Error(`stub: no such dir ${path}`); + return entries; +}; + +// args / exit stubs — exit captures the code instead of terminating. +let lastExit = null; +globalThis.Deno.args = ["alpha", "beta", "gamma"]; +globalThis.Deno.exit = (code) => { lastExit = code; return code; }; + +// Capture stderr writes from consoleError. +const stderrLog = []; +const origError = console.error; +console.error = (...a) => { stderrLog.push(a.join(" ")); }; + +const { + count_walked, + first_walked, + arg_count, + iso_starts_with_year, + is_pa_code, + warn_then_zero, + exit_with, +} = await import("./deno_scripting.deno.js"); + +// walkRecursive — depth-first across nested dirs. +assert.equal(count_walked("/root"), 3, "walkRecursive finds 3 files (a.txt + b.txt + c.txt)"); +assert.equal(first_walked("/root"), "/root/a.txt", "walkRecursive depth-first leading entry"); +assert.equal(count_walked("/empty"), 0, "walkRecursive on empty dir returns []"); + +// Deno.args +assert.equal(arg_count(), 3, "Deno.args lowered as [String]"); + +// ISO timestamp shape — real new Date(), so just check leading year. +assert.equal(iso_starts_with_year(), true, "dateNowIso starts with 4-digit year"); + +// Regex +assert.equal(is_pa_code("PA001"), true, "regexMatch matches PA001"); +assert.equal(is_pa_code("PA42"), false, "regexMatch rejects PA42 (needs 3 digits)"); +assert.equal(is_pa_code("UnsafeCode"), false, "regexMatch rejects bare category name"); + +// consoleError captured to stderr +assert.equal(warn_then_zero("test warning"), 0, "consoleError returns 0"); +assert.equal(stderrLog.length, 1, "consoleError went to stderr"); +assert.equal(stderrLog[0], "test warning", "consoleError payload preserved"); + +// exit captured (doesn't actually terminate the harness because we stubbed it) +exit_with(2); +assert.equal(lastExit, 2, "Deno.exit lowered correctly"); + +console.error = origError; +console.log("deno_scripting.harness.mjs OK");