From 3a65ad05a0b52fd5a9f8ecc3b91b479d5db0d5bf Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 20 Apr 2026 20:48:32 +0300 Subject: [PATCH 1/2] Add codegen/1, codegen!/1, and bind/2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - codegen: AST map → JavaScript source via OXC's Codegen - bind: substitute $placeholders in parsed AST - Rust NIF reads BEAM terms directly (no serde roundtrip) - Supports all ESTree node types: statements, expressions, declarations, patterns, modules, classes, template literals --- lib/oxc.ex | 107 +++ lib/oxc/native.ex | 3 + native/oxc_ex_nif/src/codegen.rs | 1069 ++++++++++++++++++++++++++++++ native/oxc_ex_nif/src/lib.rs | 1 + test/codegen_test.exs | 260 ++++++++ 5 files changed, 1440 insertions(+) create mode 100644 native/oxc_ex_nif/src/codegen.rs create mode 100644 test/codegen_test.exs diff --git a/lib/oxc.ex b/lib/oxc.ex index 89a2290..e0c8e84 100644 --- a/lib/oxc.ex +++ b/lib/oxc.ex @@ -511,6 +511,113 @@ defmodule OXC do # ── AST Traversal ── + # ── Codegen ── + + @doc """ + Generate JavaScript source code from an AST map. + + Takes an ESTree AST (as returned by `parse/2` or constructed manually) + and produces formatted JavaScript source code using OXC's code generator. + + Handles operator precedence, indentation, and semicolon insertion. + + ## Examples + + iex> ast = OXC.parse!("const x = 1 + 2", "test.js") + iex> {:ok, js} = OXC.codegen(ast) + iex> js =~ "const x = 1 + 2" + true + + iex> ast = %{type: :program, body: [ + ...> %{type: :variable_declaration, kind: :const, declarations: [ + ...> %{type: :variable_declarator, + ...> id: %{type: :identifier, name: "x"}, + ...> init: %{type: :literal, value: 42}} + ...> ]} + ...> ]} + iex> {:ok, js} = OXC.codegen(ast) + iex> js =~ "const x = 42" + true + """ + @spec codegen(ast()) :: {:ok, String.t()} | {:error, [error()]} + def codegen(ast) do + case OXC.Native.codegen(deatomize_ast(ast)) do + {:ok, code} -> {:ok, code} + {:error, errors} -> {:error, atomize_term_keys(errors)} + end + end + + @doc """ + Like `codegen/1` but raises on errors. + """ + @spec codegen!(ast()) :: String.t() + def codegen!(ast) do + case codegen(ast) do + {:ok, code} -> code + {:error, errors} -> raise Error, message: "OXC codegen error: #{inspect(errors)}", errors: errors + end + end + + @doc """ + Substitute `$placeholders` in an AST with provided values. + + Walks the AST and replaces any identifier node whose name starts with `$` + with the corresponding value from `bindings`. + + Binding values can be: + * A string — replaced as an identifier name + * `{:literal, value}` — replaced with a literal node + * A map with `:type` — spliced as a raw AST node + + ## Examples + + iex> {:ok, ast} = OXC.parse("const x = $value", "t.js") + iex> ast = OXC.bind(ast, value: {:literal, 42}) + iex> OXC.codegen!(ast) =~ "const x = 42" + true + + iex> {:ok, ast} = OXC.parse("const $name = 1", "t.js") + iex> ast = OXC.bind(ast, name: "myVar") + iex> OXC.codegen!(ast) =~ "const myVar = 1" + true + """ + @spec bind(ast(), keyword()) :: ast() + def bind(ast, bindings) when is_list(bindings) do + lookup = Map.new(bindings, fn {k, v} -> {"$#{k}", v} end) + + postwalk(ast, fn + %{type: :identifier, name: "$" <> _ = name} = node -> + case Map.get(lookup, name) do + nil -> node + value when is_binary(value) -> %{node | name: value} + {:literal, lit} -> %{type: :literal, value: lit} + %{type: _} = ast_node -> ast_node + end + + node -> + node + end) + end + + # Convert atom keys/values back to strings for the Rust NIF + defp deatomize_ast(map) when is_map(map) do + Map.new(map, fn {key, value} -> + str_key = if is_atom(key), do: deatomize_key(key), else: key + {str_key, deatomize_value(key, value)} + end) + end + + defp deatomize_ast(list) when is_list(list), do: Enum.map(list, &deatomize_ast/1) + defp deatomize_ast(value), do: value + + defp deatomize_key(:super_class), do: :superClass + defp deatomize_key(key), do: key + + defp deatomize_value(:type, value) when is_atom(value), do: value + defp deatomize_value(:kind, value) when is_atom(value), do: value + defp deatomize_value(_key, value), do: deatomize_ast(value) + + @doc """ Walk an AST tree, calling `fun` on every node (any map with a `:type` key). diff --git a/lib/oxc/native.ex b/lib/oxc/native.ex index a01250b..d12cf56 100644 --- a/lib/oxc/native.ex +++ b/lib/oxc/native.ex @@ -45,4 +45,7 @@ defmodule OXC.Native do @spec transform_many([{String.t(), String.t()}], map()) :: list() def transform_many(_inputs, _opts), do: :erlang.nif_error(:nif_not_loaded) + + @spec codegen(map()) :: {:ok, String.t()} | {:error, list()} + def codegen(_ast), do: :erlang.nif_error(:nif_not_loaded) end diff --git a/native/oxc_ex_nif/src/codegen.rs b/native/oxc_ex_nif/src/codegen.rs new file mode 100644 index 0000000..5eb8fcc --- /dev/null +++ b/native/oxc_ex_nif/src/codegen.rs @@ -0,0 +1,1069 @@ +use std::cell::Cell; + +use oxc_allocator::{Allocator, Box as OxcBox, Vec as OxcVec}; +use oxc_ast::{ast::*, AstBuilder, NONE}; +use oxc_codegen::{Codegen, CodegenReturn}; +use oxc_span::{Atom, SourceType, SPAN}; +use oxc_syntax::{ + node::NodeId, + number::{BigintBase, NumberBase}, + operator::{ + AssignmentOperator, BinaryOperator, LogicalOperator, UnaryOperator, UpdateOperator, + }, +}; +use rustler::{Encoder, Env, NifResult, Term}; + +use crate::atoms; +use crate::error::error_to_term; + +mod a { + rustler::atoms! { + r#type = "type", + block, body, expression, argument, arguments, left, right, operator, + test, consequent, alternate, init, update, object, callee, + optional, computed, name, value, raw, cooked, tail, + declarations, kind, id, params, rest, generator, + async_field = "async", await_field = "await", + source, specifiers, imported, exported, local, declaration, + properties, elements, key, shorthand, method, quasis, expressions, + tag, quasi, prefix, delegate, label, cases, handler, param, + finalizer, discriminant, super_class = "superClass", + meta, property, regex, pattern, flags, bigint, + static_field = "static", + + // node types + program, expression_statement, block_statement, return_statement, + variable_declaration, function_declaration, class_declaration, + if_statement, for_statement, for_in_statement, for_of_statement, + while_statement, do_while_statement, switch_statement, try_statement, + throw_statement, break_statement, continue_statement, + labeled_statement, empty_statement, debugger_statement, with_statement, + import_declaration, export_named_declaration, + export_default_declaration, export_all_declaration, + + identifier, identifier_reference, literal, + numeric_literal, string_literal, boolean_literal, null_literal, + big_int_literal, reg_exp_literal, + binary_expression, logical_expression, unary_expression, + update_expression, assignment_expression, conditional_expression, + call_expression, new_expression, + member_expression, static_member_expression, computed_member_expression, + chain_expression, object_expression, array_expression, + arrow_function_expression, function_expression, class_expression, + template_literal, tagged_template_expression, + sequence_expression, this_expression, + super_expr = "super", + await_expression, yield_expression, import_expression, + meta_property, parenthesized_expression, + spread_element, rest_element, + object_pattern, array_pattern, assignment_pattern, + import_specifier, import_default_specifier, import_namespace_specifier, + export_specifier, + method_definition, property_definition, static_block, + } +} + +type R = Result; + +fn err(msg: impl Into) -> R { + Err(msg.into()) +} + +// ── Term helpers ── + +fn get<'a>(term: Term<'a>, key: rustler::Atom) -> Option> { + term.map_get(key).ok() +} + +fn is_nil(term: Term) -> bool { + term.is_atom() && term.atom_to_string().ok().as_deref() == Some("nil") +} + +fn opt<'a>(term: Term<'a>, key: rustler::Atom) -> Option> { + get(term, key).filter(|t| !is_nil(*t)) +} + +fn str_val<'a>(term: Term<'a>, key: rustler::Atom) -> String { + match get(term, key) { + Some(t) => { + if let Ok(s) = t.decode::() { + s + } else if let Ok(s) = t.atom_to_string() { + s + } else { + String::new() + } + } + None => String::new(), + } +} + +fn bool_val(term: Term, key: rustler::Atom) -> bool { + get(term, key).and_then(|t| t.decode::().ok()).unwrap_or(false) +} + +fn f64_val(term: Term, key: rustler::Atom) -> f64 { + get(term, key) + .and_then(|t| t.decode::().ok().or_else(|| t.decode::().ok().map(|i| i as f64))) + .unwrap_or(0.0) +} + +fn list_val<'a>(term: Term<'a>, key: rustler::Atom) -> Vec> { + get(term, key).and_then(|t| t.decode::>().ok()).unwrap_or_default() +} + +fn type_atom(term: Term) -> Option { + get(term, a::r#type()).and_then(|t| t.decode::().ok()) +} + +fn type_eq(term: Term, expected: rustler::Atom) -> bool { + type_atom(term) == Some(expected) +} + +fn type_str(term: Term) -> String { + get(term, a::r#type()) + .and_then(|t| t.atom_to_string().ok()) + .unwrap_or_else(|| "".into()) +} + +fn nid() -> Cell { + Cell::new(NodeId::DUMMY) +} + +fn atom<'a>(b: AstBuilder<'a>, s: &str) -> Atom<'a> { + Atom::from(b.str(s)) +} + +fn ident_name<'a>(b: AstBuilder<'a>, s: &str) -> IdentifierName<'a> { + IdentifierName { node_id: nid(), span: SPAN, name: b.str(s).into() } +} + +fn str_lit<'a>(b: AstBuilder<'a>, s: &str) -> StringLiteral<'a> { + StringLiteral { node_id: nid(), span: SPAN, value: atom(b, s), raw: None, lone_surrogates: false } +} + +fn opt_binding_id<'a>(b: AstBuilder<'a>, term: Term) -> Option> { + opt(term, a::id()).map(|t| { + let n = str_val(t, a::name()); + b.binding_identifier(SPAN, b.str(&n)) + }) +} + +fn static_member<'a>(b: AstBuilder<'a>, object: Expression<'a>, prop: &str, optional: bool) -> Expression<'a> { + Expression::StaticMemberExpression(b.alloc(b.static_member_expression(SPAN, object, ident_name(b, prop), optional))) +} + +fn computed_member<'a>(b: AstBuilder<'a>, object: Expression<'a>, prop: Expression<'a>, optional: bool) -> Expression<'a> { + Expression::ComputedMemberExpression(b.alloc(b.computed_member_expression(SPAN, object, prop, optional))) +} + +// ── NIF entry point ── + +#[rustler::nif(schedule = "DirtyCpu")] +pub fn codegen<'a>(env: Env<'a>, ast: Term<'a>) -> NifResult> { + let allocator = Allocator::default(); + let b = AstBuilder::new(&allocator); + + match build_program(b, ast) { + Ok(program) => { + let CodegenReturn { code, .. } = Codegen::new().build(&program); + Ok((atoms::ok(), code).encode(env)) + } + Err(msg) => error_to_term(env, &[msg]), + } +} + +// ── Program ── + +fn build_program<'a>(b: AstBuilder<'a>, term: Term) -> R> { + let body = build_stmts(b, list_val(term, a::body()))?; + Ok(b.program(SPAN, SourceType::mjs(), b.str(""), b.vec(), None, b.vec(), body)) +} + +// ── Statements ── + +fn build_stmt<'a>(b: AstBuilder<'a>, term: Term) -> R> { + let ty = type_atom(term).ok_or_else(|| format!("Missing :type on statement"))?; + + if ty == a::expression_statement() { + return Ok(b.statement_expression(SPAN, build_expr(b, get(term, a::expression()).ok_or("Missing :expression")?)?)); + } + if ty == a::block_statement() { + return Ok(b.statement_block(SPAN, build_stmts(b, list_val(term, a::body()))?)); + } + if ty == a::return_statement() { + return Ok(b.statement_return(SPAN, opt_expr(b, term, a::argument())?)); + } + if ty == a::throw_statement() { + return Ok(b.statement_throw(SPAN, build_expr(b, get(term, a::argument()).ok_or("Missing :argument")?)?)); + } + if ty == a::empty_statement() { return Ok(b.statement_empty(SPAN)); } + if ty == a::debugger_statement() { return Ok(b.statement_debugger(SPAN)); } + + if ty == a::variable_declaration() { return Ok(Statement::from(build_var_decl(b, term)?)); } + if ty == a::function_declaration() { return Ok(Statement::from(build_fn_decl(b, term)?)); } + if ty == a::class_declaration() { return Ok(Statement::from(build_class_decl(b, term)?)); } + + if ty == a::if_statement() { + let test = build_expr(b, get(term, a::test()).ok_or("Missing :test")?)?; + let cons = build_stmt(b, get(term, a::consequent()).ok_or("Missing :consequent")?)?; + let alt = match opt(term, a::alternate()) { + Some(t) => Some(build_stmt(b, t)?), + None => None, + }; + return Ok(b.statement_if(SPAN, test, cons, alt)); + } + if ty == a::for_statement() { + let init = match opt(term, a::init()) { + Some(t) if type_eq(t, a::variable_declaration()) => + Some(ForStatementInit::VariableDeclaration(build_var_decl_boxed(b, t)?)), + Some(t) => Some(ForStatementInit::from(build_expr(b, t)?)), + None => None, + }; + let test = opt_expr(b, term, a::test())?; + let update = opt_expr(b, term, a::update())?; + let body = build_stmt(b, get(term, a::body()).ok_or("Missing :body")?)?; + return Ok(b.statement_for(SPAN, init, test, update, body)); + } + if ty == a::for_in_statement() { + let left = build_for_left(b, get(term, a::left()).ok_or("Missing :left")?)?; + let right = build_expr(b, get(term, a::right()).ok_or("Missing :right")?)?; + let body = build_stmt(b, get(term, a::body()).ok_or("Missing :body")?)?; + return Ok(b.statement_for_in(SPAN, left, right, body)); + } + if ty == a::for_of_statement() { + let aw = bool_val(term, a::await_field()); + let left = build_for_left(b, get(term, a::left()).ok_or("Missing :left")?)?; + let right = build_expr(b, get(term, a::right()).ok_or("Missing :right")?)?; + let body = build_stmt(b, get(term, a::body()).ok_or("Missing :body")?)?; + return Ok(b.statement_for_of(SPAN, aw, left, right, body)); + } + if ty == a::while_statement() { + let test = build_expr(b, get(term, a::test()).ok_or("Missing :test")?)?; + let body = build_stmt(b, get(term, a::body()).ok_or("Missing :body")?)?; + return Ok(b.statement_while(SPAN, test, body)); + } + if ty == a::do_while_statement() { + let body = build_stmt(b, get(term, a::body()).ok_or("Missing :body")?)?; + let test = build_expr(b, get(term, a::test()).ok_or("Missing :test")?)?; + return Ok(b.statement_do_while(SPAN, body, test)); + } + if ty == a::switch_statement() { + let disc = build_expr(b, get(term, a::discriminant()).ok_or("Missing :discriminant")?)?; + let cases_list = list_val(term, a::cases()); + let mut cases = b.vec_with_capacity(cases_list.len()); + for c in &cases_list { + let test = opt_expr(b, *c, a::test())?; + let cons = build_stmts(b, list_val(*c, a::consequent()))?; + cases.push(b.switch_case(SPAN, test, cons)); + } + return Ok(b.statement_switch(SPAN, disc, cases)); + } + if ty == a::try_statement() { + let block_term = opt(term, a::block()).or_else(|| opt(term, a::body())).ok_or("Missing try body")?; + let block = build_block(b, block_term)?; + let handler = match opt(term, a::handler()) { + Some(h) => { + let param = match opt(h, a::param()) { + Some(p) => Some(CatchParameter { + node_id: nid(), span: SPAN, + pattern: build_binding_pat(b, p)?, + type_annotation: None, + }), + None => None, + }; + let hbody = build_block(b, get(h, a::body()).ok_or("Missing catch body")?)?; + Some(b.catch_clause(SPAN, param, hbody)) + } + None => None, + }; + let finalizer = match opt(term, a::finalizer()) { + Some(f) => Some(build_block(b, f)?), + None => None, + }; + return Ok(b.statement_try(SPAN, block, handler, finalizer)); + } + if ty == a::break_statement() { + return Ok(b.statement_break(SPAN, opt_label(b, term))); + } + if ty == a::continue_statement() { + return Ok(b.statement_continue(SPAN, opt_label(b, term))); + } + if ty == a::labeled_statement() { + let lt = get(term, a::label()).ok_or("Missing :label")?; + let label = LabelIdentifier { node_id: nid(), span: SPAN, name: b.str(&str_val(lt, a::name())).into() }; + let body = build_stmt(b, get(term, a::body()).ok_or("Missing :body")?)?; + return Ok(b.statement_labeled(SPAN, label, body)); + } + if ty == a::with_statement() { + let obj = build_expr(b, get(term, a::object()).ok_or("Missing :object")?)?; + let body = build_stmt(b, get(term, a::body()).ok_or("Missing :body")?)?; + return Ok(b.statement_with(SPAN, obj, body)); + } + if ty == a::import_declaration() { return Ok(Statement::from(build_import(b, term)?)); } + if ty == a::export_named_declaration() { return Ok(Statement::from(build_export_named(b, term)?)); } + if ty == a::export_default_declaration() { return Ok(Statement::from(build_export_default(b, term)?)); } + if ty == a::export_all_declaration() { return Ok(Statement::from(build_export_all(b, term)?)); } + + err(format!("Unsupported statement: {}", type_str(term))) +} + +fn build_stmts<'a>(b: AstBuilder<'a>, list: Vec) -> R>> { + let mut out = b.vec_with_capacity(list.len()); + for t in &list { out.push(build_stmt(b, *t)?); } + Ok(out) +} + +fn build_block<'a>(b: AstBuilder<'a>, term: Term) -> R> { + Ok(BlockStatement { node_id: nid(), span: SPAN, body: build_stmts(b, list_val(term, a::body()))?, scope_id: Default::default() }) +} + +// ── Expressions ── + +fn build_expr<'a>(b: AstBuilder<'a>, term: Term) -> R> { + let ty = type_atom(term).ok_or_else(|| format!("Missing :type on expression"))?; + + if ty == a::identifier() || ty == a::identifier_reference() { + let n = str_val(term, a::name()); + return Ok(b.expression_identifier(SPAN, b.str(&n))); + } + if ty == a::literal() { return build_generic_lit(b, term); } + if ty == a::numeric_literal() { return Ok(b.expression_numeric_literal(SPAN, f64_val(term, a::value()), None, NumberBase::Decimal)); } + if ty == a::string_literal() { let s = str_val(term, a::value()); return Ok(b.expression_string_literal(SPAN, atom(b, &s), None)); } + if ty == a::boolean_literal() { return Ok(b.expression_boolean_literal(SPAN, bool_val(term, a::value()))); } + if ty == a::null_literal() { return Ok(b.expression_null_literal(SPAN)); } + if ty == a::big_int_literal() { let v = str_val(term, a::value()); return Ok(b.expression_big_int_literal(SPAN, atom(b, &v), None, BigintBase::Decimal)); } + if ty == a::reg_exp_literal() { return build_regexp(b, term); } + + if ty == a::binary_expression() { + let l = build_expr(b, get(term, a::left()).ok_or("Missing :left")?)?; + let op = parse_bin_op(&str_val(term, a::operator()))?; + let r = build_expr(b, get(term, a::right()).ok_or("Missing :right")?)?; + return Ok(b.expression_binary(SPAN, l, op, r)); + } + if ty == a::logical_expression() { + let l = build_expr(b, get(term, a::left()).ok_or("Missing :left")?)?; + let op = parse_log_op(&str_val(term, a::operator()))?; + let r = build_expr(b, get(term, a::right()).ok_or("Missing :right")?)?; + return Ok(b.expression_logical(SPAN, l, op, r)); + } + if ty == a::unary_expression() { + let op = parse_unary_op(&str_val(term, a::operator()))?; + return Ok(b.expression_unary(SPAN, op, build_expr(b, get(term, a::argument()).ok_or("Missing :argument")?)?)); + } + if ty == a::update_expression() { + let op = parse_update_op(&str_val(term, a::operator()))?; + let prefix = bool_val(term, a::prefix()); + let arg = build_simple_target(b, get(term, a::argument()).ok_or("Missing :argument")?)?; + return Ok(b.expression_update(SPAN, op, prefix, arg)); + } + if ty == a::assignment_expression() { + let op = parse_assign_op(&str_val(term, a::operator()))?; + let l = build_assign_target(b, get(term, a::left()).ok_or("Missing :left")?)?; + let r = build_expr(b, get(term, a::right()).ok_or("Missing :right")?)?; + return Ok(b.expression_assignment(SPAN, op, l, r)); + } + if ty == a::conditional_expression() { + let test = build_expr(b, get(term, a::test()).ok_or("Missing :test")?)?; + let cons = build_expr(b, get(term, a::consequent()).ok_or("Missing :consequent")?)?; + let alt = build_expr(b, get(term, a::alternate()).ok_or("Missing :alternate")?)?; + return Ok(b.expression_conditional(SPAN, test, cons, alt)); + } + if ty == a::call_expression() { + let callee = build_expr(b, get(term, a::callee()).ok_or("Missing :callee")?)?; + let args = build_args(b, list_val(term, a::arguments()))?; + return Ok(b.expression_call(SPAN, callee, NONE, args, bool_val(term, a::optional()))); + } + if ty == a::new_expression() { + let callee = build_expr(b, get(term, a::callee()).ok_or("Missing :callee")?)?; + let args = build_args(b, list_val(term, a::arguments()))?; + return Ok(b.expression_new(SPAN, callee, NONE, args)); + } + if ty == a::member_expression() || ty == a::static_member_expression() { + return build_member(b, term); + } + if ty == a::computed_member_expression() { + let obj = build_expr(b, get(term, a::object()).ok_or("Missing :object")?)?; + let prop = build_expr(b, get(term, a::expression()).or_else(|| get(term, a::property())).ok_or("Missing prop")?)?; + return Ok(computed_member(b, obj, prop, bool_val(term, a::optional()))); + } + if ty == a::chain_expression() { + let inner = get(term, a::expression()).ok_or("Missing chain :expression")?; + return build_chain(b, inner); + } + if ty == a::object_expression() { + return Ok(b.expression_object(SPAN, build_obj_props(b, list_val(term, a::properties()))?)); + } + if ty == a::array_expression() { + let elems_list = list_val(term, a::elements()); + let mut elems = b.vec_with_capacity(elems_list.len()); + for e in &elems_list { + if is_nil(*e) { + elems.push(ArrayExpressionElement::Elision(Elision { node_id: nid(), span: SPAN })); + } else if type_eq(*e, a::spread_element()) { + let arg = build_expr(b, get(*e, a::argument()).ok_or("Missing spread :argument")?)?; + elems.push(ArrayExpressionElement::SpreadElement(b.alloc(b.spread_element(SPAN, arg)))); + } else { + elems.push(ArrayExpressionElement::from(build_expr(b, *e)?)); + } + } + return Ok(b.expression_array(SPAN, elems)); + } + if ty == a::arrow_function_expression() { + let is_async = bool_val(term, a::async_field()); + let is_expr = bool_val(term, a::expression()); + let params = build_params(b, term)?; + let body_term = get(term, a::body()).ok_or("Missing arrow body")?; + let body = if is_expr && !type_eq(body_term, a::block_statement()) { + let expr = build_expr(b, body_term)?; + b.function_body(SPAN, b.vec(), b.vec1(b.statement_expression(SPAN, expr))) + } else { + build_fn_body(b, body_term)? + }; + return Ok(b.expression_arrow_function(SPAN, is_expr, is_async, NONE, params, NONE, body)); + } + if ty == a::function_expression() { + let id = opt_binding_id(b, term); + let params = build_params(b, term)?; + let body = build_fn_body(b, get(term, a::body()).ok_or("Missing fn body")?)?; + return Ok(b.expression_function( + SPAN, FunctionType::FunctionExpression, id, + bool_val(term, a::generator()), bool_val(term, a::async_field()), + false, NONE, NONE, params, NONE, Some(b.alloc(body)), + )); + } + if ty == a::class_expression() { + let id = opt_binding_id(b, term); + let sc = opt_expr(b, term, a::super_class())?; + let body = build_class_body(b, get(term, a::body()).ok_or("Missing class body")?)?; + return Ok(b.expression_class(SPAN, ClassType::ClassExpression, b.vec(), id, NONE, sc, NONE, b.vec(), body, false, false)); + } + if ty == a::template_literal() { return build_template(b, term); } + if ty == a::tagged_template_expression() { + let tag = build_expr(b, get(term, a::tag()).ok_or("Missing :tag")?)?; + let qt = get(term, a::quasi()).ok_or("Missing :quasi")?; + let quasis = build_quasis(b, list_val(qt, a::quasis()))?; + let exprs = build_exprs(b, list_val(qt, a::expressions()))?; + return Ok(b.expression_tagged_template(SPAN, tag, NONE, b.template_literal(SPAN, quasis, exprs))); + } + if ty == a::sequence_expression() { + return Ok(b.expression_sequence(SPAN, build_exprs(b, list_val(term, a::expressions()))?)); + } + if ty == a::this_expression() { return Ok(b.expression_this(SPAN)); } + if ty == a::super_expr() { + return Ok(Expression::Super(b.alloc(Super { node_id: nid(), span: SPAN }))); + } + if ty == a::await_expression() { + return Ok(b.expression_await(SPAN, build_expr(b, get(term, a::argument()).ok_or("Missing :argument")?)?)); + } + if ty == a::yield_expression() { + return Ok(b.expression_yield(SPAN, bool_val(term, a::delegate()), opt_expr(b, term, a::argument())?)); + } + if ty == a::import_expression() { + return Ok(b.expression_import(SPAN, build_expr(b, get(term, a::source()).ok_or("Missing :source")?)?, None, None)); + } + if ty == a::meta_property() { + let m = ident_name(b, &str_val(get(term, a::meta()).unwrap_or(term), a::name())); + let p = ident_name(b, &str_val(get(term, a::property()).unwrap_or(term), a::name())); + return Ok(b.expression_meta_property(SPAN, m, p)); + } + if ty == a::parenthesized_expression() { + return Ok(b.expression_parenthesized(SPAN, build_expr(b, get(term, a::expression()).ok_or("Missing :expression")?)?)); + } + + err(format!("Unsupported expression: {}", type_str(term))) +} + +fn opt_expr<'a>(b: AstBuilder<'a>, term: Term, key: rustler::Atom) -> R>> { + match opt(term, key) { Some(t) => Ok(Some(build_expr(b, t)?)), None => Ok(None) } +} + +fn build_exprs<'a>(b: AstBuilder<'a>, list: Vec) -> R>> { + let mut out = b.vec_with_capacity(list.len()); + for t in &list { out.push(build_expr(b, *t)?); } + Ok(out) +} + +// ── Literals ── + +fn build_generic_lit<'a>(b: AstBuilder<'a>, term: Term) -> R> { + if let Some(rx) = opt(term, a::regex()) { return build_regexp_from(b, rx); } + if opt(term, a::bigint()).is_some() { + let v = str_val(term, a::bigint()); + return Ok(b.expression_big_int_literal(SPAN, atom(b, &v), None, BigintBase::Decimal)); + } + match get(term, a::value()) { + None => Ok(b.expression_null_literal(SPAN)), + Some(t) if is_nil(t) => Ok(b.expression_null_literal(SPAN)), + Some(t) => { + if let Ok(v) = t.decode::() { return Ok(b.expression_boolean_literal(SPAN, v)); } + if let Ok(v) = t.decode::() { return Ok(b.expression_numeric_literal(SPAN, v, None, NumberBase::Decimal)); } + if let Ok(v) = t.decode::() { return Ok(b.expression_numeric_literal(SPAN, v as f64, None, NumberBase::Decimal)); } + if let Ok(v) = t.decode::() { return Ok(b.expression_string_literal(SPAN, atom(b, &v), None)); } + Ok(b.expression_null_literal(SPAN)) + } + } +} + +fn build_regexp<'a>(b: AstBuilder<'a>, term: Term) -> R> { + build_regexp_from(b, get(term, a::regex()).unwrap_or(term)) +} + +fn build_regexp_from<'a>(b: AstBuilder<'a>, rx: Term) -> R> { + let pat = str_val(rx, a::pattern()); + let fl = str_val(rx, a::flags()); + Ok(Expression::RegExpLiteral(b.alloc(RegExpLiteral { + node_id: nid(), span: SPAN, raw: None, + regex: RegExp { + pattern: RegExpPattern { text: atom(b, &pat), pattern: None }, + flags: parse_regex_flags(&fl), + }, + }))) +} + +// ── Member / Chain ── + +fn build_member<'a>(b: AstBuilder<'a>, term: Term) -> R> { + let obj = build_expr(b, get(term, a::object()).ok_or("Missing :object")?)?; + let optional = bool_val(term, a::optional()); + if bool_val(term, a::computed()) { + let prop = build_expr(b, get(term, a::property()).ok_or("Missing :property")?)?; + Ok(computed_member(b, obj, prop, optional)) + } else { + let pn = str_val(get(term, a::property()).ok_or("Missing :property")?, a::name()); + Ok(static_member(b, obj, &pn, optional)) + } +} + +fn build_chain<'a>(b: AstBuilder<'a>, inner: Term) -> R> { + let inner_ty = type_atom(inner).ok_or("Missing chain inner type")?; + let elem = if inner_ty == a::call_expression() { + let callee = build_expr(b, get(inner, a::callee()).ok_or("Missing :callee")?)?; + let args = build_args(b, list_val(inner, a::arguments()))?; + ChainElement::CallExpression(b.alloc(CallExpression { + node_id: nid(), span: SPAN, callee, type_arguments: None, + arguments: args, optional: bool_val(inner, a::optional()), pure: false, + })) + } else { + let obj = build_expr(b, get(inner, a::object()).ok_or("Missing :object")?)?; + let optional = bool_val(inner, a::optional()); + if bool_val(inner, a::computed()) { + let prop = build_expr(b, get(inner, a::property()).ok_or("Missing :property")?)?; + ChainElement::ComputedMemberExpression(b.alloc(b.computed_member_expression(SPAN, obj, prop, optional))) + } else { + let pn = str_val(get(inner, a::property()).ok_or("Missing :property")?, a::name()); + ChainElement::StaticMemberExpression(b.alloc(b.static_member_expression(SPAN, obj, ident_name(b, &pn), optional))) + } + }; + Ok(b.expression_chain(SPAN, elem)) +} + +// ── Template literals ── + +fn build_template<'a>(b: AstBuilder<'a>, term: Term) -> R> { + let quasis = build_quasis(b, list_val(term, a::quasis()))?; + let exprs = build_exprs(b, list_val(term, a::expressions()))?; + Ok(b.expression_template_literal(SPAN, quasis, exprs)) +} + +fn build_quasis<'a>(b: AstBuilder<'a>, list: Vec) -> R>> { + let mut out = b.vec_with_capacity(list.len()); + for q in &list { + let vt = get(*q, a::value()).unwrap_or(*q); + let raw = str_val(vt, a::raw()); + let cooked = opt(vt, a::cooked()).and_then(|t| t.decode::().ok()); + let tail = bool_val(*q, a::tail()); + out.push(b.template_element(SPAN, TemplateElementValue { + raw: atom(b, &raw), + cooked: cooked.as_deref().map(|s| atom(b, s)), + }, tail, false)); + } + Ok(out) +} + +// ── Declarations ── + +fn build_var_decl<'a>(b: AstBuilder<'a>, term: Term) -> R> { + let kind = match str_val(term, a::kind()).as_str() { + "let" => VariableDeclarationKind::Let, + "var" => VariableDeclarationKind::Var, + "using" => VariableDeclarationKind::Using, + "await_using" | "await using" => VariableDeclarationKind::AwaitUsing, + _ => VariableDeclarationKind::Const, + }; + let dl = list_val(term, a::declarations()); + let mut decls = b.vec_with_capacity(dl.len()); + for d in &dl { + let id = build_binding_pat(b, get(*d, a::id()).ok_or("Missing declarator :id")?)?; + let init = opt_expr(b, *d, a::init())?; + decls.push(b.variable_declarator(SPAN, kind, id, NONE, init, false)); + } + Ok(b.declaration_variable(SPAN, kind, decls, false)) +} + +fn build_var_decl_boxed<'a>(b: AstBuilder<'a>, term: Term) -> R>> { + let kind = match str_val(term, a::kind()).as_str() { + "let" => VariableDeclarationKind::Let, + "var" => VariableDeclarationKind::Var, + "using" => VariableDeclarationKind::Using, + "await_using" | "await using" => VariableDeclarationKind::AwaitUsing, + _ => VariableDeclarationKind::Const, + }; + let dl = list_val(term, a::declarations()); + let mut decls = b.vec_with_capacity(dl.len()); + for d in &dl { + let id = build_binding_pat(b, get(*d, a::id()).ok_or("Missing declarator :id")?)?; + let init = opt_expr(b, *d, a::init())?; + decls.push(b.variable_declarator(SPAN, kind, id, NONE, init, false)); + } + Ok(b.alloc(b.variable_declaration(SPAN, kind, decls, false))) +} + +fn build_fn_decl<'a>(b: AstBuilder<'a>, term: Term) -> R> { + let id = opt_binding_id(b, term); + let params = build_params(b, term)?; + let body = build_fn_body(b, get(term, a::body()).ok_or("Missing fn body")?)?; + Ok(b.declaration_function( + SPAN, FunctionType::FunctionDeclaration, id, + bool_val(term, a::generator()), bool_val(term, a::async_field()), + false, NONE, NONE, params, NONE, Some(b.alloc(body)), + )) +} + +fn build_class_decl<'a>(b: AstBuilder<'a>, term: Term) -> R> { + let id = opt_binding_id(b, term); + let sc = opt_expr(b, term, a::super_class())?; + let body = build_class_body(b, get(term, a::body()).ok_or("Missing class body")?)?; + Ok(b.declaration_class(SPAN, ClassType::ClassDeclaration, b.vec(), id, NONE, sc, NONE, b.vec(), body, false, false)) +} + +// ── Class body ── + +fn build_class_body<'a>(b: AstBuilder<'a>, term: Term) -> R> { + let bl = list_val(term, a::body()); + let mut elems = b.vec_with_capacity(bl.len()); + for e in &bl { + let ty = type_atom(*e).ok_or("Missing class element type")?; + if ty == a::method_definition() { + let kind_s = str_val(*e, a::kind()); + let kind = match kind_s.as_str() { + "constructor" => MethodDefinitionKind::Constructor, + "get" => MethodDefinitionKind::Get, + "set" => MethodDefinitionKind::Set, + _ => MethodDefinitionKind::Method, + }; + let key = build_prop_key(b, get(*e, a::key()).ok_or("Missing method key")?)?; + let is_static = bool_val(*e, a::static_field()); + let is_computed = bool_val(*e, a::computed()); + let vt = get(*e, a::value()).ok_or("Missing method value")?; + let params = build_params(b, vt)?; + let body = build_fn_body(b, get(vt, a::body()).ok_or("Missing method body")?)?; + let func = Function { + node_id: nid(), span: SPAN, + r#type: FunctionType::FunctionExpression, id: None, + generator: bool_val(vt, a::generator()), + r#async: bool_val(vt, a::async_field()), + declare: false, type_parameters: None, this_param: None, + params: b.alloc(params), return_type: None, + body: Some(b.alloc(body)), + scope_id: Default::default(), pure: false, pife: false, + }; + elems.push(ClassElement::MethodDefinition(b.alloc(b.method_definition( + SPAN, MethodDefinitionType::MethodDefinition, b.vec(), key, func, + kind, is_computed, is_static, false, false, None, + )))); + } else if ty == a::property_definition() { + let key = build_prop_key(b, get(*e, a::key()).ok_or("Missing property key")?)?; + let val = opt_expr(b, *e, a::value())?; + elems.push(ClassElement::PropertyDefinition(b.alloc(b.property_definition( + SPAN, PropertyDefinitionType::PropertyDefinition, b.vec(), key, + NONE, val, bool_val(*e, a::computed()), bool_val(*e, a::static_field()), + false, false, false, false, false, None, + )))); + } else if ty == a::static_block() { + let body = build_stmts(b, list_val(*e, a::body()))?; + elems.push(ClassElement::StaticBlock(b.alloc(StaticBlock { + node_id: nid(), span: SPAN, body, scope_id: Default::default(), + }))); + } else { + return err(format!("Unsupported class element: {}", type_str(*e))); + } + } + Ok(b.class_body(SPAN, elems)) +} + +// ── Module declarations ── + +fn build_import<'a>(b: AstBuilder<'a>, term: Term) -> R> { + let src = str_val(get(term, a::source()).ok_or("Missing import :source")?, a::value()); + let sl = str_lit(b, &src); + let specs_list = list_val(term, a::specifiers()); + let specifiers = if specs_list.is_empty() { + if get(term, a::specifiers()).map_or(true, |t| is_nil(t)) { None } else { Some(b.vec()) } + } else { + let mut specs = b.vec_with_capacity(specs_list.len()); + for s in &specs_list { + let ty = type_atom(*s).ok_or("Missing specifier type")?; + if ty == a::import_specifier() { + let imp_name = str_val(get(*s, a::imported()).unwrap_or(*s), a::name()); + let loc_name = str_val(get(*s, a::local()).unwrap_or(*s), a::name()); + let imported = ModuleExportName::IdentifierName(ident_name(b, &imp_name)); + let local = b.binding_identifier(SPAN, b.str(&loc_name)); + specs.push(ImportDeclarationSpecifier::ImportSpecifier( + b.alloc(b.import_specifier(SPAN, imported, local, ImportOrExportKind::Value)), + )); + } else if ty == a::import_default_specifier() { + let loc_name = str_val(get(*s, a::local()).unwrap_or(*s), a::name()); + specs.push(ImportDeclarationSpecifier::ImportDefaultSpecifier( + b.alloc(b.import_default_specifier(SPAN, b.binding_identifier(SPAN, b.str(&loc_name)))), + )); + } else if ty == a::import_namespace_specifier() { + let loc_name = str_val(get(*s, a::local()).unwrap_or(*s), a::name()); + specs.push(ImportDeclarationSpecifier::ImportNamespaceSpecifier( + b.alloc(b.import_namespace_specifier(SPAN, b.binding_identifier(SPAN, b.str(&loc_name)))), + )); + } else { + return err(format!("Unsupported import specifier: {}", type_str(*s))); + } + } + Some(specs) + }; + Ok(ModuleDeclaration::ImportDeclaration(b.alloc( + b.import_declaration(SPAN, specifiers, sl, None, NONE, ImportOrExportKind::Value), + ))) +} + +fn build_export_named<'a>(b: AstBuilder<'a>, term: Term) -> R> { + let declaration = match opt(term, a::declaration()) { + Some(t) => { + let ty = type_atom(t).ok_or("Missing export decl type")?; + Some(if ty == a::variable_declaration() { build_var_decl(b, t)? } + else if ty == a::function_declaration() { build_fn_decl(b, t)? } + else if ty == a::class_declaration() { build_class_decl(b, t)? } + else { return err(format!("Unsupported export declaration: {}", type_str(t))); }) + } + None => None, + }; + let sl = list_val(term, a::specifiers()); + let mut specifiers = b.vec_with_capacity(sl.len()); + for s in &sl { + let loc = str_val(get(*s, a::local()).unwrap_or(*s), a::name()); + let exp = str_val(get(*s, a::exported()).unwrap_or(*s), a::name()); + specifiers.push(b.export_specifier( + SPAN, + ModuleExportName::IdentifierName(ident_name(b, &loc)), + ModuleExportName::IdentifierName(ident_name(b, &exp)), + ImportOrExportKind::Value, + )); + } + let source = opt(term, a::source()).map(|t| str_lit(b, &str_val(t, a::value()))); + Ok(ModuleDeclaration::ExportNamedDeclaration(b.alloc( + b.export_named_declaration(SPAN, declaration, specifiers, source, ImportOrExportKind::Value, NONE), + ))) +} + +fn build_export_default<'a>(b: AstBuilder<'a>, term: Term) -> R> { + let dt = get(term, a::declaration()).ok_or("Missing default export declaration")?; + let ty = type_atom(dt).ok_or("Missing declaration type")?; + let kind = if ty == a::function_declaration() { + let id = opt_binding_id(b, dt); + let params = build_params(b, dt)?; + let body = build_fn_body(b, get(dt, a::body()).ok_or("Missing fn body")?)?; + let func = Function { + node_id: nid(), span: SPAN, r#type: FunctionType::FunctionDeclaration, + id, generator: bool_val(dt, a::generator()), r#async: bool_val(dt, a::async_field()), + declare: false, type_parameters: None, this_param: None, + params: b.alloc(params), return_type: None, body: Some(b.alloc(body)), + scope_id: Default::default(), pure: false, pife: false, + }; + ExportDefaultDeclarationKind::FunctionDeclaration(b.alloc(func)) + } else if ty == a::class_declaration() { + let id = opt_binding_id(b, dt); + let sc = opt_expr(b, dt, a::super_class())?; + let body = build_class_body(b, get(dt, a::body()).ok_or("Missing class body")?)?; + let class = Class { + node_id: nid(), span: SPAN, r#type: ClassType::ClassDeclaration, + decorators: b.vec(), id, type_parameters: None, super_class: sc, + super_type_arguments: None, implements: b.vec(), body: b.alloc(body), + r#abstract: false, declare: false, scope_id: Default::default(), + }; + ExportDefaultDeclarationKind::ClassDeclaration(b.alloc(class)) + } else { + ExportDefaultDeclarationKind::from(build_expr(b, dt)?) + }; + Ok(ModuleDeclaration::ExportDefaultDeclaration(b.alloc(b.export_default_declaration(SPAN, kind)))) +} + +fn build_export_all<'a>(b: AstBuilder<'a>, term: Term) -> R> { + let src = str_val(get(term, a::source()).ok_or("Missing export :source")?, a::value()); + let exported = opt(term, a::exported()).map(|t| { + ModuleExportName::IdentifierName(ident_name(b, &str_val(t, a::name()))) + }); + Ok(ModuleDeclaration::ExportAllDeclaration(b.alloc( + b.export_all_declaration(SPAN, exported, str_lit(b, &src), NONE, ImportOrExportKind::Value), + ))) +} + +// ── Patterns ── + +fn build_binding_pat<'a>(b: AstBuilder<'a>, term: Term) -> R> { + let ty = type_atom(term).ok_or("Missing pattern type")?; + if ty == a::identifier() { + let n = str_val(term, a::name()); + return Ok(b.binding_pattern_binding_identifier(SPAN, b.str(&n))); + } + if ty == a::object_pattern() { + let pl = list_val(term, a::properties()); + let mut props = b.vec_with_capacity(pl.len()); + let mut rest = None; + for p in &pl { + if type_eq(*p, a::rest_element()) { + let arg = build_binding_pat(b, get(*p, a::argument()).ok_or("Missing rest :argument")?)?; + rest = Some(b.alloc(BindingRestElement { node_id: nid(), span: SPAN, argument: arg })); + } else { + let key = build_prop_key(b, get(*p, a::key()).ok_or("Missing property :key")?)?; + let val = build_binding_pat(b, get(*p, a::value()).ok_or("Missing property :value")?)?; + props.push(BindingProperty { + node_id: nid(), span: SPAN, key, value: val, + shorthand: bool_val(*p, a::shorthand()), computed: bool_val(*p, a::computed()), + }); + } + } + return Ok(b.binding_pattern_object_pattern(SPAN, props, rest)); + } + if ty == a::array_pattern() { + let el = list_val(term, a::elements()); + let mut elems = b.vec_with_capacity(el.len()); + let mut rest = None; + for e in &el { + if is_nil(*e) { + elems.push(None); + } else if type_eq(*e, a::rest_element()) { + let arg = build_binding_pat(b, get(*e, a::argument()).ok_or("Missing rest :argument")?)?; + rest = Some(b.alloc(BindingRestElement { node_id: nid(), span: SPAN, argument: arg })); + } else { + elems.push(Some(build_binding_pat(b, *e)?)); + } + } + return Ok(b.binding_pattern_array_pattern(SPAN, elems, rest)); + } + if ty == a::assignment_pattern() { + let left = build_binding_pat(b, get(term, a::left()).ok_or("Missing :left")?)?; + let right = build_expr(b, get(term, a::right()).ok_or("Missing :right")?)?; + return Ok(b.binding_pattern_assignment_pattern(SPAN, left, right)); + } + err(format!("Unsupported binding pattern: {}", type_str(term))) +} + +fn build_assign_target<'a>(b: AstBuilder<'a>, term: Term) -> R> { + let ty = type_atom(term).ok_or("Missing target type")?; + if ty == a::identifier() || ty == a::identifier_reference() { + let n = str_val(term, a::name()); + return Ok(AssignmentTarget::AssignmentTargetIdentifier(b.alloc(IdentifierReference { + node_id: nid(), span: SPAN, name: b.str(&n).into(), reference_id: Default::default(), + }))); + } + if ty == a::member_expression() || ty == a::static_member_expression() || ty == a::computed_member_expression() { + let obj = build_expr(b, get(term, a::object()).ok_or("Missing :object")?)?; + let optional = bool_val(term, a::optional()); + if bool_val(term, a::computed()) || ty == a::computed_member_expression() { + let prop = build_expr(b, get(term, a::property()).or_else(|| get(term, a::expression())).ok_or("Missing prop")?)?; + return Ok(AssignmentTarget::ComputedMemberExpression(b.alloc(b.computed_member_expression(SPAN, obj, prop, optional)))); + } + let pn = str_val(get(term, a::property()).ok_or("Missing :property")?, a::name()); + return Ok(AssignmentTarget::StaticMemberExpression(b.alloc(b.static_member_expression(SPAN, obj, ident_name(b, &pn), optional)))); + } + err(format!("Unsupported assignment target: {}", type_str(term))) +} + +fn build_simple_target<'a>(b: AstBuilder<'a>, term: Term) -> R> { + let ty = type_atom(term).ok_or("Missing target type")?; + if ty == a::identifier() || ty == a::identifier_reference() { + let n = str_val(term, a::name()); + return Ok(SimpleAssignmentTarget::AssignmentTargetIdentifier(b.alloc(IdentifierReference { + node_id: nid(), span: SPAN, name: b.str(&n).into(), reference_id: Default::default(), + }))); + } + if ty == a::member_expression() || ty == a::static_member_expression() || ty == a::computed_member_expression() { + let obj = build_expr(b, get(term, a::object()).ok_or("Missing :object")?)?; + let optional = bool_val(term, a::optional()); + if bool_val(term, a::computed()) || ty == a::computed_member_expression() { + let prop = build_expr(b, get(term, a::property()).or_else(|| get(term, a::expression())).ok_or("Missing prop")?)?; + return Ok(SimpleAssignmentTarget::ComputedMemberExpression(b.alloc(b.computed_member_expression(SPAN, obj, prop, optional)))); + } + let pn = str_val(get(term, a::property()).ok_or("Missing :property")?, a::name()); + return Ok(SimpleAssignmentTarget::StaticMemberExpression(b.alloc(b.static_member_expression(SPAN, obj, ident_name(b, &pn), optional)))); + } + err(format!("Unsupported simple target: {}", type_str(term))) +} + +// ── Helpers ── + +fn build_params<'a>(b: AstBuilder<'a>, term: Term) -> R> { + let pl = list_val(term, a::params()); + let mut items = b.vec_with_capacity(pl.len()); + let mut rest = None; + for p in &pl { + if type_eq(*p, a::rest_element()) { + let arg = build_binding_pat(b, get(*p, a::argument()).ok_or("Missing rest :argument")?)?; + let rest_elem = BindingRestElement { node_id: nid(), span: SPAN, argument: arg }; + rest = Some(b.alloc(FormalParameterRest { + node_id: nid(), span: SPAN, decorators: b.vec(), + rest: rest_elem, type_annotation: None, + })); + } else { + let pat = build_binding_pat(b, *p)?; + items.push(FormalParameter { + node_id: nid(), span: SPAN, decorators: b.vec(), pattern: pat, + type_annotation: None, initializer: None, optional: false, + accessibility: None, readonly: false, r#override: false, + }); + } + } + Ok(b.formal_parameters(SPAN, FormalParameterKind::FormalParameter, items, rest)) +} + +fn build_fn_body<'a>(b: AstBuilder<'a>, term: Term) -> R> { + Ok(b.function_body(SPAN, b.vec(), build_stmts(b, list_val(term, a::body()))?)) +} + +fn build_for_left<'a>(b: AstBuilder<'a>, term: Term) -> R> { + if type_eq(term, a::variable_declaration()) { + Ok(ForStatementLeft::VariableDeclaration(build_var_decl_boxed(b, term)?)) + } else { + Ok(ForStatementLeft::from(build_assign_target(b, term)?)) + } +} + +fn opt_label<'a>(b: AstBuilder<'a>, term: Term) -> Option> { + opt(term, a::label()).map(|t| { + let n = str_val(t, a::name()); + LabelIdentifier { node_id: nid(), span: SPAN, name: b.str(&n).into() } + }) +} + +fn build_prop_key<'a>(b: AstBuilder<'a>, term: Term) -> R> { + let ty = type_atom(term).ok_or("Missing key type")?; + if ty == a::identifier() { + return Ok(PropertyKey::StaticIdentifier(b.alloc(ident_name(b, &str_val(term, a::name()))))); + } + if ty == a::literal() || ty == a::string_literal() { + let vt = get(term, a::value()); + if let Some(t) = vt { + if let Ok(s) = t.decode::() { + return Ok(PropertyKey::StringLiteral(b.alloc(str_lit(b, &s)))); + } + if let Ok(n) = t.decode::() { + return Ok(PropertyKey::NumericLiteral(b.alloc(NumericLiteral { node_id: nid(), span: SPAN, value: n, raw: None, base: NumberBase::Decimal }))); + } + if let Ok(n) = t.decode::() { + return Ok(PropertyKey::NumericLiteral(b.alloc(NumericLiteral { node_id: nid(), span: SPAN, value: n as f64, raw: None, base: NumberBase::Decimal }))); + } + } + } + if ty == a::numeric_literal() { + return Ok(PropertyKey::NumericLiteral(b.alloc(NumericLiteral { node_id: nid(), span: SPAN, value: f64_val(term, a::value()), raw: None, base: NumberBase::Decimal }))); + } + Ok(PropertyKey::from(build_expr(b, term)?)) +} + +fn build_obj_props<'a>(b: AstBuilder<'a>, list: Vec) -> R>> { + let mut out = b.vec_with_capacity(list.len()); + for p in &list { + if type_eq(*p, a::spread_element()) { + let arg = build_expr(b, get(*p, a::argument()).ok_or("Missing spread :argument")?)?; + out.push(ObjectPropertyKind::SpreadProperty(b.alloc(b.spread_element(SPAN, arg)))); + } else { + let key = build_prop_key(b, get(*p, a::key()).ok_or("Missing :key")?)?; + let val = build_expr(b, get(*p, a::value()).ok_or("Missing :value")?)?; + let kind_s = str_val(*p, a::kind()); + let kind = match kind_s.as_str() { "get" => PropertyKind::Get, "set" => PropertyKind::Set, _ => PropertyKind::Init }; + out.push(ObjectPropertyKind::ObjectProperty(b.alloc(b.object_property( + SPAN, kind, key, val, bool_val(*p, a::method()), bool_val(*p, a::shorthand()), bool_val(*p, a::computed()), + )))); + } + } + Ok(out) +} + +fn build_args<'a>(b: AstBuilder<'a>, list: Vec) -> R>> { + let mut out = b.vec_with_capacity(list.len()); + for a_term in &list { + if type_eq(*a_term, a::spread_element()) { + let arg = build_expr(b, get(*a_term, a::argument()).ok_or("Missing spread :argument")?)?; + out.push(Argument::SpreadElement(b.alloc(b.spread_element(SPAN, arg)))); + } else { + out.push(Argument::from(build_expr(b, *a_term)?)); + } + } + Ok(out) +} + +// ── Operators ── + +fn parse_bin_op(op: &str) -> R { + Ok(match op { + "==" => BinaryOperator::Equality, "!=" => BinaryOperator::Inequality, + "===" => BinaryOperator::StrictEquality, "!==" => BinaryOperator::StrictInequality, + "<" => BinaryOperator::LessThan, "<=" => BinaryOperator::LessEqualThan, + ">" => BinaryOperator::GreaterThan, ">=" => BinaryOperator::GreaterEqualThan, + "+" => BinaryOperator::Addition, "-" => BinaryOperator::Subtraction, + "*" => BinaryOperator::Multiplication, "/" => BinaryOperator::Division, + "%" => BinaryOperator::Remainder, "**" => BinaryOperator::Exponential, + "<<" => BinaryOperator::ShiftLeft, ">>" => BinaryOperator::ShiftRight, + ">>>" => BinaryOperator::ShiftRightZeroFill, + "|" => BinaryOperator::BitwiseOR, "^" => BinaryOperator::BitwiseXOR, "&" => BinaryOperator::BitwiseAnd, + "in" => BinaryOperator::In, "instanceof" => BinaryOperator::Instanceof, + _ => return err(format!("Unknown binary operator: {op}")), + }) +} + +fn parse_log_op(op: &str) -> R { + Ok(match op { + "||" => LogicalOperator::Or, "&&" => LogicalOperator::And, "??" => LogicalOperator::Coalesce, + _ => return err(format!("Unknown logical operator: {op}")), + }) +} + +fn parse_unary_op(op: &str) -> R { + Ok(match op { + "+" => UnaryOperator::UnaryPlus, "-" => UnaryOperator::UnaryNegation, + "!" => UnaryOperator::LogicalNot, "~" => UnaryOperator::BitwiseNot, + "typeof" => UnaryOperator::Typeof, "void" => UnaryOperator::Void, "delete" => UnaryOperator::Delete, + _ => return err(format!("Unknown unary operator: {op}")), + }) +} + +fn parse_update_op(op: &str) -> R { + Ok(match op { + "++" => UpdateOperator::Increment, "--" => UpdateOperator::Decrement, + _ => return err(format!("Unknown update operator: {op}")), + }) +} + +fn parse_assign_op(op: &str) -> R { + Ok(match op { + "=" => AssignmentOperator::Assign, "+=" => AssignmentOperator::Addition, + "-=" => AssignmentOperator::Subtraction, "*=" => AssignmentOperator::Multiplication, + "/=" => AssignmentOperator::Division, "%=" => AssignmentOperator::Remainder, + "**=" => AssignmentOperator::Exponential, + "<<=" => AssignmentOperator::ShiftLeft, ">>=" => AssignmentOperator::ShiftRight, + ">>>=" => AssignmentOperator::ShiftRightZeroFill, + "|=" => AssignmentOperator::BitwiseOR, "^=" => AssignmentOperator::BitwiseXOR, "&=" => AssignmentOperator::BitwiseAnd, + "||=" => AssignmentOperator::LogicalOr, "&&=" => AssignmentOperator::LogicalAnd, "??=" => AssignmentOperator::LogicalNullish, + _ => return err(format!("Unknown assignment operator: {op}")), + }) +} + +fn parse_regex_flags(flags: &str) -> RegExpFlags { + let mut r = RegExpFlags::empty(); + for ch in flags.chars() { + r |= match ch { + 'g' => RegExpFlags::G, 'i' => RegExpFlags::I, 'm' => RegExpFlags::M, + 's' => RegExpFlags::S, 'u' => RegExpFlags::U, 'y' => RegExpFlags::Y, + 'd' => RegExpFlags::D, 'v' => RegExpFlags::V, + _ => RegExpFlags::empty(), + }; + } + r +} diff --git a/native/oxc_ex_nif/src/lib.rs b/native/oxc_ex_nif/src/lib.rs index 0985eee..900eed5 100644 --- a/native/oxc_ex_nif/src/lib.rs +++ b/native/oxc_ex_nif/src/lib.rs @@ -1,4 +1,5 @@ mod bundle; +mod codegen; mod error; mod imports; mod options; diff --git a/test/codegen_test.exs b/test/codegen_test.exs new file mode 100644 index 0000000..8ba05c0 --- /dev/null +++ b/test/codegen_test.exs @@ -0,0 +1,260 @@ +defmodule OXC.CodegenTest do + use ExUnit.Case, async: true + + describe "codegen/1" do + test "roundtrips parsed code" do + source = "const x = 1 + 2;\n" + {:ok, ast} = OXC.parse(source, "test.js") + {:ok, js} = OXC.codegen(ast) + assert js == source + end + + test "generates from manual AST" do + ast = %{ + type: :program, + body: [ + %{ + type: :variable_declaration, + kind: :const, + declarations: [ + %{ + type: :variable_declarator, + id: %{type: :identifier, name: "x"}, + init: %{type: :literal, value: 42} + } + ] + } + ] + } + + assert {:ok, js} = OXC.codegen(ast) + assert js =~ "const x = 42" + end + + test "generates function declaration" do + ast = %{ + type: :program, + body: [ + %{ + type: :function_declaration, + id: %{type: :identifier, name: "add"}, + params: [%{type: :identifier, name: "a"}, %{type: :identifier, name: "b"}], + body: %{ + type: :block_statement, + body: [ + %{ + type: :return_statement, + argument: %{ + type: :binary_expression, + operator: "+", + left: %{type: :identifier, name: "a"}, + right: %{type: :identifier, name: "b"} + } + } + ] + } + } + ] + } + + assert {:ok, js} = OXC.codegen(ast) + assert js =~ "function add(a, b)" + assert js =~ "return a + b" + end + + test "generates arrow function expression" do + ast = %{ + type: :program, + body: [ + %{ + type: :variable_declaration, + kind: :const, + declarations: [ + %{ + type: :variable_declarator, + id: %{type: :identifier, name: "f"}, + init: %{ + type: :arrow_function_expression, + expression: true, + async: false, + params: [%{type: :identifier, name: "x"}], + body: %{ + type: :binary_expression, + operator: "*", + left: %{type: :identifier, name: "x"}, + right: %{type: :literal, value: 2} + } + } + } + ] + } + ] + } + + assert {:ok, js} = OXC.codegen(ast) + assert js =~ "=> x * 2" + end + + test "generates import declaration" do + ast = %{ + type: :program, + body: [ + %{ + type: :import_declaration, + source: %{type: :literal, value: "vue"}, + specifiers: [ + %{ + type: :import_specifier, + local: %{type: :identifier, name: "ref"}, + imported: %{type: :identifier, name: "ref"} + } + ] + } + ] + } + + assert {:ok, js} = OXC.codegen(ast) + assert js =~ ~s(import { ref } from "vue") + end + + test "generates export default" do + ast = %{ + type: :program, + body: [ + %{type: :export_default_declaration, declaration: %{type: :literal, value: 42}} + ] + } + + assert {:ok, js} = OXC.codegen(ast) + assert js =~ "export default 42" + end + + test "generates class with methods" do + source = "class Dog extends Animal {\n\tconstructor(name) {\n\t\tsuper(name);\n\t}\n\tbark() {\n\t\treturn \"woof\";\n\t}\n}\n" + {:ok, ast} = OXC.parse(source, "test.js") + {:ok, js} = OXC.codegen(ast) + assert js =~ "class Dog extends Animal" + assert js =~ "constructor(name)" + assert js =~ "bark()" + end + + test "generates template literal" do + source = "const x = `hello ${name}!`;\n" + {:ok, ast} = OXC.parse(source, "test.js") + {:ok, js} = OXC.codegen(ast) + assert js =~ "${name}" + end + + test "generates object expression" do + source = "const obj = { a: 1, b: \"two\" };\n" + {:ok, ast} = OXC.parse(source, "test.js") + {:ok, js} = OXC.codegen(ast) + assert js =~ "a: 1" + assert js =~ ~s(b: "two") + end + + test "generates if/else" do + source = "if (x > 0) {\n\ty();\n} else {\n\tz();\n}\n" + {:ok, ast} = OXC.parse(source, "test.js") + {:ok, js} = OXC.codegen(ast) + assert js =~ "if (x > 0)" + assert js =~ "else" + end + + test "generates for-of loop" do + source = "for (const item of items) {\n\tconsole.log(item);\n}\n" + {:ok, ast} = OXC.parse(source, "test.js") + {:ok, js} = OXC.codegen(ast) + assert js =~ "for (const item of items)" + end + + test "generates try/catch" do + source = "try {\n\tx();\n} catch (e) {\n\ty(e);\n}\n" + {:ok, ast} = OXC.parse(source, "test.js") + {:ok, js} = OXC.codegen(ast) + assert js =~ "try" + assert js =~ "catch (e)" + end + + test "generates async/await" do + source = "async function f() {\n\tconst x = await fetch(url);\n}\n" + {:ok, ast} = OXC.parse(source, "test.js") + {:ok, js} = OXC.codegen(ast) + assert js =~ "async function" + assert js =~ "await fetch" + end + + test "generates spread and rest" do + source = "const [first, ...rest] = items;\nconst merged = { ...a, ...b };\n" + {:ok, ast} = OXC.parse(source, "test.js") + {:ok, js} = OXC.codegen(ast) + assert js =~ "...rest" + assert js =~ "...a" + end + + test "returns error for invalid AST" do + assert {:error, _} = OXC.codegen(%{type: :program, body: [%{type: :invalid_type}]}) + end + end + + describe "codegen!/1" do + test "returns code on success" do + {:ok, ast} = OXC.parse("const x = 1", "test.js") + assert is_binary(OXC.codegen!(ast)) + end + + test "raises on error" do + assert_raise OXC.Error, ~r/codegen error/, fn -> + OXC.codegen!(%{type: :program, body: [%{type: :invalid_type}]}) + end + end + end + + describe "bind/2" do + test "substitutes identifier placeholders" do + {:ok, ast} = OXC.parse("const $name = $value", "t.js") + ast = OXC.bind(ast, name: "greeting", value: "hello") + {:ok, js} = OXC.codegen(ast) + assert js =~ "const greeting = hello" + end + + test "substitutes literal values" do + {:ok, ast} = OXC.parse("const x = $val", "t.js") + ast = OXC.bind(ast, val: {:literal, 42}) + {:ok, js} = OXC.codegen(ast) + assert js =~ "const x = 42" + end + + test "substitutes string literals" do + {:ok, ast} = OXC.parse("const x = $val", "t.js") + ast = OXC.bind(ast, val: {:literal, "hello"}) + {:ok, js} = OXC.codegen(ast) + assert js =~ ~s(const x = "hello") + end + + test "substitutes AST nodes" do + {:ok, ast} = OXC.parse("const x = $val", "t.js") + node = %{type: :binary_expression, operator: "+", + left: %{type: :literal, value: 1}, right: %{type: :literal, value: 2}} + ast = OXC.bind(ast, val: node) + {:ok, js} = OXC.codegen(ast) + assert js =~ "const x = 1 + 2" + end + + test "leaves unbound placeholders as-is" do + {:ok, ast} = OXC.parse("const $x = $y", "t.js") + ast = OXC.bind(ast, x: "a") + {:ok, js} = OXC.codegen(ast) + assert js =~ "const a = $y" + end + + test "works with parse -> bind -> codegen pipeline" do + js = + OXC.parse!("const $name = $value", "t.js") + |> OXC.bind(name: "count", value: {:literal, 0}) + |> OXC.codegen!() + + assert js =~ "const count = 0" + end + end +end From afb48b06f6fb57e3ec04eecb401599877a2554a8 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 20 Apr 2026 21:12:46 +0300 Subject: [PATCH 2/2] Add splice/3, {:expr, ...} and {:literal, map/list} to bind, update README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bind/2 now supports: - {:expr, "ref(0)"} — parse JS expression - {:literal, %{...}} — recursive map → JS object - {:literal, [...]} — recursive list → JS array splice/3 replaces $placeholder statements/properties/elements: - Statement-level: $actions → list of statements - Property-level: {$fields} → list of object properties - Element-level: [$items] → list of array elements - Accepts strings (auto-parsed) or raw AST nodes README updated with codegen, bind, splice, and lint sections. --- README.md | 161 +++++++++++++++++++++++++++++-- lib/oxc.ex | 164 +++++++++++++++++++++++++++++++- test/codegen_test.exs | 213 ++++++++++++++++++++++++++++++++++++------ 3 files changed, 496 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 946e370..5001e9f 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,16 @@ Elixir bindings for the [OXC](https://oxc.rs) JavaScript toolchain via Rust NIFs. -Parse, transform, and minify JavaScript/TypeScript at native speed. +Parse, transform, minify, lint, and generate JavaScript/TypeScript at native speed. ## Features - **Parse** JS/TS/JSX/TSX into ESTree AST (maps with atom keys, snake_case types) +- **Codegen** — serialize AST maps back to JavaScript source via OXC's code generator +- **Bind** — substitute `$placeholders` in parsed AST (quasiquoting for JS) - **Transform** TypeScript → JavaScript, JSX → `createElement`/`jsx` calls - **Minify** with dead code elimination, constant folding, and variable mangling +- **Lint** with 650+ built-in oxlint rules + custom Elixir rules - **Bundle** multiple TS/JS modules into a single IIFE with dependency resolution - **Rewrite specifiers** — rewrite import/export paths in a single pass - **Collect imports** — typed import analysis (static/dynamic, import/export/export_all) @@ -22,7 +25,7 @@ Parse, transform, and minify JavaScript/TypeScript at native speed. ```elixir def deps do [ - {:oxc, "~> 0.7.0"} + {:oxc, "~> 0.8.0"} ] end ``` @@ -53,6 +56,82 @@ File extension determines the dialect — `.js`, `.jsx`, `.ts`, `.tsx`: AST node `:type` and `:kind` values are snake_case atoms (e.g. `:import_declaration`, `:variable_declaration`, `:const`). +### Codegen + +Generate JavaScript source from an AST map — the inverse of `parse/2`. +Uses OXC's code generator for correct operator precedence, formatting, and semicolons: + +```elixir +{:ok, ast} = OXC.parse("const x = 1 + 2", "test.js") +{:ok, js} = OXC.codegen(ast) +# "const x = 1 + 2;\n" +``` + +Construct AST by hand and generate JS: + +```elixir +ast = %{type: :program, body: [ + %{type: :function_declaration, + id: %{type: :identifier, name: "add"}, + params: [%{type: :identifier, name: "a"}, %{type: :identifier, name: "b"}], + body: %{type: :block_statement, body: [ + %{type: :return_statement, argument: %{type: :binary_expression, operator: "+", + left: %{type: :identifier, name: "a"}, right: %{type: :identifier, name: "b"}}} + ]}} +]} + +OXC.codegen!(ast) +# "function add(a, b) {\n\treturn a + b;\n}\n" +``` + +### Bind (Quasiquoting) + +Parse a JS template with `$placeholders`, substitute values, and generate code. +Like Elixir's `quote`/`unquote` but for JavaScript: + +```elixir +js = + OXC.parse!("const $name = $value", "t.js") + |> OXC.bind(name: "count", value: {:literal, 0}) + |> OXC.codegen!() +# "const count = 0;\n" +``` + +Binding values can be: +- A string — replaces the identifier name +- `{:literal, value}` — replaces with a literal node (string, number, boolean, nil) +- A map with `:type` — splices a raw AST node + +```elixir +# Splice an AST node +expr = %{type: :binary_expression, operator: "+", + left: %{type: :literal, value: 1}, + right: %{type: :literal, value: 2}} + +js = + OXC.parse!("const result = $expr", "t.js") + |> OXC.bind(expr: expr) + |> OXC.codegen!() +# "const result = 1 + 2;\n" +``` + +Use `.js`/`.ts` files as templates with full editor support: + +```elixir +# priv/templates/api-client.js — real JS, full syntax highlighting +# import { z } from "zod"; +# export const $schema = z.object($fields); +# export async function $listFn(params = {}) { ... } + +template = File.read!("priv/templates/api-client.js") +ast = OXC.parse!(template, "api-client.js") + +js = + ast + |> OXC.bind(schema: "userSchema", listFn: "listUsers", ...) + |> OXC.codegen!() +``` + ### Transform Strip TypeScript types and transform JSX: @@ -98,6 +177,63 @@ Custom JSX import source (Vue, Preact, etc.): # Compress without renaming variables ``` +### Lint + +Lint JavaScript/TypeScript with oxlint's 650+ built-in rules: + +```elixir +{:ok, diags} = OXC.Lint.run("x == y", "test.js", + rules: %{"eqeqeq" => :deny}) +# [%{rule: "eqeqeq", message: "Require the use of === and !==", severity: :deny, ...}] + +{:ok, []} = OXC.Lint.run("export const x = 1;\n", "test.ts") +``` + +Enable specific plugins: + +```elixir +{:ok, diags} = OXC.Lint.run(source, "app.tsx", + plugins: [:react, :typescript], + rules: %{"no-console" => :warn, "react/no-danger" => :deny}) +``` + +Available plugins: `:react`, `:typescript`, `:unicorn`, `:import`, `:jsdoc`, +`:jest`, `:vitest`, `:jsx_a11y`, `:nextjs`, `:react_perf`, `:promise`, +`:node`, `:vue`, `:oxc`. + +#### Custom Elixir Rules + +Write project-specific lint rules in Elixir using the same AST from `OXC.parse/2`: + +```elixir +defmodule MyApp.NoConsoleLog do + @behaviour OXC.Lint.Rule + + @impl true + def meta do + %{name: "my-app/no-console-log", + description: "Disallow console.log in production code", + category: :restriction, fixable: false} + end + + @impl true + def run(ast, _context) do + OXC.collect(ast, fn + %{type: :call_expression, + callee: %{type: :member_expression, + object: %{type: :identifier, name: "console"}, + property: %{type: :identifier, name: "log"}}, + start: start, end: stop} -> + {:keep, %{span: {start, stop}, message: "Unexpected console.log"}} + _ -> :skip + end) + end +end + +{:ok, diags} = OXC.Lint.run(source, "app.ts", + custom_rules: [{MyApp.NoConsoleLog, :warn}]) +``` + ### Import Extraction Fast NIF-level extraction of import specifiers — skips full AST serialization: @@ -254,6 +390,7 @@ All functions have bang variants that raise `OXC.Error` on failure: ast = OXC.parse!("const x = 1", "test.js") js = OXC.transform!("const x: number = 42", "test.ts") min = OXC.minify!("const x = 1 + 2;", "test.js") +js = OXC.codegen!(ast) imports = OXC.imports!("import { ref } from 'vue'", "test.ts") ``` @@ -270,13 +407,23 @@ maps with a `:message` key: OXC is a collection of high-performance JavaScript tools written in Rust. This library wraps `oxc_parser`, `oxc_transformer`, `oxc_minifier`, -`oxc_transformer_plugins`, and `oxc_codegen` via [Rustler](https://github.com/rusterlium/rustler) NIFs, -and uses Rolldown/OXC for `bundle/2`. +`oxc_transformer_plugins`, `oxc_codegen`, and `oxc_linter` via +[Rustler](https://github.com/rusterlium/rustler) NIFs, and uses +Rolldown/OXC for `bundle/2`. All NIF calls run on the dirty CPU scheduler so they don't block the BEAM. -The parser produces ESTree JSON via OXC's serializer, Rustler encodes it -as BEAM terms, and the Elixir wrapper normalizes AST keys to atoms with -snake_case type values. + +For **parse**, the parser produces ESTree JSON via OXC's serializer, +Rustler encodes it as BEAM terms, and the Elixir wrapper normalizes +AST keys to atoms with snake_case type values. + +For **codegen**, the reverse happens: the Elixir AST map (BEAM terms) is +read directly by the NIF via Rustler's Term API, reconstructed into OXC's +arena-allocated AST using `AstBuilder`, and then emitted as JavaScript +via `oxc_codegen`. + +For **lint**, oxlint's built-in rules run natively in Rust. Custom rules +written in Elixir receive the same parsed AST and run in the BEAM. ## License diff --git a/lib/oxc.ex b/lib/oxc.ex index e0c8e84..f74ff3a 100644 --- a/lib/oxc.ex +++ b/lib/oxc.ex @@ -566,7 +566,10 @@ defmodule OXC do Binding values can be: * A string — replaced as an identifier name - * `{:literal, value}` — replaced with a literal node + * `{:literal, value}` — replaced with a literal node (string, number, + boolean, nil, map, or list — maps and lists are converted recursively + into JS object/array expressions) + * `{:expr, code}` — parsed as a JavaScript expression * A map with `:type` — spliced as a raw AST node ## Examples @@ -583,15 +586,14 @@ defmodule OXC do """ @spec bind(ast(), keyword()) :: ast() def bind(ast, bindings) when is_list(bindings) do - lookup = Map.new(bindings, fn {k, v} -> {"$#{k}", v} end) + lookup = Map.new(bindings, fn {k, v} -> {"$#{k}", resolve_binding(v)} end) postwalk(ast, fn %{type: :identifier, name: "$" <> _ = name} = node -> case Map.get(lookup, name) do nil -> node - value when is_binary(value) -> %{node | name: value} - {:literal, lit} -> %{type: :literal, value: lit} - %{type: _} = ast_node -> ast_node + {:rename, new_name} -> %{node | name: new_name} + {:node, ast_node} -> ast_node end node -> @@ -599,6 +601,158 @@ defmodule OXC do end) end + defp resolve_binding(value) when is_binary(value), do: {:rename, value} + defp resolve_binding({:literal, lit}), do: {:node, literal_to_ast(lit)} + defp resolve_binding({:expr, code}) when is_binary(code), do: {:node, parse_expression!(code)} + defp resolve_binding(%{type: _} = node), do: {:node, node} + + @doc """ + Replace `$placeholder` statements, properties, or elements with a list of nodes. + + Finds expression statements, shorthand object properties, or array elements + whose identifier name starts with `$` and replaces them with the provided + nodes. Accepts a single item or a list. Strings are auto-parsed as JS. + + ## Examples + + iex> {:ok, ast} = OXC.parse("function f() { $body }", "t.js") + iex> ast = OXC.splice(ast, :body, ["const x = 1;", "return x;"]) + iex> js = OXC.codegen!(ast) + iex> js =~ "const x = 1" and js =~ "return x" + true + + iex> {:ok, ast} = OXC.parse("const obj = {a: 1, $rest}", "t.js") + iex> ast = OXC.splice(ast, :rest, ["b: 2", "c: 3"]) + iex> js = OXC.codegen!(ast) + iex> js =~ "b: 2" and js =~ "c: 3" + true + """ + @spec splice(ast(), atom(), ast() | String.t() | [ast() | String.t()]) :: ast() + def splice(ast, name, replacement) when is_atom(name) do + placeholder = "$#{name}" + items = List.wrap(replacement) + + postwalk(ast, fn + %{type: :program, body: body} = node -> + %{node | body: splice_statements(body, placeholder, items)} + + %{type: :block_statement, body: body} = node -> + %{node | body: splice_statements(body, placeholder, items)} + + %{type: :object_expression, properties: props} = node -> + %{node | properties: splice_properties(props, placeholder, items)} + + %{type: :array_expression, elements: elems} = node -> + %{node | elements: splice_elements(elems, placeholder, items)} + + %{type: type, body: body} = node when type in [:function_body, :class_body] -> + %{node | body: splice_statements(body, placeholder, items)} + + node -> + node + end) + end + + defp splice_statements(stmts, placeholder, items) do + Enum.flat_map(stmts, fn + %{type: :expression_statement, expression: %{type: :identifier, name: ^placeholder}} -> + Enum.map(items, &resolve_splice_statement/1) + + other -> + [other] + end) + end + + defp splice_properties(props, placeholder, items) do + Enum.flat_map(props, fn + %{type: :property, shorthand: true, key: %{type: :identifier, name: ^placeholder}} -> + Enum.map(items, &resolve_splice_property/1) + + other -> + [other] + end) + end + + defp splice_elements(elems, placeholder, items) do + Enum.flat_map(elems, fn + %{type: :identifier, name: ^placeholder} -> + Enum.map(items, &resolve_splice_element/1) + + other -> + [other] + end) + end + + defp resolve_splice_statement(item) when is_binary(item) do + case parse(item, "splice.js") do + {:ok, %{body: [stmt]}} -> + stmt + + _ -> + %{body: [%{body: %{body: [stmt]}}]} = parse!("function _(){" <> item <> "}", "splice.js") + stmt + end + end + + defp resolve_splice_statement(%{type: _} = node), do: node + + defp resolve_splice_property(item) when is_binary(item) do + ast = parse!("({" <> item <> "})", "splice.js") + [%{type: :expression_statement, expression: expr}] = ast.body + props = case expr do + %{type: :parenthesized_expression, expression: %{properties: p}} -> p + %{type: :object_expression, properties: p} -> p + %{properties: p} -> p + end + [prop] = props + prop + + end + + defp resolve_splice_property(%{type: _} = node), do: node + + defp resolve_splice_element(item) when is_binary(item) do + parse_expression!(item) + end + + defp resolve_splice_element(%{type: _} = node), do: node + + defp parse_expression!(code) do + ast = parse!(code, "expr.js") + + case ast.body do + [%{type: :expression_statement, expression: expr}] -> expr + _ -> raise Error, message: "Expected a single expression: #{code}", errors: [] + end + end + + defp literal_to_ast(value) when is_binary(value), do: %{type: :literal, value: value} + defp literal_to_ast(value) when is_number(value), do: %{type: :literal, value: value} + defp literal_to_ast(value) when is_boolean(value), do: %{type: :literal, value: value} + defp literal_to_ast(nil), do: %{type: :literal, value: nil} + + defp literal_to_ast(map) when is_map(map) do + %{ + type: :object_expression, + properties: + Enum.map(map, fn {k, v} -> + %{ + type: :property, + key: %{type: :identifier, name: to_string(k)}, + value: literal_to_ast(v), + kind: :init, + shorthand: false, + computed: false, + method: false + } + end) + } + end + + defp literal_to_ast(list) when is_list(list) do + %{type: :array_expression, elements: Enum.map(list, &literal_to_ast/1)} + end + # Convert atom keys/values back to strings for the Rust NIF defp deatomize_ast(map) when is_map(map) do Map.new(map, fn {key, value} -> diff --git a/test/codegen_test.exs b/test/codegen_test.exs index 8ba05c0..6765469 100644 --- a/test/codegen_test.exs +++ b/test/codegen_test.exs @@ -129,7 +129,7 @@ defmodule OXC.CodegenTest do assert js =~ "export default 42" end - test "generates class with methods" do + test "roundtrips class with methods" do source = "class Dog extends Animal {\n\tconstructor(name) {\n\t\tsuper(name);\n\t}\n\tbark() {\n\t\treturn \"woof\";\n\t}\n}\n" {:ok, ast} = OXC.parse(source, "test.js") {:ok, js} = OXC.codegen(ast) @@ -138,22 +138,14 @@ defmodule OXC.CodegenTest do assert js =~ "bark()" end - test "generates template literal" do + test "roundtrips template literal" do source = "const x = `hello ${name}!`;\n" {:ok, ast} = OXC.parse(source, "test.js") {:ok, js} = OXC.codegen(ast) assert js =~ "${name}" end - test "generates object expression" do - source = "const obj = { a: 1, b: \"two\" };\n" - {:ok, ast} = OXC.parse(source, "test.js") - {:ok, js} = OXC.codegen(ast) - assert js =~ "a: 1" - assert js =~ ~s(b: "two") - end - - test "generates if/else" do + test "roundtrips if/else" do source = "if (x > 0) {\n\ty();\n} else {\n\tz();\n}\n" {:ok, ast} = OXC.parse(source, "test.js") {:ok, js} = OXC.codegen(ast) @@ -161,14 +153,14 @@ defmodule OXC.CodegenTest do assert js =~ "else" end - test "generates for-of loop" do + test "roundtrips for-of loop" do source = "for (const item of items) {\n\tconsole.log(item);\n}\n" {:ok, ast} = OXC.parse(source, "test.js") {:ok, js} = OXC.codegen(ast) assert js =~ "for (const item of items)" end - test "generates try/catch" do + test "roundtrips try/catch" do source = "try {\n\tx();\n} catch (e) {\n\ty(e);\n}\n" {:ok, ast} = OXC.parse(source, "test.js") {:ok, js} = OXC.codegen(ast) @@ -176,7 +168,7 @@ defmodule OXC.CodegenTest do assert js =~ "catch (e)" end - test "generates async/await" do + test "roundtrips async/await" do source = "async function f() {\n\tconst x = await fetch(url);\n}\n" {:ok, ast} = OXC.parse(source, "test.js") {:ok, js} = OXC.codegen(ast) @@ -184,7 +176,7 @@ defmodule OXC.CodegenTest do assert js =~ "await fetch" end - test "generates spread and rest" do + test "roundtrips spread and rest" do source = "const [first, ...rest] = items;\nconst merged = { ...a, ...b };\n" {:ok, ast} = OXC.parse(source, "test.js") {:ok, js} = OXC.codegen(ast) @@ -211,44 +203,97 @@ defmodule OXC.CodegenTest do end describe "bind/2" do - test "substitutes identifier placeholders" do + test "renames identifiers" do {:ok, ast} = OXC.parse("const $name = $value", "t.js") ast = OXC.bind(ast, name: "greeting", value: "hello") {:ok, js} = OXC.codegen(ast) assert js =~ "const greeting = hello" end - test "substitutes literal values" do + test "substitutes literal numbers" do {:ok, ast} = OXC.parse("const x = $val", "t.js") ast = OXC.bind(ast, val: {:literal, 42}) - {:ok, js} = OXC.codegen(ast) - assert js =~ "const x = 42" + assert OXC.codegen!(ast) =~ "const x = 42" end - test "substitutes string literals" do + test "substitutes literal strings" do {:ok, ast} = OXC.parse("const x = $val", "t.js") ast = OXC.bind(ast, val: {:literal, "hello"}) - {:ok, js} = OXC.codegen(ast) - assert js =~ ~s(const x = "hello") + assert OXC.codegen!(ast) =~ ~s(const x = "hello") + end + + test "substitutes literal booleans" do + {:ok, ast} = OXC.parse("const x = $val", "t.js") + ast = OXC.bind(ast, val: {:literal, true}) + assert OXC.codegen!(ast) =~ "const x = true" + end + + test "substitutes literal nil as null" do + {:ok, ast} = OXC.parse("const x = $val", "t.js") + ast = OXC.bind(ast, val: {:literal, nil}) + assert OXC.codegen!(ast) =~ "const x = null" + end + + test "substitutes literal maps as objects" do + {:ok, ast} = OXC.parse("const x = $val", "t.js") + ast = OXC.bind(ast, val: {:literal, %{port: 3000, debug: true}}) + js = OXC.codegen!(ast) + assert js =~ "port:" + assert js =~ "3e3" or js =~ "3000" + assert js =~ "debug: true" + end + + test "substitutes literal lists as arrays" do + {:ok, ast} = OXC.parse("const x = $val", "t.js") + ast = OXC.bind(ast, val: {:literal, [1, "two", true]}) + js = OXC.codegen!(ast) + assert js =~ "1" + assert js =~ ~s("two") + assert js =~ "true" + end + + test "substitutes nested literal structures" do + {:ok, ast} = OXC.parse("const x = $val", "t.js") + ast = OXC.bind(ast, val: {:literal, %{user: %{name: "Joe", tags: ["admin"]}}}) + js = OXC.codegen!(ast) + assert js =~ "user:" + assert js =~ ~s("Joe") + assert js =~ ~s("admin") + end + + test "substitutes expressions with {:expr, ...}" do + {:ok, ast} = OXC.parse("const $name = $init", "t.js") + ast = OXC.bind(ast, name: "count", init: {:expr, "ref(0)"}) + assert OXC.codegen!(ast) =~ "const count = ref(0)" end - test "substitutes AST nodes" do + test "substitutes complex expressions" do {:ok, ast} = OXC.parse("const x = $val", "t.js") - node = %{type: :binary_expression, operator: "+", - left: %{type: :literal, value: 1}, right: %{type: :literal, value: 2}} + ast = OXC.bind(ast, val: {:expr, "a > 0 ? a : -a"}) + assert OXC.codegen!(ast) =~ "a > 0 ? a : -a" + end + + test "substitutes raw AST nodes" do + {:ok, ast} = OXC.parse("const x = $val", "t.js") + + node = %{ + type: :binary_expression, + operator: "+", + left: %{type: :literal, value: 1}, + right: %{type: :literal, value: 2} + } + ast = OXC.bind(ast, val: node) - {:ok, js} = OXC.codegen(ast) - assert js =~ "const x = 1 + 2" + assert OXC.codegen!(ast) =~ "const x = 1 + 2" end test "leaves unbound placeholders as-is" do {:ok, ast} = OXC.parse("const $x = $y", "t.js") ast = OXC.bind(ast, x: "a") - {:ok, js} = OXC.codegen(ast) - assert js =~ "const a = $y" + assert OXC.codegen!(ast) =~ "const a = $y" end - test "works with parse -> bind -> codegen pipeline" do + test "works in a pipeline" do js = OXC.parse!("const $name = $value", "t.js") |> OXC.bind(name: "count", value: {:literal, 0}) @@ -257,4 +302,112 @@ defmodule OXC.CodegenTest do assert js =~ "const count = 0" end end + + describe "splice/3" do + test "splices statements into function body" do + js = + OXC.parse!("function f() { $body }", "t.js") + |> OXC.splice(:body, ["const x = 1;", "const y = 2;", "return x + y;"]) + |> OXC.codegen!() + + assert js =~ "const x = 1" + assert js =~ "const y = 2" + assert js =~ "return x + y" + end + + test "splices a single statement" do + js = + OXC.parse!("function f() { $action }", "t.js") + |> OXC.splice(:action, "return 42;") + |> OXC.codegen!() + + assert js =~ "return 42" + end + + test "splices object properties" do + js = + OXC.parse!("const obj = {a: 1, $rest}", "t.js") + |> OXC.splice(:rest, ["b: 2", "c: 3"]) + |> OXC.codegen!() + + assert js =~ "a: 1" + assert js =~ "b: 2" + assert js =~ "c: 3" + end + + test "splices array elements" do + js = + OXC.parse!("const arr = [$items]", "t.js") + |> OXC.splice(:items, ["1", "\"two\"", "true"]) + |> OXC.codegen!() + + assert js =~ "1" + assert js =~ ~s("two") + assert js =~ "true" + end + + test "removes placeholder with empty list" do + js = + OXC.parse!("function f() { $debug; return 1; }", "t.js") + |> OXC.splice(:debug, []) + |> OXC.codegen!() + + assert js =~ "return 1" + refute js =~ "debug" + end + + test "splices into program body" do + js = + OXC.parse!("const x = 1;\n$more", "t.js") + |> OXC.splice(:more, ["const y = 2;", "const z = 3;"]) + |> OXC.codegen!() + + assert js =~ "const x = 1" + assert js =~ "const y = 2" + assert js =~ "const z = 3" + end + + test "accepts raw AST nodes" do + stmt = %{ + type: :variable_declaration, + kind: :const, + declarations: [ + %{ + type: :variable_declarator, + id: %{type: :identifier, name: "x"}, + init: %{type: :literal, value: 99} + } + ] + } + + js = + OXC.parse!("function f() { $body }", "t.js") + |> OXC.splice(:body, stmt) + |> OXC.codegen!() + + assert js =~ "const x = 99" + end + + test "full pipeline with bind and splice" do + template = ~s|import { z } from "zod";\nexport const $schema = z.object({$fields});\n$actions\n| + + fields = ["id: z.string().uuid()", "name: z.string()"] + + actions = [ + ~s|export function listUsers() { return fetch("/api/users"); }| + ] + + js = + OXC.parse!(template, "t.ts") + |> OXC.bind(schema: "userSchema") + |> OXC.splice(:fields, fields) + |> OXC.splice(:actions, actions) + |> OXC.codegen!() + + assert js =~ "userSchema" + assert js =~ "z.string().uuid()" + assert js =~ "z.string()" + assert js =~ "listUsers" + end + end end