diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 329cf8cc..62bf2620 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,8 @@ jobs: uses: actions/setup-node@v7.0.0 with: node-version: "20" + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - name: Install dependencies run: opam install . --deps-only --with-test --with-doc --yes - name: Install tree-sitter CLI (for res-to-affine walker tests) @@ -79,6 +81,8 @@ jobs: # and runs the *.harness.mjs under Node (CI has Node 20, not Deno; # the Phase 1 fixtures are pure logic so Node ESM exercises them). run: opam exec -- ./tools/run_codegen_deno_tests.sh + - name: Run native Bun-ESM tests (issue #734) + run: opam exec -- ./tools/run_codegen_bun_tests.sh - name: Run face-transformer regression tests run: opam exec -- ./tools/run_face_transformer_tests.sh - name: Issue #35 Phase 3 — block extension.ts regression diff --git a/.gitignore b/.gitignore index b410a189..0f632882 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,9 @@ bisect*.coverage # issue #122: generated Deno-ESM regression outputs (compiled from the # committed *.affine fixtures by tools/run_codegen_deno_tests.sh). /tests/codegen-deno/*.deno.js +# Issue #734: generated native Bun-ESM acceptance outputs. +/tests/codegen-bun/*.bun.js +/tests/codegen-bun/backend-conflict.json # Local-only build workaround (see file header); never committed. /dune-workspace packages/affinescript-cli/deno.lock diff --git a/bin/main.ml b/bin/main.ml index 8230ab2b..6a6e2c62 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -490,9 +490,21 @@ 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 vscode_ext vscode_adapter vscode_no_lc - deno_esm target path output = + deno_esm bun_esm target path output = let face = resolve_face ~quiet:json face path in - if json then begin + let is_deno = deno_esm || Filename.check_suffix output ".deno.js" in + let is_bun = bun_esm || Filename.check_suffix output ".bun.js" in + if is_deno && is_bun then + let message = "--deno-esm and --bun-esm are mutually exclusive" in + if json then + json_finish [{ Affinescript.Json_output.severity = Error; + code = "E0826"; message; + span = Affinescript.Span.dummy; help = None; labels = [] }] + else begin + Format.eprintf "@[Backend selection error: %s@]@." message; + `Error (false, "Backend selection error") + end + else if json then begin let diags = ref [] in let add d = diags := d :: !diags in begin try @@ -525,8 +537,9 @@ let compile_file face json wasm_gc vscode_ext vscode_adapter vscode_no_lc via Codegen.gen_imports / the import section. *) let flat_prog = Affinescript.Module_loader.flatten_imports loader prog in let is_deno = deno_esm || Filename.check_suffix output ".deno.js" in + let is_bun = bun_esm || Filename.check_suffix output ".bun.js" in let is_julia = Filename.check_suffix output ".jl" in - let is_js = (not is_deno) && Filename.check_suffix output ".js" in + let is_js = (not is_deno) && (not is_bun) && Filename.check_suffix output ".js" in let is_c = Filename.check_suffix output ".c" in let is_wgsl = Filename.check_suffix output ".wgsl" in let is_faust = Filename.check_suffix output ".dsp" in @@ -547,14 +560,24 @@ let compile_file face json wasm_gc vscode_ext vscode_adapter vscode_no_lc let is_why3 = Filename.check_suffix output ".mlw" in let is_lean = Filename.check_suffix output ".lean" in let is_spirv = Filename.check_suffix output ".spv" in - if is_deno then begin + if is_bun then begin + match Affinescript.Codegen_deno.codegen_bun flat_prog resolve_ctx.symbols with + | Error msg -> + add { severity = Error; code = "E0825"; + message = msg; + span = Affinescript.Span.dummy; help = None; labels = [] } + | Ok esm_code -> + let oc = open_out_bin output in + output_string oc esm_code; + close_out oc + end else if is_deno then begin match Affinescript.Codegen_deno.codegen_deno flat_prog resolve_ctx.symbols with | Error msg -> add { severity = Error; code = "E0824"; message = Printf.sprintf "Deno-ESM codegen error: %s" msg; span = Affinescript.Span.dummy; help = None; labels = [] } | Ok esm_code -> - let oc = open_out output in + let oc = open_out_bin output in output_string oc esm_code; close_out oc end else if is_julia then begin @@ -757,8 +780,9 @@ let compile_file face json wasm_gc vscode_ext vscode_adapter vscode_no_lc module-system support. Wasm/Wasm-GC keep the original [prog]. *) let flat_prog = Affinescript.Module_loader.flatten_imports loader prog in let is_deno = deno_esm || Filename.check_suffix output ".deno.js" in + let is_bun = bun_esm || Filename.check_suffix output ".bun.js" in let is_julia = Filename.check_suffix output ".jl" in - let is_js = (not is_deno) && Filename.check_suffix output ".js" in + let is_js = (not is_deno) && (not is_bun) && Filename.check_suffix output ".js" in let is_c = Filename.check_suffix output ".c" in let is_wgsl = Filename.check_suffix output ".wgsl" in let is_faust = Filename.check_suffix output ".dsp" in @@ -779,7 +803,18 @@ let compile_file face json wasm_gc vscode_ext vscode_adapter vscode_no_lc let is_why3 = Filename.check_suffix output ".mlw" in let is_lean = Filename.check_suffix output ".lean" in let is_spirv = Filename.check_suffix output ".spv" in - if is_deno then + if is_bun then + (match Affinescript.Codegen_deno.codegen_bun flat_prog resolve_ctx.symbols with + | Error e -> + Format.eprintf "@[%s@]@." e; + `Error (false, "Bun-ESM codegen error") + | Ok esm_code -> + let oc = open_out_bin output in + output_string oc esm_code; + close_out oc; + Format.printf "Compiled %s -> %s (Bun-ESM)@." path output; + `Ok ()) + else if is_deno then (match Affinescript.Codegen_deno.codegen_deno flat_prog resolve_ctx.symbols with | Error e -> Format.eprintf "@[Deno-ESM codegen error: %s@]@." e; @@ -1259,6 +1294,15 @@ let deno_esm_arg = no handle table — the output is a drop-in importable ESM. A \ `.deno.js` output extension selects this backend implicitly.") +let bun_esm_arg = + Arg.(value & flag & info ["bun-esm"] + ~doc:"Emit a standalone Bun-native ES module directly from the AST: \ + public declarations are exported and host operations use Bun's \ + Node-compatible synchronous APIs. The emitted module contains no \ + legacy-runtime shim. A `.bun.js` output extension selects this \ + backend implicitly. This option is mutually exclusive with \ + `--deno-esm`.") + (** Shared --face flag: select the parser surface-syntax face. *) let face_arg = let faces = Arg.enum [ @@ -1602,11 +1646,11 @@ 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), Julia (.jl), JavaScript (.js), C (.c), a WGSL compute kernel (.wgsl), a Faust DSP program (.dsp), or an ONNX model (.onnx)" in + let doc = "Compile a file to WebAssembly (1.0 or GC proposal), native Bun ESM (.bun.js/--bun-esm), Julia (.jl), JavaScript (.js), C (.c), a WGSL compute kernel (.wgsl), a Faust DSP program (.dsp), or an ONNX model (.onnx)" in let info = Cmd.info "compile" ~doc in Cmd.v info Term.(ret (const compile_file $ face_arg $ json_arg $ wasm_gc_arg $ vscode_ext_arg $ vscode_adapter_arg $ vscode_no_lc_arg $ deno_esm_arg - $ target_arg $ path_arg $ output_arg)) + $ bun_esm_arg $ target_arg $ path_arg $ output_arg)) let fmt_cmd = let doc = "Format a file" in diff --git a/docs/CAPABILITY-MATRIX.adoc b/docs/CAPABILITY-MATRIX.adoc index b89c9ceb..80ed92ba 100644 --- a/docs/CAPABILITY-MATRIX.adoc +++ b/docs/CAPABILITY-MATRIX.adoc @@ -195,6 +195,16 @@ Producer-side; target spec is the separate `hyperpolymath/typed-wasm` repo. |Deno-ESM |works |Direct AST→ES-module transpiler (`lib/codegen_deno.ml`), `--deno-esm` / `.deno.js`. Shipped + consumer-verified (ubicity). +|Bun-ESM |works |Host-profiled direct AST→ES-module transpiler, +`--bun-esm` / `.bun.js`. Public declarations remain importable ESM; filesystem, +argument, and process operations lower through Bun's synchronous +Node-compatibility surface. Host operations are resolved lazily so a generated +module that only uses browser-provided externs remains browser-importable. CI +compiles, checks for legacy-runtime leakage, parses, and executes filesystem, +byte, argument, export, and missing-path controls under Bun. +Migration and host-contract details: +link:guides/bun-esm-migration.adoc[guides/bun-esm-migration.adoc]. + |Node-CJS |works |`lib/codegen_node.ml`; CJS shim, handle table, the `.affine` VS Code extension compiles through it. Live-host smoke harness for the compiled extension landed via PR #317 / issue #139: @@ -250,6 +260,7 @@ test is deleted or renamed without this section (and CI) noticing. |typed-wasm isolation |`test/test_tw_isolation.ml` |Codegen (golden snapshots) |`test/test_golden.ml` |Deno / JS host backends |`test/test_deno_builtins_consistency.ml`, `test/test_int_div_js.ml` +|Bun-ESM host backend |`tools/run_codegen_bun_tests.sh` |Solo core (executable metatheory) |`test/test_solo_cesk.ml` |=== diff --git a/docs/guides/bun-esm-migration.adoc b/docs/guides/bun-esm-migration.adoc new file mode 100644 index 00000000..d0f07eaa --- /dev/null +++ b/docs/guides/bun-esm-migration.adoc @@ -0,0 +1,71 @@ += Migrating direct ESM output to Bun +:toc: macro + +toc::[] + +== Select the Bun host profile + +Replace `--deno-esm` with `--bun-esm`, and replace generated `.deno.js` names +with `.bun.js`. A `.bun.js` output suffix selects the profile implicitly, but +the explicit flag is preferable in build definitions because it records the +runtime contract directly. + +[source,console] +---- +affinescript compile --bun-esm -o build/library.bun.js src/library.affine +bun --check build/library.bun.js +---- + +The compiler emits public functions, constants, variants, and generated +classes as standard ESM exports. Bun output is checked in CI to contain no +case-insensitive reference to the former runtime. + +== Host operations + +The shared ESM core contains runtime-neutral lowering for JSON, URL, +WebAssembly, web APIs, and pure language operations. The Bun profile supplies +its own filesystem, arguments, process, environment, subprocess, standard-I/O, +and exit lowerings. Synchronous filesystem and subprocess operations use Bun's +Node-compatibility surface. They are resolved lazily, so a generated module +that only calls browser-provided externs can still load in a webview with no +`process` global. + +Import explicit Bun process capabilities from `stdlib/Bun.affine`: + +[source,affinescript] +---- +use Bun::{ bun_env_get, bun_run, bun_stdin_text, + bun_stdout_write, bun_stderr_write, bun_exit }; +---- + +Names beginning with `bun_` form a checked capability namespace. If such an +extern has no compiler lowering, Bun code generation fails non-zero. This is a +deliberate negative guarantee: a misspelt or unimplemented host operation must +not silently turn into an optimistic JavaScript call. Other `extern fn` names +retain the existing direct-ESM contract and lower to same-named host symbols; +that facility is required for explicit webview bridges such as Gossamer. + +== Runtime-neutral core and compatibility + +`lib/codegen_deno.ml` currently owns the mature direct-ESM lowering machinery, +but its generated runtime is split into three parts: a compatibility host +profile, a Bun host profile, and a shared runtime-neutral prelude/builtin core. +The source filename is retained to avoid a high-risk mechanical move in the +same change as the semantic migration. It does not imply that Bun output passes +through or embeds the compatibility runtime. + +The compatibility flag remains available for existing external consumers. An +estate migration is complete only when its active workflows, runtime files, +lockfiles, build commands, generated artefact names, examples, and current +documentation have moved to Bun. Historical records may retain their original +runtime names when clearly marked as history; renaming history would make it +less accurate. + +== Verification contract + +`tools/run_codegen_bun_tests.sh` provides positive and planted-negative +controls. It compiles and imports public exports, exercises filesystem and byte +I/O, arguments, environment values, subprocess status, stdin, and explicit +exit status under Bun; checks JavaScript syntax and absence of legacy-runtime +references; compiles twice and compares output byte-for-byte; and proves that +an unsupported `bun_` capability fails compilation. diff --git a/lib/codegen_deno.ml b/lib/codegen_deno.ml index 2a289331..b3842b43 100644 --- a/lib/codegen_deno.ml +++ b/lib/codegen_deno.ml @@ -37,11 +37,14 @@ open Ast +type host_profile = Deno | Bun + (* ============================================================================ Code-generation context ============================================================================ *) type codegen_ctx = { + host : host_profile; output : Buffer.t; indent : int; symbols : Symbol.t; @@ -95,7 +98,8 @@ type codegen_ctx = { in_async : bool; } -let create_ctx symbols = { +let create_ctx host symbols = { + host; output = Buffer.create 1024; indent = 0; symbols; @@ -148,12 +152,7 @@ let fd_is_async (fd : fn_decl) : bool = that need Node should target the .cjs backend instead). ============================================================================ *) -let prelude = {|// ---- 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; +let deno_host_prelude = {|// ---- AffineScript Deno-ESM runtime ---- 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) ---- @@ -181,7 +180,7 @@ const __as_walkRecursive = (root) => { const out = []; const rec = (dir) => { for (const entry of Deno.readDirSync(dir)) { - const full = (dir.endsWith("/") ? dir : dir + "/") + entry.name; + const full = __as_pathJoin(dir, entry.name); if (entry.isFile) out.push(full); else if (entry.isDirectory) rec(full); } @@ -189,12 +188,74 @@ const __as_walkRecursive = (root) => { rec(root); return out; }; +|} + +let bun_host_prelude = {|// ---- AffineScript Bun-ESM runtime ---- +const print = (s) => { + const stdout = globalThis.process?.stdout; + if (stdout) stdout.write(String(s)); else console.log(String(s)); +}; +const println = (s) => { console.log(String(s)); }; +// ---- Bun host shims (extern fn lowering targets) ---- +// Resolve Node-compatible builtins lazily. This keeps pure/browser-facing +// generated modules importable where no filesystem host is present, while +// Bun executions receive its synchronous compatibility implementation. +const __as_fs = () => { + const getBuiltinModule = globalThis.process?.getBuiltinModule; + if (!getBuiltinModule) throw new Error("Bun filesystem host is unavailable"); + return getBuiltinModule("node:fs"); +}; +const __as_process = () => { + const process = globalThis.process; + if (!process) throw new Error("Bun process host is unavailable"); + return process; +}; +const __as_childProcess = () => { + const getBuiltinModule = globalThis.process?.getBuiltinModule; + if (!getBuiltinModule) throw new Error("Bun subprocess host is unavailable"); + return getBuiltinModule("node:child_process"); +}; +const __as_path = () => { + const getBuiltinModule = globalThis.process?.getBuiltinModule; + if (!getBuiltinModule) throw new Error("Bun path host is unavailable"); + return getBuiltinModule("node:path"); +}; +const __as_ensureDir = (p) => { __as_fs().mkdirSync(p, { recursive: true }); }; +const __as_pathJoin = (a, b) => __as_path().join(a, b); +const __as_readDirNames = (p) => { + const names = []; + for (const entry of __as_fs().readdirSync(p, { withFileTypes: true })) { + if (entry.isFile()) names.push(entry.name); + } + return names; +}; +const __as_isNotFound = (e) => Boolean(e && e.code === "ENOENT"); +const __as_walkRecursive = (root) => { + const out = []; + const rec = (dir) => { + for (const entry of __as_fs().readdirSync(dir, { withFileTypes: true })) { + const full = __as_pathJoin(dir, entry.name); + if (entry.isFile()) out.push(full); + else if (entry.isDirectory()) rec(full); + } + }; + rec(root); + return out; +}; +|} + +let common_prelude = {| +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 __as_regexMatch = (s, pat) => new RegExp(pat).test(String(s)); const __as_wasmInstance = (bytes) => new WebAssembly.Instance(new WebAssembly.Module(bytes), { wasi_snapshot_preview1: { fd_write: () => 0 } }).exports; const __as_wasmCall = (exports, name, args) => Number(exports[name](...(args || []))); -// ---- WasmValue (Deno.affine #455 — Tier 1 #5, Option B) ---- +// ---- WasmValue host bindings (#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. @@ -344,7 +405,7 @@ const __as_pixiSoundSetVolume = (s, vol) => { s.volume = vol; return 0; }; const __as_pixiSoundSetLoop = (s, loop) => { s.loop = loop; return 0; }; // ---- Ipc (bindings #9): web-platform MessageChannel/MessagePort ---- // Uses standard web globals (MessageChannel, structuredClone) — no -// consumer-side init required. Available unmodified in Deno, Node 16+, +// consumer-side init required. Available unmodified in modern JS runtimes, // browsers, and Web Workers. const __as_messageChannelNew = () => new MessageChannel(); const __as_messageChannelPort1 = (ch) => ch.port1; @@ -358,7 +419,7 @@ const __as_structuredCloneValue = (v) => structuredClone(v); // ---- Canvas (bindings #8): HTML5 Canvas 2D rendering context ---- // `canvas` arg is the consumer-supplied HTMLCanvasElement; helpers // dispatch directly to the standard CanvasRenderingContext2D -// methods. Available unmodified in browsers, jsdom-under-Deno, +// methods. Available unmodified in browsers and jsdom, // idaptik's WebView host, and any DOM emulator. const __as_canvasGetContext2D = (canvas) => canvas.getContext("2d"); const __as_canvasFillStyle = (ctx, color) => { ctx.fillStyle = color; return 0; }; @@ -427,7 +488,7 @@ const __as_httpHeadersFromResponse = (res) => { 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 +// `HpmJsonValue` is opaque to AffineScript; on direct 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. @@ -480,7 +541,7 @@ const __as_hpmJsonEscapeString = (s) => { // ---- Sqlite (db-theory #1a / stdlib/Sqlite.affine): SQL via host adapter ---- // Host JS environment must expose globalThis.__as_sqlite, a namespace // implementing the small adapter contract below. Consumers init once -// (Deno): +// (host runtime): // import * as s from "jsr:@db/sqlite"; // globalThis.__as_sqlite = { // open: (p) => new s.Database(p), @@ -542,6 +603,23 @@ const __as_dbColumnText = (s, idx) => { }; const __as_dbReset = (s) => { globalThis.__as_sqlite.reset(s); return 0; }; const __as_dbFinalize = (s) => { globalThis.__as_sqlite.finalize(s); return 0; }; +// ---- Sqlite schema introspection + bulk I/O + error inspection (db-theory #1c) ---- +// Five more adapter methods (`schemaTables`, `schemaColumns`, +// `tableExists`, `importCsv`, `exportCsv`, `lastError`); each +// real-world adapter backs them with a small query or file operation. +const __as_dbSchemaTables = (h) => String(globalThis.__as_sqlite.schemaTables(h)); +const __as_dbSchemaColumns = (h, table) => String(globalThis.__as_sqlite.schemaColumns(h, table)); +const __as_dbTableExists = (h, table) => Boolean(globalThis.__as_sqlite.tableExists(h, table)); +const __as_dbImportCsv = (h, table, path, hasHeader) => + Number(globalThis.__as_sqlite.importCsv(h, table, path, Boolean(hasHeader))) | 0; +const __as_dbExportCsv = (h, sql, paramsJson, path) => { + const params = paramsJson === "" || paramsJson === "[]" ? [] : JSON.parse(paramsJson); + return Number(globalThis.__as_sqlite.exportCsv(h, sql, params, path)) | 0; +}; +const __as_dbLastError = (h) => { + const v = globalThis.__as_sqlite.lastError(h); + return v == null ? "" : String(v); +}; // ---- Sqlite transactions (db-theory #2) ---- // `Tx` is an opaque handle; the host adapter is required to // invalidate it on `commit` / `rollback` so that subsequent calls @@ -873,6 +951,13 @@ let () = b "db_column_text" (fun a -> Printf.sprintf "__as_dbColumnText(%s, %s)" (arg 0 a) (arg 1 a)); b "db_reset" (fun a -> Printf.sprintf "__as_dbReset(%s)" (arg 0 a)); b "db_finalize" (fun a -> Printf.sprintf "__as_dbFinalize(%s)" (arg 0 a)); + (* ---- Sqlite schema/bulk/error inspection (db-theory #1c) ---- *) + b "db_schema_tables" (fun a -> Printf.sprintf "__as_dbSchemaTables(%s)" (arg 0 a)); + b "db_schema_columns" (fun a -> Printf.sprintf "__as_dbSchemaColumns(%s, %s)" (arg 0 a) (arg 1 a)); + b "db_table_exists" (fun a -> Printf.sprintf "__as_dbTableExists(%s, %s)" (arg 0 a) (arg 1 a)); + b "db_import_csv" (fun a -> Printf.sprintf "__as_dbImportCsv(%s, %s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a) (arg 3 a)); + b "db_export_csv" (fun a -> Printf.sprintf "__as_dbExportCsv(%s, %s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a) (arg 3 a)); + b "db_last_error" (fun a -> Printf.sprintf "__as_dbLastError(%s)" (arg 0 a)); (* ---- Sqlite transactions (db-theory #2 / stdlib/Transaction.affine) ---- *) b "tx_begin" (fun a -> Printf.sprintf "__as_txBegin(%s)" (arg 0 a)); b "tx_commit" (fun a -> Printf.sprintf "__as_txCommit(%s)" (arg 0 a)); @@ -956,6 +1041,55 @@ let pat_var_name : pattern -> string option = function (* Builtins whose return type is unambiguously [Int]. Calls to these count as integer operands. (Excludes e.g. [parse_int], which is Option.) *) +let bun_builtins : + (string, string list -> string) Hashtbl.t = Hashtbl.copy deno_builtins + +let () = + let b name f = Hashtbl.replace bun_builtins name f in + let arg n a = List.nth a n in + b "writeTextFile" + (fun a -> Printf.sprintf "(__as_fs().writeFileSync(%s, %s, \"utf8\"), 0)" + (arg 0 a) (arg 1 a)); + b "readTextFile" + (fun a -> Printf.sprintf "__as_fs().readFileSync(%s, \"utf8\")" (arg 0 a)); + b "readFileBytes" + (fun a -> Printf.sprintf "new Uint8Array(__as_fs().readFileSync(%s))" (arg 0 a)); + b "removePath" + (fun a -> Printf.sprintf "(__as_fs().rmSync(%s), 0)" (arg 0 a)); + b "mkdirRecursive" + (fun a -> Printf.sprintf "(__as_fs().mkdirSync(%s, { recursive: true }), 0)" + (arg 0 a)); + b "statSize" + (fun a -> Printf.sprintf "__as_fs().statSync(%s).size" (arg 0 a)); + b "statIsFile" + (fun a -> Printf.sprintf "__as_fs().statSync(%s).isFile()" (arg 0 a)); + b "statIsDirectory" + (fun a -> Printf.sprintf "__as_fs().statSync(%s).isDirectory()" (arg 0 a)); + b "args" (fun _ -> "(globalThis.process?.argv?.slice(2) ?? [])"); + b "exit" + (fun a -> Printf.sprintf "__as_process().exit(%s)" (arg 0 a)); + b "bun_env_get" + (fun a -> + Printf.sprintf + "((__v) => __v === undefined ? None : Some(__v))(__as_process().env[%s])" + (arg 0 a)); + b "bun_run" + (fun a -> + Printf.sprintf + "(__as_childProcess().spawnSync(%s, %s, { stdio: \"inherit\" }).status ?? 1)" + (arg 0 a) (arg 1 a)); + b "bun_stdin_text" + (fun _ -> "__as_fs().readFileSync(0, \"utf8\")"); + b "bun_stdout_write" + (fun a -> Printf.sprintf "(__as_process().stdout.write(String(%s)), 0)" (arg 0 a)); + b "bun_stderr_write" + (fun a -> Printf.sprintf "(__as_process().stderr.write(String(%s)), 0)" (arg 0 a)); + b "bun_exit" + (fun a -> Printf.sprintf "__as_process().exit(%s)" (arg 0 a)) + +let builtins_for ctx = + match ctx.host with Deno -> deno_builtins | Bun -> bun_builtins + let int_returning_builtins = [ "len"; "string_find"; "string_char_code_at"; "char_to_int"; "string_length" ] @@ -1049,15 +1183,21 @@ let rec gen_expr ctx (expr : expr) : string = inside the [async] method bodies we emit). *) "(await " ^ recv ^ "." ^ m ^ "(" ^ String.concat ", " rest ^ "))" | ExprVar id - when Hashtbl.mem deno_builtins id.name + when Hashtbl.mem (builtins_for ctx) id.name && not (Hashtbl.mem ctx.local_fns id.name) -> (* Honest host/runtime intrinsic (FS/JSON/Date/Wasm extern or a string/number primitive underpinning stdlib/string.affine). Applied to ANY matching call head, not only declared externs, so AffineScript-level stdlib compiled here resolves — but a same-named user definition shadows it (e.g. a user `len`). *) - (Hashtbl.find deno_builtins id.name) (List.map (gen_expr ctx) args) + (Hashtbl.find (builtins_for ctx) id.name) + (List.map (gen_expr ctx) args) | ExprVar id when Hashtbl.mem ctx.externs id.name -> + if ctx.host = Bun && String.starts_with ~prefix:"bun_" id.name then + failwith + (Printf.sprintf + "unsupported Bun host operation `%s`: add an explicit lowering before using it" + id.name); (* Declared extern with no intrinsic lowering: assume a same-named host symbol is in scope. *) let arg_strs = List.map (gen_expr ctx) args in @@ -1182,15 +1322,15 @@ let rec gen_expr ctx (expr : expr) : string = unaffected — they lower via `async function` (fd_is_async), not via `handle` expressions. *) failwith - "effect handler (handle { ... }) is not supported by the Deno-ESM \ + "effect handler (handle { ... }) is not supported by the direct ESM \ backend — handler arms cannot be dispatched (Refs #555); \ use `--interp` / `-i`" | ExprResume _ -> failwith - "`resume` is not supported by the Deno-ESM backend — only valid \ + "`resume` is not supported by the direct ESM backend — only valid \ inside a `handle` block (Refs #555); use `--interp` / `-i`" | ExprUnsafe _ -> - iife ctx "throw new Error('unsafe op not supported in Deno-ESM backend');" + iife ctx "throw new Error('unsafe op not supported in direct ESM backend');" and gen_literal (lit : literal) : string = match lit with @@ -1719,8 +1859,8 @@ let gen_type_decl ctx (td : type_decl) : unit = a bare struct/alias/extern type carries no runtime value. *) emit_line ctx (Printf.sprintf "// type %s" td.td_name.name) -let generate (program : program) (symbols : Symbol.t) : string = - let ctx = create_ctx symbols in +let generate (host : host_profile) (program : program) (symbols : Symbol.t) : string = + let ctx = create_ctx host symbols in (* Register extern names so calls lower via the builtin table, and user-defined top-level names so they shadow host intrinsics. *) List.iter (function @@ -1751,9 +1891,13 @@ let generate (program : program) (symbols : Symbol.t) : string = | ImplType _ -> ()) ib.ib_items | _ -> ()) program.prog_decls; - emit_line ctx "// Generated by AffineScript compiler (Deno-ESM target, issue #122)"; + emit_line ctx + (match host with + | Deno -> "// Generated by AffineScript compiler (Deno-ESM target, issue #122)" + | Bun -> "// Generated by AffineScript compiler (Bun-ESM target, issue #734)"); emit_line ctx "// SPDX-License-Identifier: MPL-2.0"; - emit ctx prelude; + emit ctx (match host with Deno -> deno_host_prelude | Bun -> bun_host_prelude); + emit ctx common_prelude; (* Collect structs. AffineScript's grammar accepts neither inherent [impl Type {}] nor a [self] expression (SELF_KW has no expression @@ -1854,7 +1998,14 @@ let generate (program : program) (symbols : Symbol.t) : string = let codegen_deno (program : program) (symbols : Symbol.t) : (string, string) result = - try Ok (generate program symbols) + try Ok (generate Deno program symbols) with | Failure msg -> Error ("Deno-ESM codegen error: " ^ msg) | e -> Error ("Deno-ESM codegen error: " ^ Printexc.to_string e) + +let codegen_bun (program : program) (symbols : Symbol.t) + : (string, string) result = + try Ok (generate Bun program symbols) + with + | Failure msg -> Error ("Bun-ESM codegen error: " ^ msg) + | e -> Error ("Bun-ESM codegen error: " ^ Printexc.to_string e) diff --git a/mise.toml b/mise.toml index 6dd983f6..28fe8ff0 100644 --- a/mise.toml +++ b/mise.toml @@ -1,46 +1,10 @@ [tools] -# Language runtimes +# Project toolchains. Language package managers come with their runtimes; +# listing npm, cargo, pip, or gofmt as independent mise plugins is invalid. +bun = "latest" node = "latest" -python = "latest" rust = "latest" -go = "latest" zig = "latest" -java = "latest" -bun = "latest" -denojs = "latest" - -# Package managers -npm = "latest" -yarn = "latest" -pnpm = "latest" -pip = "latest" -cargo = "latest" -go-task = "latest" - -# Formatting & Linting -gofmt = "latest" -black = "latest" -isort = "latest" -ruff = "latest" -prettier = "latest" -shfmt = "latest" -stylua = "latest" - -# Build tools -cmake = "latest" -make = "latest" -ninja = "latest" - -# Shell tools -git = "latest" -gnu-sed = "latest" -gnu-tar = "latest" -gnu-grep = "latest" - -# Testing -vitest = "latest" -pytest = "latest" -jest = "latest" [env] # Common environment variables @@ -48,10 +12,11 @@ NODE_ENV = "development" PYTHONDONTWRITEBYTECODE = "1" PYTHONUNBUFFERED = "1" -# Task runner alias -[alias] -task = "go-task" -build = "cargo build --release || npm run build || go build" -test = "cargo test || npm test || go test ./..." -lint = "ruff check . || prettier --check . || black --check ." -fmt = "ruff format . || prettier --write . || black ." +[tasks.build] +run = "opam exec -- dune build" + +[tasks.test] +run = "opam exec -- dune runtest" + +[tasks.bun-esm-test] +run = "./tools/run_codegen_bun_tests.sh" diff --git a/stdlib/Bun.affine b/stdlib/Bun.affine new file mode 100644 index 00000000..6e4e3dc7 --- /dev/null +++ b/stdlib/Bun.affine @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Explicit host operations for the native `--bun-esm` profile. The `bun_` +// prefix is capability-bearing: an unknown prefixed extern is rejected by the +// compiler instead of silently becoming a same-named JavaScript call. + +module Bun; + +use prelude::{Option}; + +/// Read an environment variable without conflating absence with an empty value. +pub extern fn bun_env_get(name: String) -> Option; + +/// Run a child process synchronously, inherit its standard streams, and return +/// its exit status (or 1 when the host supplies no status). +pub extern fn bun_run(command: String, arguments: [String]) -> Int; + +/// Read standard input to EOF as UTF-8. +pub extern fn bun_stdin_text() -> String; + +/// Write without adding a newline. Both operations return 0. +pub extern fn bun_stdout_write(value: String) -> Int; +pub extern fn bun_stderr_write(value: String) -> Int; + +/// Terminate with the supplied status. This function does not return. +pub extern fn bun_exit(status: Int) -> Unit; diff --git a/tests/codegen-bun/host_profile.affine b/tests/codegen-bun/host_profile.affine new file mode 100644 index 00000000..e9bd8b23 --- /dev/null +++ b/tests/codegen-bun/host_profile.affine @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MPL-2.0 +// Bun-ESM host-profile acceptance fixture (issue #734). + +use Bun::{ bun_env_get, bun_run, bun_stdin_text, bun_stdout_write, bun_stderr_write, bun_exit }; +use prelude::{Some, None}; + +extern type Bytes; +extern fn writeTextFile(path: String, content: String) -> Int; +extern fn readTextFile(path: String) -> String; +extern fn readFileBytes(path: String) -> Bytes; +extern fn removePath(path: String) -> Int; +extern fn mkdirRecursive(path: String) -> Int; +extern fn statSize(path: String) -> Int; +extern fn statIsFile(path: String) -> Bool; +extern fn statIsDirectory(path: String) -> Bool; +extern fn bytesLength(value: Bytes) -> Int; +extern fn bytesByteAt(value: Bytes, offset: Int) -> Int; +extern fn args() -> [String]; + +pub fn write_text(path: String, content: String) -> Int { + writeTextFile(path, content) +} + +pub fn read_text(path: String) -> String { readTextFile(path) } +pub fn remove_path(path: String) -> Int { removePath(path) } +pub fn make_directory(path: String) -> Int { mkdirRecursive(path) } +pub fn file_size(path: String) -> Int { statSize(path) } +pub fn is_file(path: String) -> Bool { statIsFile(path) } +pub fn is_directory(path: String) -> Bool { statIsDirectory(path) } +pub fn argument_count() -> Int { len(args()) } + +pub fn environment_value(name: String) -> String { + match bun_env_get(name) { + Some(value) => value, + None => "" + } +} + +pub fn run_successful_child() -> Int { + bun_run("bun", ["-e", "process.exit(0)"]) +} + +pub fn read_standard_input() -> String { bun_stdin_text() } +pub fn write_standard_output(value: String) -> Int { bun_stdout_write(value) } +pub fn write_standard_error(value: String) -> Int { bun_stderr_write(value) } +pub fn exit_with(status: Int) -> Unit { bun_exit(status) } + +pub fn first_byte(path: String) -> Int { + let value = readFileBytes(path); + if bytesLength(value) == 0 { 0 } else { bytesByteAt(value, 0) } +} diff --git a/tests/codegen-bun/host_profile.harness.mjs b/tests/codegen-bun/host_profile.harness.mjs new file mode 100644 index 00000000..e09589f2 --- /dev/null +++ b/tests/codegen-bun/host_profile.harness.mjs @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MPL-2.0 +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as subject from "./host_profile.bun.js"; + +const root = mkdtempSync(join(tmpdir(), "affinescript-bun-esm-")); +const nested = join(root, "nested"); +const file = join(nested, "probe.txt"); + +try { + subject.make_directory(nested); + if (!subject.is_directory(nested)) throw new Error("mkdir/stat directory failed"); + subject.write_text(file, "Bun host profile"); + if (!subject.is_file(file)) throw new Error("write/stat file failed"); + if (subject.read_text(file) !== "Bun host profile") throw new Error("text round trip failed"); + if (subject.file_size(file) !== 16) throw new Error("file size failed"); + if (subject.first_byte(file) !== 66) throw new Error("byte read failed"); + if (subject.argument_count() !== 2) throw new Error("argv lowering failed"); + if (subject.environment_value("AFFINESCRIPT_BUN_PROBE") !== "estate") { + throw new Error("environment lowering failed"); + } + if (subject.environment_value("AFFINESCRIPT_BUN_MISSING") !== "") { + throw new Error("missing environment value failed"); + } + if (subject.run_successful_child() !== 0) throw new Error("subprocess lowering failed"); + subject.remove_path(file); + let missingPathThrew = false; + try { + subject.is_file(file); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + missingPathThrew = true; + } + if (!missingPathThrew) throw new Error("remove did not make path absent"); +} finally { + rmSync(root, { recursive: true, force: true }); +} + +const moduleUrl = new URL("./host_profile.bun.js", import.meta.url).href; +const stdinProbe = Bun.spawnSync({ + cmd: ["bun", "-e", `const m = await import(${JSON.stringify(moduleUrl)}); process.stdout.write(m.read_standard_input());`], + stdin: new TextEncoder().encode("stdin-probe"), + stdout: "pipe", + stderr: "pipe", +}); +if (stdinProbe.exitCode !== 0 || stdinProbe.stdout.toString() !== "stdin-probe") { + throw new Error(`stdin lowering failed: ${stdinProbe.stderr.toString()}`); +} + +const exitProbe = Bun.spawnSync({ + cmd: ["bun", "-e", `const m = await import(${JSON.stringify(moduleUrl)}); m.exit_with(23);`], + stdout: "pipe", + stderr: "pipe", +}); +if (exitProbe.exitCode !== 23) throw new Error("exit-status lowering failed"); + +console.log("Bun-ESM host profile: ok"); diff --git a/tests/codegen-bun/unsupported_host.affine b/tests/codegen-bun/unsupported_host.affine new file mode 100644 index 00000000..fed773ba --- /dev/null +++ b/tests/codegen-bun/unsupported_host.affine @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MPL-2.0 +// Planted negative control: a Bun capability name without a compiler lowering +// must be rejected, never emitted as an optimistic same-named JavaScript call. + +extern fn bun_unimplemented_operation() -> Int; + +pub fn probe() -> Int { bun_unimplemented_operation() } diff --git a/tests/codegen-deno/bytes_binary_io.deno.js b/tests/codegen-deno/bytes_binary_io.deno.js index 20f5dd1d..4a116af6 100644 --- a/tests/codegen-deno/bytes_binary_io.deno.js +++ b/tests/codegen-deno/bytes_binary_io.deno.js @@ -1,11 +1,6 @@ // 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) ---- @@ -33,7 +28,7 @@ const __as_walkRecursive = (root) => { const out = []; const rec = (dir) => { for (const entry of Deno.readDirSync(dir)) { - const full = (dir.endsWith("/") ? dir : dir + "/") + entry.name; + const full = __as_pathJoin(dir, entry.name); if (entry.isFile) out.push(full); else if (entry.isDirectory) rec(full); } @@ -41,12 +36,18 @@ const __as_walkRecursive = (root) => { rec(root); return out; }; + +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 __as_regexMatch = (s, pat) => new RegExp(pat).test(String(s)); const __as_wasmInstance = (bytes) => new WebAssembly.Instance(new WebAssembly.Module(bytes), { wasi_snapshot_preview1: { fd_write: () => 0 } }).exports; const __as_wasmCall = (exports, name, args) => Number(exports[name](...(args || []))); -// ---- WasmValue (Deno.affine #455 — Tier 1 #5, Option B) ---- +// ---- WasmValue host bindings (#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. @@ -196,7 +197,7 @@ const __as_pixiSoundSetVolume = (s, vol) => { s.volume = vol; return 0; }; const __as_pixiSoundSetLoop = (s, loop) => { s.loop = loop; return 0; }; // ---- Ipc (bindings #9): web-platform MessageChannel/MessagePort ---- // Uses standard web globals (MessageChannel, structuredClone) — no -// consumer-side init required. Available unmodified in Deno, Node 16+, +// consumer-side init required. Available unmodified in modern JS runtimes, // browsers, and Web Workers. const __as_messageChannelNew = () => new MessageChannel(); const __as_messageChannelPort1 = (ch) => ch.port1; @@ -210,7 +211,7 @@ const __as_structuredCloneValue = (v) => structuredClone(v); // ---- Canvas (bindings #8): HTML5 Canvas 2D rendering context ---- // `canvas` arg is the consumer-supplied HTMLCanvasElement; helpers // dispatch directly to the standard CanvasRenderingContext2D -// methods. Available unmodified in browsers, jsdom-under-Deno, +// methods. Available unmodified in browsers and jsdom, // idaptik's WebView host, and any DOM emulator. const __as_canvasGetContext2D = (canvas) => canvas.getContext("2d"); const __as_canvasFillStyle = (ctx, color) => { ctx.fillStyle = color; return 0; }; @@ -279,7 +280,7 @@ const __as_httpHeadersFromResponse = (res) => { 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 +// `HpmJsonValue` is opaque to AffineScript; on direct 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. @@ -332,7 +333,7 @@ const __as_hpmJsonEscapeString = (s) => { // ---- Sqlite (db-theory #1a / stdlib/Sqlite.affine): SQL via host adapter ---- // Host JS environment must expose globalThis.__as_sqlite, a namespace // implementing the small adapter contract below. Consumers init once -// (Deno): +// (host runtime): // import * as s from "jsr:@db/sqlite"; // globalThis.__as_sqlite = { // open: (p) => new s.Database(p), @@ -397,9 +398,7 @@ const __as_dbFinalize = (s) => { globalThis.__as_sqlite.finalize(s); return // ---- Sqlite schema introspection + bulk I/O + error inspection (db-theory #1c) ---- // Five more adapter methods (`schemaTables`, `schemaColumns`, // `tableExists`, `importCsv`, `exportCsv`, `lastError`); each -// real-world adapter (jsr:@db/sqlite, better-sqlite3) backs them with -// a one-liner over `PRAGMA table_info` / a `Database.prepare()` -// iterator / a `fs.writeFileSync(..., csv)` call. +// real-world adapter backs them with a small query or file operation. const __as_dbSchemaTables = (h) => String(globalThis.__as_sqlite.schemaTables(h)); const __as_dbSchemaColumns = (h, table) => String(globalThis.__as_sqlite.schemaColumns(h, table)); const __as_dbTableExists = (h, table) => Boolean(globalThis.__as_sqlite.tableExists(h, table)); diff --git a/tests/codegen-deno/deno_scripting_part2.deno.js b/tests/codegen-deno/deno_scripting_part2.deno.js index f2142169..fdbaa5cf 100644 --- a/tests/codegen-deno/deno_scripting_part2.deno.js +++ b/tests/codegen-deno/deno_scripting_part2.deno.js @@ -1,11 +1,6 @@ // 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) ---- @@ -33,7 +28,7 @@ const __as_walkRecursive = (root) => { const out = []; const rec = (dir) => { for (const entry of Deno.readDirSync(dir)) { - const full = (dir.endsWith("/") ? dir : dir + "/") + entry.name; + const full = __as_pathJoin(dir, entry.name); if (entry.isFile) out.push(full); else if (entry.isDirectory) rec(full); } @@ -41,12 +36,18 @@ const __as_walkRecursive = (root) => { rec(root); return out; }; + +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 __as_regexMatch = (s, pat) => new RegExp(pat).test(String(s)); const __as_wasmInstance = (bytes) => new WebAssembly.Instance(new WebAssembly.Module(bytes), { wasi_snapshot_preview1: { fd_write: () => 0 } }).exports; const __as_wasmCall = (exports, name, args) => Number(exports[name](...(args || []))); -// ---- WasmValue (Deno.affine #455 — Tier 1 #5, Option B) ---- +// ---- WasmValue host bindings (#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. @@ -196,7 +197,7 @@ const __as_pixiSoundSetVolume = (s, vol) => { s.volume = vol; return 0; }; const __as_pixiSoundSetLoop = (s, loop) => { s.loop = loop; return 0; }; // ---- Ipc (bindings #9): web-platform MessageChannel/MessagePort ---- // Uses standard web globals (MessageChannel, structuredClone) — no -// consumer-side init required. Available unmodified in Deno, Node 16+, +// consumer-side init required. Available unmodified in modern JS runtimes, // browsers, and Web Workers. const __as_messageChannelNew = () => new MessageChannel(); const __as_messageChannelPort1 = (ch) => ch.port1; @@ -210,7 +211,7 @@ const __as_structuredCloneValue = (v) => structuredClone(v); // ---- Canvas (bindings #8): HTML5 Canvas 2D rendering context ---- // `canvas` arg is the consumer-supplied HTMLCanvasElement; helpers // dispatch directly to the standard CanvasRenderingContext2D -// methods. Available unmodified in browsers, jsdom-under-Deno, +// methods. Available unmodified in browsers and jsdom, // idaptik's WebView host, and any DOM emulator. const __as_canvasGetContext2D = (canvas) => canvas.getContext("2d"); const __as_canvasFillStyle = (ctx, color) => { ctx.fillStyle = color; return 0; }; @@ -279,7 +280,7 @@ const __as_httpHeadersFromResponse = (res) => { 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 +// `HpmJsonValue` is opaque to AffineScript; on direct 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. @@ -332,7 +333,7 @@ const __as_hpmJsonEscapeString = (s) => { // ---- Sqlite (db-theory #1a / stdlib/Sqlite.affine): SQL via host adapter ---- // Host JS environment must expose globalThis.__as_sqlite, a namespace // implementing the small adapter contract below. Consumers init once -// (Deno): +// (host runtime): // import * as s from "jsr:@db/sqlite"; // globalThis.__as_sqlite = { // open: (p) => new s.Database(p), @@ -397,9 +398,7 @@ const __as_dbFinalize = (s) => { globalThis.__as_sqlite.finalize(s); return // ---- Sqlite schema introspection + bulk I/O + error inspection (db-theory #1c) ---- // Five more adapter methods (`schemaTables`, `schemaColumns`, // `tableExists`, `importCsv`, `exportCsv`, `lastError`); each -// real-world adapter (jsr:@db/sqlite, better-sqlite3) backs them with -// a one-liner over `PRAGMA table_info` / a `Database.prepare()` -// iterator / a `fs.writeFileSync(..., csv)` call. +// real-world adapter backs them with a small query or file operation. const __as_dbSchemaTables = (h) => String(globalThis.__as_sqlite.schemaTables(h)); const __as_dbSchemaColumns = (h, table) => String(globalThis.__as_sqlite.schemaColumns(h, table)); const __as_dbTableExists = (h, table) => Boolean(globalThis.__as_sqlite.tableExists(h, table)); diff --git a/tests/codegen-deno/random_smoke.deno.js b/tests/codegen-deno/random_smoke.deno.js index 2f5f8b2b..f2c1cb32 100644 --- a/tests/codegen-deno/random_smoke.deno.js +++ b/tests/codegen-deno/random_smoke.deno.js @@ -1,11 +1,6 @@ // 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) ---- @@ -33,7 +28,7 @@ const __as_walkRecursive = (root) => { const out = []; const rec = (dir) => { for (const entry of Deno.readDirSync(dir)) { - const full = (dir.endsWith("/") ? dir : dir + "/") + entry.name; + const full = __as_pathJoin(dir, entry.name); if (entry.isFile) out.push(full); else if (entry.isDirectory) rec(full); } @@ -41,12 +36,18 @@ const __as_walkRecursive = (root) => { rec(root); return out; }; + +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 __as_regexMatch = (s, pat) => new RegExp(pat).test(String(s)); const __as_wasmInstance = (bytes) => new WebAssembly.Instance(new WebAssembly.Module(bytes), { wasi_snapshot_preview1: { fd_write: () => 0 } }).exports; const __as_wasmCall = (exports, name, args) => Number(exports[name](...(args || []))); -// ---- WasmValue (Deno.affine #455 — Tier 1 #5, Option B) ---- +// ---- WasmValue host bindings (#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. @@ -196,7 +197,7 @@ const __as_pixiSoundSetVolume = (s, vol) => { s.volume = vol; return 0; }; const __as_pixiSoundSetLoop = (s, loop) => { s.loop = loop; return 0; }; // ---- Ipc (bindings #9): web-platform MessageChannel/MessagePort ---- // Uses standard web globals (MessageChannel, structuredClone) — no -// consumer-side init required. Available unmodified in Deno, Node 16+, +// consumer-side init required. Available unmodified in modern JS runtimes, // browsers, and Web Workers. const __as_messageChannelNew = () => new MessageChannel(); const __as_messageChannelPort1 = (ch) => ch.port1; @@ -210,7 +211,7 @@ const __as_structuredCloneValue = (v) => structuredClone(v); // ---- Canvas (bindings #8): HTML5 Canvas 2D rendering context ---- // `canvas` arg is the consumer-supplied HTMLCanvasElement; helpers // dispatch directly to the standard CanvasRenderingContext2D -// methods. Available unmodified in browsers, jsdom-under-Deno, +// methods. Available unmodified in browsers and jsdom, // idaptik's WebView host, and any DOM emulator. const __as_canvasGetContext2D = (canvas) => canvas.getContext("2d"); const __as_canvasFillStyle = (ctx, color) => { ctx.fillStyle = color; return 0; }; @@ -279,7 +280,7 @@ const __as_httpHeadersFromResponse = (res) => { 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 +// `HpmJsonValue` is opaque to AffineScript; on direct 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. @@ -332,7 +333,7 @@ const __as_hpmJsonEscapeString = (s) => { // ---- Sqlite (db-theory #1a / stdlib/Sqlite.affine): SQL via host adapter ---- // Host JS environment must expose globalThis.__as_sqlite, a namespace // implementing the small adapter contract below. Consumers init once -// (Deno): +// (host runtime): // import * as s from "jsr:@db/sqlite"; // globalThis.__as_sqlite = { // open: (p) => new s.Database(p), @@ -397,9 +398,7 @@ const __as_dbFinalize = (s) => { globalThis.__as_sqlite.finalize(s); return // ---- Sqlite schema introspection + bulk I/O + error inspection (db-theory #1c) ---- // Five more adapter methods (`schemaTables`, `schemaColumns`, // `tableExists`, `importCsv`, `exportCsv`, `lastError`); each -// real-world adapter (jsr:@db/sqlite, better-sqlite3) backs them with -// a one-liner over `PRAGMA table_info` / a `Database.prepare()` -// iterator / a `fs.writeFileSync(..., csv)` call. +// real-world adapter backs them with a small query or file operation. const __as_dbSchemaTables = (h) => String(globalThis.__as_sqlite.schemaTables(h)); const __as_dbSchemaColumns = (h, table) => String(globalThis.__as_sqlite.schemaColumns(h, table)); const __as_dbTableExists = (h, table) => Boolean(globalThis.__as_sqlite.tableExists(h, table)); diff --git a/tools/check-capability-anchors.sh b/tools/check-capability-anchors.sh index 02fc932b..43fc13ef 100755 --- a/tools/check-capability-anchors.sh +++ b/tools/check-capability-anchors.sh @@ -15,8 +15,8 @@ # # Checks: # 1. The matrix exists and carries a "== Test anchors" section. -# 2. Every test/*.ml and test/e2e/fixtures/*.affine path named anywhere in the -# matrix actually exists on disk. +# 2. Every test/*.ml, test/e2e/fixtures/*.affine, and tools/*.sh path named +# anywhere in the matrix actually exists on disk. # # Usage: ./tools/check-capability-anchors.sh # Wired into: just check (via the `guard` recipe) and CI (.github/workflows/ci.yml). @@ -56,7 +56,7 @@ while IFS= read -r path; do fi note " - $path" fi -done < <(grep -oE 'test/[A-Za-z0-9_./-]+\.(ml|affine)' "$MATRIX" | LC_ALL=C sort -u) +done < <(grep -oE '(test/[A-Za-z0-9_./-]+\.(ml|affine)|tools/[A-Za-z0-9_./-]+\.sh)' "$MATRIX" | LC_ALL=C sort -u) if [ "$missing" -eq 1 ]; then note " Either restore the test or update the matrix to its new anchor" note " in the same change. A renamed/deleted test must not leave a" diff --git a/tools/res-to-affine/test/fixtures/sample.res b/tools/res-to-affine/test/fixtures/sample.res new file mode 100644 index 00000000..010afb55 --- /dev/null +++ b/tools/res-to-affine/test/fixtures/sample.res @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MPL-2.0 +// Synthetic fixture exercising every Phase-1 anti-pattern. Not a real +// ReScript program; the scanner is line-based so it doesn't care. + +open Types + +// 1. side-effect import (Pixi sound modules) +let _ = Pixi.Sound.register + +// 2. raw JS escape hatch +let host = %raw(`globalThis.location.host`) + +// 3. mutable global ref + := assignment +let currentUser = ref(None) +currentUser := Some("alice") + +// 4. untyped exception path +let fetchUser = id => { + try { + Some(GitHub.Users.get(id)) + } catch { + | Js.Exn.Error(_) => None + } +} + +// 5. Promise.catch — different shape of the same anti-pattern +let load = () => + api->Promise.catch(e => Js.log(e)) diff --git a/tools/run_codegen_bun_tests.sh b/tools/run_codegen_bun_tests.sh new file mode 100755 index 00000000..32e999c1 --- /dev/null +++ b/tools/run_codegen_bun_tests.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Issue #734 — native Bun-ESM backend acceptance runner. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +TEST_DIR="$ROOT_DIR/tests/codegen-bun" + +if [[ -x "$ROOT_DIR/_build/default/bin/main.exe" ]]; then + COMPILE_CMD=("$ROOT_DIR/_build/default/bin/main.exe" compile) +elif command -v affinescript >/dev/null 2>&1; then + COMPILE_CMD=(affinescript compile) +else + COMPILE_CMD=(dune exec affinescript -- compile) +fi + +command -v bun >/dev/null 2>&1 || { + echo "error: Bun is required for the Bun-ESM acceptance tests" >&2 + exit 1 +} + +src="$TEST_DIR/host_profile.affine" +out="${src%.affine}.bun.js" +"${COMPILE_CMD[@]}" "$src" -o "$out" --bun-esm +if grep -qi 'deno' "$out"; then + echo "error: legacy runtime reference emitted in $(basename "$out")" >&2 + exit 1 +fi +bun --check "$out" +second="$TEST_DIR/reproducibility.bun.js" +"${COMPILE_CMD[@]}" "$src" -o "$second" --bun-esm +cmp "$out" "$second" + +conflict_log="$TEST_DIR/backend-conflict.log" +if "${COMPILE_CMD[@]}" "$src" -o "$out" --deno-esm --bun-esm \ + >"$conflict_log" 2>&1; then + echo "error: conflicting host-profile flags compiled successfully" >&2 + exit 1 +fi +grep -q -- '--deno-esm and --bun-esm are mutually exclusive' "$conflict_log" + +conflict_json="$TEST_DIR/backend-conflict.json" +if "${COMPILE_CMD[@]}" "$src" -o "$out" --deno-esm --bun-esm --json \ + >"$conflict_json" 2>&1; then + echo "error: conflicting host-profile flags passed in JSON mode" >&2 + exit 1 +fi +grep -q '"code":"E0826"' "$conflict_json" +grep -q '"success":false' "$conflict_json" + +for js in "$TEST_DIR"/*.harness.mjs; do + (cd "$TEST_DIR" && AFFINESCRIPT_BUN_PROBE=estate bun "$(basename "$js")" alpha beta) +done + +if "${COMPILE_CMD[@]}" "$TEST_DIR/unsupported_host.affine" \ + -o "$TEST_DIR/unsupported_host.bun.js" --bun-esm; then + echo "error: unsupported Bun host operation compiled successfully" >&2 + exit 1 +fi + +echo "All native Bun-ESM tests passed."