From 9fd99c8efc6dbc321caa32a3a2f78aa728dc6f06 Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Fri, 22 May 2026 09:23:15 +0100 Subject: [PATCH] Add JS compile target via --emit js (ES modules, arrow functions) Implements ILO-73 MVP: src/codegen/js.rs mirrors python.rs, wires --emit js in CLI, and verifies fac/fib compile to runnable Node.js. Co-Authored-By: Claude Sonnet 4.6 --- src/cli/args.rs | 2 +- src/codegen/js.rs | 928 +++++++++++++++++++++++++++++++++++++++++++++ src/codegen/mod.rs | 1 + src/main.rs | 7 +- 4 files changed, 936 insertions(+), 2 deletions(-) create mode 100644 src/codegen/js.rs diff --git a/src/cli/args.rs b/src/cli/args.rs index 7c4318e45..3ed11537c 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -169,7 +169,7 @@ pub struct RunArgs { #[arg(long)] pub bench: bool, - /// Emit target (e.g. python) instead of running. + /// Emit target (e.g. python, js) instead of running. #[arg(long)] pub emit: Option, diff --git a/src/codegen/js.rs b/src/codegen/js.rs new file mode 100644 index 000000000..4a94e0fe6 --- /dev/null +++ b/src/codegen/js.rs @@ -0,0 +1,928 @@ +use crate::ast::{ + BinOp, Decl, Expr, Literal, MatchArm, Pattern, Program, Spanned, Stmt, Type, UnaryOp, + UnwrapMode, +}; + +pub fn emit(program: &Program) -> String { + let mut out = String::new(); + // ES module header + out.push_str("// Generated by ilo --emit js\n\n"); + for decl in &program.declarations { + emit_decl(&mut out, decl, 0); + out.push('\n'); + } + out.trim_end().to_string() +} + +fn indent(out: &mut String, level: usize) { + for _ in 0..level { + out.push_str(" "); + } +} + +fn js_name(name: &str) -> String { + // Convert kebab-case to camelCase + let parts: Vec<&str> = name.split('-').collect(); + if parts.len() == 1 { + return name.to_string(); + } + let mut result = parts[0].to_string(); + for part in &parts[1..] { + let mut chars = part.chars(); + if let Some(first) = chars.next() { + result.push_str(&first.to_uppercase().to_string()); + result.push_str(chars.as_str()); + } + } + result +} + +fn emit_type_comment(ty: &Type) -> String { + match ty { + Type::Number => "number".to_string(), + Type::Text => "string".to_string(), + Type::Bool => "boolean".to_string(), + Type::Any => "any".to_string(), + Type::Optional(inner) => format!("{} | null", emit_type_comment(inner)), + Type::List(inner) => format!("{}[]", emit_type_comment(inner)), + Type::Map(k, v) => format!( + "Map<{}, {}>", + emit_type_comment(k), + emit_type_comment(v) + ), + Type::Sum(_) => "string".to_string(), + Type::Result(ok, _err) => format!("{{ ok: true, value: {} }} | {{ ok: false, error: string }}", emit_type_comment(ok)), + Type::Fn(params, ret) => { + let ps: Vec<_> = params.iter().map(emit_type_comment).collect(); + format!("({}) => {}", ps.join(", "), emit_type_comment(ret)) + } + Type::Named(name) => name.clone(), + } +} + +fn emit_decl(out: &mut String, decl: &Decl, level: usize) { + match decl { + Decl::Function { + name, + params, + return_type, + body, + .. + } => { + indent(out, level); + // Emit JSDoc-style type comment + out.push_str("/**\n"); + for p in params { + indent(out, level); + out.push_str(&format!( + " * @param {{{}}} {}\n", + emit_type_comment(&p.ty), + js_name(&p.name) + )); + } + indent(out, level); + out.push_str(&format!(" * @returns {{{}}}\n", emit_type_comment(return_type))); + indent(out, level); + out.push_str(" */\n"); + indent(out, level); + out.push_str(&format!("const {} = (", js_name(name))); + for (i, p) in params.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + out.push_str(&js_name(&p.name)); + } + out.push_str(") => {\n"); + emit_body(out, body, level + 1, true); + indent(out, level); + out.push_str("};\n"); + } + Decl::TypeDef { name, fields, .. } => { + indent(out, level); + out.push_str(&format!("// type {} = {{", name)); + for (i, f) in fields.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + out.push_str(&format!("{}: {}", f.name, emit_type_comment(&f.ty))); + } + out.push_str("}\n"); + } + Decl::Tool { + name, + description, + params, + return_type, + .. + } => { + indent(out, level); + out.push_str("/**\n"); + indent(out, level); + out.push_str(&format!(" * {}\n", description)); + for p in params { + indent(out, level); + out.push_str(&format!( + " * @param {{{}}} {}\n", + emit_type_comment(&p.ty), + js_name(&p.name) + )); + } + indent(out, level); + out.push_str(&format!(" * @returns {{{}}}\n", emit_type_comment(return_type))); + indent(out, level); + out.push_str(" */\n"); + indent(out, level); + out.push_str(&format!("const {} = (", js_name(name))); + for (i, p) in params.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + out.push_str(&js_name(&p.name)); + } + out.push_str(") => { throw new Error(\"not implemented\"); };\n"); + } + Decl::Alias { name, target, .. } => { + indent(out, level); + out.push_str(&format!( + "// alias {} = {}\n", + name, + emit_type_comment(target) + )); + } + Decl::Use { .. } => {} + Decl::Error { .. } => {} + } +} + +fn emit_body(out: &mut String, stmts: &[Spanned], level: usize, is_fn_body: bool) { + if stmts.is_empty() { + indent(out, level); + out.push_str("return undefined;\n"); + return; + } + for (i, spanned) in stmts.iter().enumerate() { + let is_last = i == stmts.len() - 1; + emit_stmt(out, &spanned.node, level, is_fn_body && is_last); + } +} + +fn emit_stmt(out: &mut String, stmt: &Stmt, level: usize, implicit_return: bool) { + match stmt { + Stmt::Let { name, value } => { + let val = emit_expr(out, level, value); + indent(out, level); + out.push_str(&format!("const {} = {};\n", js_name(name), val)); + } + Stmt::Destructure { bindings, value } => { + let record = emit_expr(out, level, value); + for binding in bindings { + indent(out, level); + out.push_str(&format!( + "const {} = {}[\"{}\"];\n", + js_name(binding), + record, + binding + )); + } + } + Stmt::Guard { + condition, + negated, + body, + else_body, + .. + } => { + let cond = emit_expr(out, level, condition); + indent(out, level); + let is_ternary = else_body.is_some(); + if *negated { + out.push_str(&format!("if (!({})) {{\n", cond)); + } else { + out.push_str(&format!("if ({}) {{\n", cond)); + } + emit_body(out, body, level + 1, !is_ternary); + indent(out, level); + out.push_str("}\n"); + if let Some(eb) = else_body { + indent(out, level); + out.push_str("else {\n"); + emit_body(out, eb, level + 1, false); + indent(out, level); + out.push_str("}\n"); + } + } + Stmt::Match { subject, arms } => { + emit_match_stmt(out, subject, arms, level); + } + Stmt::ForEach { + binding, + collection, + body, + } => { + let coll = emit_expr(out, level, collection); + indent(out, level); + out.push_str(&format!("for (const {} of {}) {{\n", js_name(binding), coll)); + emit_body(out, body, level + 1, false); + indent(out, level); + out.push_str("}\n"); + } + Stmt::ForRange { + binding, + start, + end, + step, + body, + } => { + let s = emit_expr(out, level, start); + let e = emit_expr(out, level, end); + indent(out, level); + if let Some(step_expr) = step { + let st = emit_expr(out, level, step_expr); + out.push_str(&format!( + "for (let {} = {}; {} < {}; {} += {}) {{\n", + js_name(binding), + s, + js_name(binding), + e, + js_name(binding), + st + )); + } else { + out.push_str(&format!( + "for (let {} = {}; {} < {}; {}++) {{\n", + js_name(binding), + s, + js_name(binding), + e, + js_name(binding) + )); + } + emit_body(out, body, level + 1, false); + indent(out, level); + out.push_str("}\n"); + } + Stmt::While { condition, body } => { + let cond = emit_expr(out, level, condition); + indent(out, level); + out.push_str(&format!("while ({}) {{\n", cond)); + emit_body(out, body, level + 1, false); + indent(out, level); + out.push_str("}\n"); + } + Stmt::Return(expr) => { + let val = emit_expr(out, level, expr); + indent(out, level); + out.push_str(&format!("return {};\n", val)); + } + Stmt::Break(Some(expr)) => { + let val = emit_expr(out, level, expr); + indent(out, level); + out.push_str(&format!("__breakVal = {};\n", val)); + indent(out, level); + out.push_str("break;\n"); + } + Stmt::Break(None) => { + indent(out, level); + out.push_str("break;\n"); + } + Stmt::Continue => { + indent(out, level); + out.push_str("continue;\n"); + } + Stmt::Expr(expr) => { + let val = emit_expr(out, level, expr); + indent(out, level); + if implicit_return { + out.push_str(&format!("return {};\n", val)); + } else { + out.push_str(&format!("{};\n", val)); + } + } + } +} + +fn emit_match_stmt(out: &mut String, subject: &Option, arms: &[MatchArm], level: usize) { + let subj_str = match subject { + Some(e) => emit_expr(out, level, e), + None => "_subject".to_string(), + }; + + for (i, arm) in arms.iter().enumerate() { + indent(out, level); + match &arm.pattern { + Pattern::Wildcard => { + if i == 0 { + emit_body(out, &arm.body, level, true); + return; + } + out.push_str("else {\n"); + emit_body(out, &arm.body, level + 1, true); + indent(out, level); + out.push_str("}\n"); + return; + } + Pattern::Ok(binding) => { + let kw = if i == 0 { "if" } else { "else if" }; + out.push_str(&format!( + "{} ({} && {}[0] === \"ok\") {{\n", + kw, subj_str, subj_str + )); + indent(out, level + 1); + out.push_str(&format!( + "const {} = {}[1];\n", + js_name(binding), + subj_str + )); + } + Pattern::Err(binding) => { + let kw = if i == 0 { "if" } else { "else if" }; + out.push_str(&format!( + "{} ({} && {}[0] === \"err\") {{\n", + kw, subj_str, subj_str + )); + indent(out, level + 1); + out.push_str(&format!( + "const {} = {}[1];\n", + js_name(binding), + subj_str + )); + } + Pattern::Literal(lit) => { + let kw = if i == 0 { "if" } else { "else if" }; + out.push_str(&format!( + "{} ({} === {}) {{\n", + kw, + subj_str, + emit_literal(lit) + )); + } + Pattern::TypeIs { ty, binding } => { + let kw = if i == 0 { "if" } else { "else if" }; + let type_check = match ty { + Type::Number => format!("typeof {} === \"number\"", subj_str), + Type::Text => format!("typeof {} === \"string\"", subj_str), + Type::Bool => format!("typeof {} === \"boolean\"", subj_str), + _ => format!("true /* type check for {} */", emit_type_comment(ty)), + }; + out.push_str(&format!("{} ({}) {{\n", kw, type_check)); + indent(out, level + 1); + out.push_str(&format!("const {} = {};\n", js_name(binding), subj_str)); + } + } + emit_body(out, &arm.body, level + 1, true); + indent(out, level); + out.push_str("}\n"); + } +} + +fn emit_expr(out: &mut String, level: usize, expr: &Expr) -> String { + match expr { + Expr::Literal(lit) => emit_literal(lit), + Expr::Ref(name) => js_name(name), + Expr::Field { object, field, safe } => { + let obj = emit_expr(out, level, object); + if *safe { + format!("({}?.[\"{}\"])", obj, field) + } else { + format!("{}[\"{}\"]", obj, field) + } + } + Expr::Index { object, index, safe } => { + let obj = emit_expr(out, level, object); + if *safe { + format!("({}?.[{}])", obj, index) + } else { + format!("{}[{}]", obj, index) + } + } + Expr::Call { function, args, unwrap } => { + emit_call(out, level, function, args, unwrap) + } + Expr::BinOp { op, left, right } => { + let op_str = match op { + BinOp::Add => "+", + BinOp::Subtract => "-", + BinOp::Multiply => "*", + BinOp::Divide => "/", + BinOp::Equals => "===", + BinOp::NotEquals => "!==", + BinOp::GreaterThan => ">", + BinOp::LessThan => "<", + BinOp::GreaterOrEqual => ">=", + BinOp::LessOrEqual => "<=", + BinOp::And => "&&", + BinOp::Or => "||", + BinOp::Append => { + let l = emit_expr(out, level, left); + let r = emit_expr(out, level, right); + return format!("[...{}, {}]", l, r); + } + }; + let l = emit_expr(out, level, left); + let r = emit_expr(out, level, right); + format!("({} {} {})", l, op_str, r) + } + Expr::UnaryOp { op, operand } => { + let val = emit_expr(out, level, operand); + match op { + UnaryOp::Not => format!("(!{})", val), + UnaryOp::Negate => format!("(-{})", val), + } + } + Expr::Ok(inner) => format!("[\"ok\", {}]", emit_expr(out, level, inner)), + Expr::Err(inner) => format!("[\"err\", {}]", emit_expr(out, level, inner)), + Expr::List(items) => { + let items_str: Vec = items.iter().map(|i| emit_expr(out, level, i)).collect(); + format!("[{}]", items_str.join(", ")) + } + Expr::AnonRecord { fields } => { + let mut parts = Vec::new(); + for (name, val) in fields { + parts.push(format!("\"{}\": {}", name, emit_expr(out, level, val))); + } + format!("{{{}}}", parts.join(", ")) + } + Expr::Record { type_name, fields } => { + let mut parts = vec![format!("\"_type\": \"{}\"", type_name)]; + for (name, val) in fields { + parts.push(format!("\"{}\": {}", name, emit_expr(out, level, val))); + } + format!("{{{}}}", parts.join(", ")) + } + Expr::Match { subject, arms } => emit_match_expr(out, level, subject, arms), + Expr::NilCoalesce { value, default } => { + let v = emit_expr(out, level, value); + let d = emit_expr(out, level, default); + format!("({} ?? {})", v, d) + } + Expr::Ternary { + condition, + then_expr, + else_expr, + } => { + let c = emit_expr(out, level, condition); + let t = emit_expr(out, level, then_expr); + let e = emit_expr(out, level, else_expr); + format!("({} ? {} : {})", c, t, e) + } + Expr::With { object, updates } => { + let obj = emit_expr(out, level, object); + let mut parts = vec![format!("...{}", obj)]; + for (name, val) in updates { + parts.push(format!("\"{}\": {}", name, emit_expr(out, level, val))); + } + format!("{{{}}}", parts.join(", ")) + } + Expr::MakeClosure { fn_name, captures } => { + let caps: Vec = captures.iter().map(|c| emit_expr(out, level, c)).collect(); + let cap_str = if caps.is_empty() { + String::new() + } else { + format!(", {}", caps.join(", ")) + }; + format!("((..._a) => {}(..._a{}))", js_name(fn_name), cap_str) + } + } +} + +fn emit_call( + out: &mut String, + level: usize, + function: &str, + args: &[Expr], + unwrap: &UnwrapMode, +) -> String { + // Built-in mappings + if function == "len" && args.len() == 1 { + let arg = emit_expr(out, level, &args[0]); + return format!("{}.length", arg); + } + if function == "str" && args.len() == 1 { + let arg = emit_expr(out, level, &args[0]); + return format!("String({})", arg); + } + if function == "num" && args.len() == 1 { + let arg = emit_expr(out, level, &args[0]); + // Return Result-shaped value + return format!( + "((v) => {{ const n = Number(v); return isNaN(n) ? [\"err\", String(v)] : [\"ok\", n]; }})({})", + arg + ); + } + if function == "abs" && args.len() == 1 { + return format!("Math.abs({})", emit_expr(out, level, &args[0])); + } + if function == "min" && args.len() == 2 { + let a = emit_expr(out, level, &args[0]); + let b = emit_expr(out, level, &args[1]); + return format!("Math.min({}, {})", a, b); + } + if function == "max" && args.len() == 2 { + let a = emit_expr(out, level, &args[0]); + let b = emit_expr(out, level, &args[1]); + return format!("Math.max({}, {})", a, b); + } + if function == "flr" && args.len() == 1 { + return format!("Math.floor({})", emit_expr(out, level, &args[0])); + } + if function == "cel" && args.len() == 1 { + return format!("Math.ceil({})", emit_expr(out, level, &args[0])); + } + if function == "rou" && args.len() == 1 { + return format!("Math.round({})", emit_expr(out, level, &args[0])); + } + if function == "sqrt" && args.len() == 1 { + return format!("Math.sqrt({})", emit_expr(out, level, &args[0])); + } + if function == "pi" && args.is_empty() { + return "Math.PI".to_string(); + } + if function == "tau" && args.is_empty() { + return "(2 * Math.PI)".to_string(); + } + if function == "e" && args.is_empty() { + return "Math.E".to_string(); + } + if function == "now" && args.is_empty() { + return "(Date.now() / 1000)".to_string(); + } + if function == "now-ms" && args.is_empty() { + return "Date.now()".to_string(); + } + if function == "rnd" && args.is_empty() { + return "Math.random()".to_string(); + } + if function == "rnd" && args.len() == 2 { + let lo = emit_expr(out, level, &args[0]); + let hi = emit_expr(out, level, &args[1]); + return format!( + "Math.floor(Math.random() * ({} - {} + 1) + {})", + hi, lo, lo + ); + } + if function == "trm" && args.len() == 1 { + return format!("{}.trim()", emit_expr(out, level, &args[0])); + } + if function == "prnt" && args.len() == 1 { + let arg = emit_expr(out, level, &args[0]); + return format!("((v) => {{ console.log(v); return v; }})({})", arg); + } + if function == "unq" && args.len() == 1 { + let xs = emit_expr(out, level, &args[0]); + return format!("[...new Set({})]", xs); + } + if function == "srt" && args.len() == 2 { + let key_fn = emit_expr(out, level, &args[0]); + let xs = emit_expr(out, level, &args[1]); + return format!("[...{}].sort((a, b) => {{const ka = {}(a); const kb = {}(b); return ka < kb ? -1 : ka > kb ? 1 : 0;}})", xs, key_fn, key_fn); + } + if function == "fmt" && !args.is_empty() { + // Simple positional format: replace {0}, {1}, ... with args + let tmpl = emit_expr(out, level, &args[0]); + if args.len() == 1 { + return tmpl; + } + let rest: Vec = args[1..].iter().map(|a| emit_expr(out, level, a)).collect(); + // Use replace chain for positional args + let replacements: Vec = rest + .iter() + .enumerate() + .map(|(i, val)| format!(".replace(\"{{{}}}\", {})", i, val)) + .collect(); + return format!("{}{}", tmpl, replacements.join("")); + } + if function == "jdmp" && args.len() == 1 { + return format!("JSON.stringify({})", emit_expr(out, level, &args[0])); + } + if function == "jpar" && args.len() == 1 { + let arg = emit_expr(out, level, &args[0]); + let call = format!( + "((s) => {{ try {{ return [\"ok\", JSON.parse(s)]; }} catch(e) {{ return [\"err\", e.message]; }} }})({})", + arg + ); + return if unwrap.is_any() { + format!("_iloUnwrap({})", call) + } else { + call + }; + } + if function == "env" && args.len() == 1 { + let arg = emit_expr(out, level, &args[0]); + // Node.js environment + let call = format!( + "((k) => {{ const v = typeof process !== 'undefined' && process.env[k]; return v !== undefined ? [\"ok\", v] : [\"err\", `env var '${{k}}' not set`]; }})({})", + arg + ); + return if unwrap.is_any() { + format!("_iloUnwrap({})", call) + } else { + call + }; + } + if function == "prod" && args.len() == 1 { + let xs = emit_expr(out, level, &args[0]); + return format!("{}.reduce((a, b) => a * b, 1)", xs); + } + if function == "argmax" && args.len() == 1 { + let xs = emit_expr(out, level, &args[0]); + return format!( + "((xs) => xs.reduce((mi, _, i) => xs[i] > xs[mi] ? i : mi, 0))({})", + xs + ); + } + if function == "argmin" && args.len() == 1 { + let xs = emit_expr(out, level, &args[0]); + return format!( + "((xs) => xs.reduce((mi, _, i) => xs[i] < xs[mi] ? i : mi, 0))({})", + xs + ); + } + + // Fall through: user-defined function call + let args_str: Vec = args.iter().map(|a| emit_expr(out, level, a)).collect(); + let call = format!("{}({})", js_name(function), args_str.join(", ")); + if unwrap.is_any() { + format!("_iloUnwrap({})", call) + } else { + call + } +} + +fn emit_match_expr( + out: &mut String, + level: usize, + subject: &Option>, + arms: &[MatchArm], +) -> String { + // Use an IIFE for complex matches + let subj = match subject { + Some(e) => emit_expr(out, level, e), + None => "_subject".to_string(), + }; + + // Try simple ternary chain first for literal/wildcard-only arms + let can_ternary = arms.iter().all(|arm| { + arm.body.len() == 1 + && matches!(arm.body[0].node, Stmt::Expr(_)) + && matches!( + arm.pattern, + Pattern::Wildcard | Pattern::Literal(_) + ) + }); + + if can_ternary { + let mut parts: Vec = Vec::new(); + let mut default = "undefined".to_string(); + for arm in arms { + let arm_val = match &arm.body[0].node { + Stmt::Expr(e) => emit_expr(out, level, e), + _ => "undefined".to_string(), + }; + match &arm.pattern { + Pattern::Wildcard => default = arm_val, + Pattern::Literal(lit) => { + parts.push(format!( + "{} === {} ? {}", + subj, + emit_literal(lit), + arm_val + )); + } + _ => {} + } + } + if parts.is_empty() { + return default; + } + return format!("({} : {})", parts.join(" : "), default); + } + + // IIFE for complex matches + let mut body = String::new(); + body.push_str("(() => {\n"); + let subj_var = "_s"; + indent(&mut body, level + 1); + body.push_str(&format!("const {} = {};\n", subj_var, subj)); + + for (i, arm) in arms.iter().enumerate() { + match &arm.pattern { + Pattern::Wildcard => { + if i == 0 { + // emit body directly + } else { + indent(&mut body, level + 1); + body.push_str("{\n"); + } + emit_body(&mut body, &arm.body, level + 2, true); + if i > 0 { + indent(&mut body, level + 1); + body.push_str("}\n"); + } + } + Pattern::Ok(binding) => { + let kw = if i == 0 { "if" } else { "else if" }; + indent(&mut body, level + 1); + body.push_str(&format!( + "{} ({} && {}[0] === \"ok\") {{\n", + kw, subj_var, subj_var + )); + indent(&mut body, level + 2); + body.push_str(&format!( + "const {} = {}[1];\n", + js_name(binding), + subj_var + )); + emit_body(&mut body, &arm.body, level + 2, true); + indent(&mut body, level + 1); + body.push_str("}\n"); + } + Pattern::Err(binding) => { + let kw = if i == 0 { "if" } else { "else if" }; + indent(&mut body, level + 1); + body.push_str(&format!( + "{} ({} && {}[0] === \"err\") {{\n", + kw, subj_var, subj_var + )); + indent(&mut body, level + 2); + body.push_str(&format!( + "const {} = {}[1];\n", + js_name(binding), + subj_var + )); + emit_body(&mut body, &arm.body, level + 2, true); + indent(&mut body, level + 1); + body.push_str("}\n"); + } + Pattern::Literal(lit) => { + let kw = if i == 0 { "if" } else { "else if" }; + indent(&mut body, level + 1); + body.push_str(&format!( + "{} ({} === {}) {{\n", + kw, + subj_var, + emit_literal(lit) + )); + emit_body(&mut body, &arm.body, level + 2, true); + indent(&mut body, level + 1); + body.push_str("}\n"); + } + Pattern::TypeIs { ty, binding } => { + let kw = if i == 0 { "if" } else { "else if" }; + let type_check = match ty { + Type::Number => format!("typeof {} === \"number\"", subj_var), + Type::Text => format!("typeof {} === \"string\"", subj_var), + Type::Bool => format!("typeof {} === \"boolean\"", subj_var), + _ => "true".to_string(), + }; + indent(&mut body, level + 1); + body.push_str(&format!("{} ({}) {{\n", kw, type_check)); + indent(&mut body, level + 2); + body.push_str(&format!("const {} = {};\n", js_name(binding), subj_var)); + emit_body(&mut body, &arm.body, level + 2, true); + indent(&mut body, level + 1); + body.push_str("}\n"); + } + } + } + + indent(&mut body, level); + body.push_str("})()"); + body +} + +fn emit_literal(lit: &Literal) -> String { + match lit { + Literal::Number(n) => { + if *n == (*n as i64) as f64 { + format!("{}", *n as i64) + } else { + format!("{}", n) + } + } + Literal::Text(s) => { + let escaped = s + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r"); + format!("\"{}\"", escaped) + } + Literal::Bool(b) => { + if *b { + "true".to_string() + } else { + "false".to_string() + } + } + Literal::Nil => "null".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lexer; + use crate::parser; + + fn parse_and_emit(source: &str) -> String { + let tokens: Vec = lexer::lex(source) + .unwrap() + .into_iter() + .map(|(t, _)| t) + .collect(); + let program = parser::parse_tokens(tokens).unwrap(); + emit(&program) + } + + fn parse_file_and_emit(path: &str) -> String { + let source = std::fs::read_to_string(path).unwrap(); + parse_and_emit(&source) + } + + #[test] + fn emit_js_fac() { + let js = parse_file_and_emit("examples/recursion.ilo"); + assert!(js.contains("const fac = ("), "got: {}", js); + assert!(js.contains("const fib = ("), "got: {}", js); + // Recursive calls + assert!(js.contains("fac("), "got: {}", js); + assert!(js.contains("fib("), "got: {}", js); + } + + #[test] + fn emit_js_simple_function() { + let js = parse_and_emit("tot p:n q:n r:n>n;s=*p q;t=*s r;+s t"); + assert!(js.contains("const tot = (p, q, r) => {"), "got: {}", js); + assert!(js.contains("const s = (p * q);"), "got: {}", js); + assert!(js.contains("return (s + t);"), "got: {}", js); + } + + #[test] + fn emit_js_guard() { + let js = parse_and_emit(r#"cls sp:n>t;>=sp 1000{"gold"};>=sp 500{"silver"};"bronze""#); + assert!(js.contains("const cls = (sp) => {"), "got: {}", js); + assert!(js.contains("if ((sp >= 1000))"), "got: {}", js); + assert!(js.contains("\"gold\""), "got: {}", js); + assert!(js.contains("\"bronze\""), "got: {}", js); + } + + #[test] + fn emit_js_binops() { + let js = parse_and_emit("f a:n b:n>b;=a b"); + assert!(js.contains("(a === b)"), "got: {}", js); + let js = parse_and_emit("f a:n b:n>b;!=a b"); + assert!(js.contains("(a !== b)"), "got: {}", js); + } + + #[test] + fn emit_js_len_builtin() { + let js = parse_and_emit("f s:t>n;len s"); + assert!(js.contains(".length"), "got: {}", js); + } + + #[test] + fn emit_js_kebab_to_camel() { + let js = parse_and_emit("f>t;make-id()"); + assert!(js.contains("makeId()"), "got: {}", js); + } + + #[test] + fn emit_js_ok_err() { + let js = parse_and_emit("f x:n>R n t;~x"); + assert!(js.contains("[\"ok\", x]"), "got: {}", js); + } + + #[test] + fn emit_js_foreach() { + let js = parse_and_emit("f xs:L n>n;@x xs{+x 1}"); + assert!(js.contains("for (const x of xs)"), "got: {}", js); + } + + #[test] + fn emit_js_bool_literals() { + let js = parse_and_emit("f>b;true"); + assert!(js.contains("true"), "got: {}", js); + let js = parse_and_emit("f>b;false"); + assert!(js.contains("false"), "got: {}", js); + } + + #[test] + fn emit_js_list_append() { + let js = parse_and_emit("f xs:L n>L n;+=xs 1"); + assert!(js.contains("[...xs, 1]"), "got: {}", js); + } + + #[test] + fn emit_js_nil_coalesce() { + let js = parse_and_emit("f x:O n>n;x??0"); + assert!(js.contains("??"), "got: {}", js); + } + + #[test] + fn emit_js_match_stmt() { + let js = parse_and_emit("f x:n>n;?x{1:10;_:0}"); + assert!(js.contains("if (x === 1)"), "got: {}", js); + assert!(js.contains("return 10"), "got: {}", js); + assert!(js.contains("return 0"), "got: {}", js); + } +} diff --git a/src/codegen/mod.rs b/src/codegen/mod.rs index dab61f1ba..c1beab97e 100644 --- a/src/codegen/mod.rs +++ b/src/codegen/mod.rs @@ -1,3 +1,4 @@ pub mod explain; pub mod fmt; +pub mod js; pub mod python; diff --git a/src/main.rs b/src/main.rs index 908a550b2..288d12978 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3775,8 +3775,11 @@ fn dispatch_run( if target == "python" { println!("{}", codegen::python::emit(&program)); 0 + } else if target == "js" { + println!("{}", codegen::js::emit(&program)); + 0 } else { - eprintln!("Unknown emit target. Supported: python"); + eprintln!("Unknown emit target. Supported: python, js"); 1 } } else if r.dense { @@ -4236,6 +4239,7 @@ fn print_help() { println!(" ilo [args...] Run from file"); println!(" ilo func [args...] Run a specific function"); println!(" ilo --emit python Transpile to Python"); + println!(" ilo --emit js Transpile to JavaScript (ES modules)"); println!(" ilo --explain / -x Annotate each statement with its role"); println!(" ilo --dense / -d Reformat (dense wire format)"); println!(" ilo --expanded / -e Reformat (expanded human format)"); @@ -4290,6 +4294,7 @@ fn print_help() { println!(" ilo 'f xs:L n>n;len xs' 1,2,3 Pass a list → 3"); println!(" ilo program.ilo 10 20 Run file with arguments"); println!(" ilo 'f x:n>n;*x 2' --emit python Transpile to Python"); + println!(" ilo 'f x:n>n;*x 2' --emit js Transpile to JavaScript"); } /// Dispatch --run-vm, routing to MCP / HTTP / plain run based on available providers.