Skip to content

Commit caf11fe

Browse files
hyperpolymathclaude
andcommitted
feat(codegen): Deno-ESM target — direct AST→ES module backend (Refs #122)
Adds `lib/codegen_deno.ml`, a direct AffineScript-AST → ES-module transpiler, plus a `--deno-esm` flag (and `.deno.js` extension) wired into both compile dispatch paths in `bin/main.ml`. Why a direct transpiler, not a wasm-wrapping ESM shim (the handoff's original Phase 1): the motivating consumer (ubicity storage.ts / wasm-bridge.ts) is pure JS-value orchestration — it JSON-stringifies opaque objects, JSON-parses file contents back into JS objects, and returns those to JS callers. AffineScript's wasm ABI is i32-only (Codegen: params/ret all I32); arbitrary JS objects / JSON.stringify cannot cross it. There is essentially no computation that belongs in wasm, so source-to-source emission is the correct tool. Backend behaviour: - `pub fn`/`pub const`/`pub enum` -> `export` (ESM, no require). - `extern fn` -> direct host calls via a lowering table (Deno.*Sync / JSON / WebAssembly / path helpers), inlined so output is genuinely drop-in (no extra package to resolve). - struct + receiver-first free functions -> `export class`: constructor synthesised from the struct-returning fn (record literal -> `this.f = e`), methods from fns whose first param is the struct (receiver -> `this`), cross-method calls rewritten to `this.m(...)`. This is necessary because the current grammar accepts neither inherent `impl Type {}` nor a `self` expression (SELF_KW has no expression production — even stdlib/traits.affine fails to parse), so an "instance method" must be a free function taking the struct first. - Methods emitted `async`: the consumer surface is entirely async and callers `await`; `await` on a synchronously-returned value is valid JS, so the exact API is preserved with no async-extern ABI (#103 not required for this consumer; remains documented future work). - js_reserved trimmed to real ECMAScript reserved words (drops the Java-ism primitives Js_codegen carries: double/int/boolean/... are valid JS identifiers; mangling them would corrupt a consumer's required ESM export surface). Regression: tests/codegen-deno/class_basic.{affine,harness.mjs} + tools/run_codegen_deno_tests.sh + a CI step. The harness runs under Node 20 (CI has no Deno; Phase 1 fixtures are pure logic and the generated module only touches the Deno global lazily in unused helpers). `dune build` clean; `dune runtest` unchanged (the 2 pre-existing E2E Node-CJS `--vscode-extension` failures, #116/#117 territory, exist identically on pristine HEAD and are out of #122 scope). Refs #122, #35, #103. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent d2875a5 commit caf11fe

8 files changed

Lines changed: 931 additions & 6 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ jobs:
4040
- name: Run codegen WASM tests
4141
run: opam exec -- ./tools/run_codegen_wasm_tests.sh
4242

43+
- name: Run codegen Deno-ESM tests (issue #122)
44+
# Compiles tests/codegen-deno/*.affine with the --deno-esm backend
45+
# and runs the *.harness.mjs under Node (CI has Node 20, not Deno;
46+
# the Phase 1 fixtures are pure logic so Node ESM exercises them).
47+
run: opam exec -- ./tools/run_codegen_deno_tests.sh
48+
4349
- name: Run face-transformer regression tests
4450
run: opam exec -- ./tools/run_face_transformer_tests.sh
4551

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,9 @@ htmlcov/
8989
*.bak
9090
*.wasm
9191
/a.out
92+
93+
# issue #122: generated Deno-ESM regression outputs (compiled from the
94+
# committed *.affine fixtures by tools/run_codegen_deno_tests.sh).
95+
/tests/codegen-deno/*.deno.js
96+
# Local-only build workaround (see file header); never committed.
97+
/dune-workspace

bin/main.ml

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -478,7 +478,7 @@ let repl_cmd_fn () =
478478
compilation errors. With [--wasm-gc], targets the WebAssembly GC
479479
proposal instead of WASM 1.0 linear memory. *)
480480
let compile_file face json wasm_gc vscode_ext vscode_adapter vscode_no_lc
481-
path output =
481+
deno_esm path output =
482482
let face = resolve_face ~quiet:json face path in
483483
if json then begin
484484
let diags = ref [] in
@@ -512,8 +512,9 @@ let compile_file face json wasm_gc vscode_ext vscode_adapter vscode_no_lc
512512
use the original [prog] because they handle imports natively
513513
via Codegen.gen_imports / the import section. *)
514514
let flat_prog = Affinescript.Module_loader.flatten_imports loader prog in
515+
let is_deno = deno_esm || Filename.check_suffix output ".deno.js" in
515516
let is_julia = Filename.check_suffix output ".jl" in
516-
let is_js = Filename.check_suffix output ".js" in
517+
let is_js = (not is_deno) && Filename.check_suffix output ".js" in
517518
let is_c = Filename.check_suffix output ".c" in
518519
let is_wgsl = Filename.check_suffix output ".wgsl" in
519520
let is_faust = Filename.check_suffix output ".dsp" in
@@ -534,7 +535,17 @@ let compile_file face json wasm_gc vscode_ext vscode_adapter vscode_no_lc
534535
let is_why3 = Filename.check_suffix output ".mlw" in
535536
let is_lean = Filename.check_suffix output ".lean" in
536537
let is_spirv = Filename.check_suffix output ".spv" in
537-
if is_julia then begin
538+
if is_deno then begin
539+
match Affinescript.Codegen_deno.codegen_deno flat_prog resolve_ctx.symbols with
540+
| Error msg ->
541+
add { severity = Error; code = "E0824";
542+
message = Printf.sprintf "Deno-ESM codegen error: %s" msg;
543+
span = Affinescript.Span.dummy; help = None; labels = [] }
544+
| Ok esm_code ->
545+
let oc = open_out output in
546+
output_string oc esm_code;
547+
close_out oc
548+
end else if is_julia then begin
538549
match Affinescript.Julia_codegen.codegen_julia flat_prog resolve_ctx.symbols with
539550
| Error msg ->
540551
add { severity = Error; code = "E0800";
@@ -725,8 +736,9 @@ let compile_file face json wasm_gc vscode_ext vscode_adapter vscode_no_lc
725736
cross-module imports for backends that don't have native
726737
module-system support. Wasm/Wasm-GC keep the original [prog]. *)
727738
let flat_prog = Affinescript.Module_loader.flatten_imports loader prog in
739+
let is_deno = deno_esm || Filename.check_suffix output ".deno.js" in
728740
let is_julia = Filename.check_suffix output ".jl" in
729-
let is_js = Filename.check_suffix output ".js" in
741+
let is_js = (not is_deno) && Filename.check_suffix output ".js" in
730742
let is_c = Filename.check_suffix output ".c" in
731743
let is_wgsl = Filename.check_suffix output ".wgsl" in
732744
let is_faust = Filename.check_suffix output ".dsp" in
@@ -747,7 +759,18 @@ let compile_file face json wasm_gc vscode_ext vscode_adapter vscode_no_lc
747759
let is_why3 = Filename.check_suffix output ".mlw" in
748760
let is_lean = Filename.check_suffix output ".lean" in
749761
let is_spirv = Filename.check_suffix output ".spv" in
750-
if is_julia then
762+
if is_deno then
763+
(match Affinescript.Codegen_deno.codegen_deno flat_prog resolve_ctx.symbols with
764+
| Error e ->
765+
Format.eprintf "@[<v>Deno-ESM codegen error: %s@]@." e;
766+
`Error (false, "Deno-ESM codegen error")
767+
| Ok esm_code ->
768+
let oc = open_out output in
769+
output_string oc esm_code;
770+
close_out oc;
771+
Format.printf "Compiled %s -> %s (Deno-ESM)@." path output;
772+
`Ok ())
773+
else if is_julia then
751774
(match Affinescript.Julia_codegen.codegen_julia flat_prog resolve_ctx.symbols with
752775
| Error e ->
753776
Format.eprintf "@[<v>Julia codegen error: %s@]@." e;
@@ -1178,6 +1201,20 @@ let vscode_no_lc_arg =
11781201
dependency for extensions that ship no language client; the \
11791202
wiring passes null in its place.")
11801203

1204+
(* Issue #122: --deno-esm. Selects the direct AST -> ES-module backend
1205+
({!Affinescript.Codegen_deno}) regardless of output extension, so a
1206+
drop-in `.js` ES module can be produced (e.g. `-o src/storage.js
1207+
--deno-esm`). A `.deno.js` output extension also routes here without
1208+
the flag, as a convenience for the test corpus / ad-hoc use. *)
1209+
let deno_esm_arg =
1210+
Arg.(value & flag & info ["deno-esm"]
1211+
~doc:"Emit a standalone Deno/Node ES module directly from the AST \
1212+
(issue #122): `export class` for struct+impl, `export` for \
1213+
public fns/consts, and `extern fn` lowered to direct host \
1214+
calls (Deno.*Sync / JSON / WebAssembly). No wasm, no require, \
1215+
no handle table — the output is a drop-in importable ESM. A \
1216+
`.deno.js` output extension selects this backend implicitly.")
1217+
11811218
(** Shared --face flag: select the parser surface-syntax face. *)
11821219
let face_arg =
11831220
let faces = Arg.enum [
@@ -1524,7 +1561,7 @@ let compile_cmd =
15241561
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
15251562
let info = Cmd.info "compile" ~doc in
15261563
Cmd.v info Term.(ret (const compile_file $ face_arg $ json_arg $ wasm_gc_arg
1527-
$ vscode_ext_arg $ vscode_adapter_arg $ vscode_no_lc_arg
1564+
$ vscode_ext_arg $ vscode_adapter_arg $ vscode_no_lc_arg $ deno_esm_arg
15281565
$ path_arg $ output_arg))
15291566

15301567
let fmt_cmd =

0 commit comments

Comments
 (0)