From c67e3f31c3a42053f862512ef91001cffeea368d Mon Sep 17 00:00:00 2001 From: Brandon Lucas Date: Thu, 19 Feb 2026 03:35:47 -0500 Subject: [PATCH] feat: add and/or keywords, handle alias, --watch flag, JS tree-shaking - WISH-008: `and`/`or` as aliases for `&&`/`||` boolean operators - WISH-006: `handle` as alias for `run ... with` (same AST output) - WISH-005: `--watch` flag for `lux compile` recompiles on file change - WISH-009: Tree-shake unused runtime sections from JS output based on which effects are actually used (Console, Random, Time, Http, Dom) Co-Authored-By: Claude Opus 4.6 --- src/codegen/js_backend.rs | 277 ++++++++++++++++++++++++++++++-------- src/lexer.rs | 5 + src/main.rs | 70 ++++++++++ src/parser.rs | 36 +++++ 4 files changed, 334 insertions(+), 54 deletions(-) diff --git a/src/codegen/js_backend.rs b/src/codegen/js_backend.rs index b8a4be8..4df8ca8 100644 --- a/src/codegen/js_backend.rs +++ b/src/codegen/js_backend.rs @@ -69,6 +69,8 @@ pub struct JsBackend { has_handlers: bool, /// Variable substitutions for let binding var_substitutions: HashMap, + /// Effects actually used in the program (for tree-shaking runtime) + used_effects: HashSet, } impl JsBackend { @@ -90,6 +92,7 @@ impl JsBackend { effectful_functions: HashSet::new(), has_handlers: false, var_substitutions: HashMap::new(), + used_effects: HashSet::new(), } } @@ -97,9 +100,6 @@ impl JsBackend { pub fn generate(&mut self, program: &Program) -> Result { self.output.clear(); - // Emit runtime helpers - self.emit_runtime(); - // First pass: collect all function names, types, and effects for decl in &program.declarations { match decl { @@ -116,6 +116,12 @@ impl JsBackend { } } + // Collect used effects for tree-shaking + self.collect_used_effects(program); + + // Emit runtime helpers (tree-shaken based on used effects) + self.emit_runtime(); + // Emit type constructors for decl in &program.declarations { if let Declaration::Type(t) = decl { @@ -163,32 +169,181 @@ impl JsBackend { Ok(self.output.clone()) } - /// Emit the minimal Lux runtime + /// Collect all effects used in the program for runtime tree-shaking + fn collect_used_effects(&mut self, program: &Program) { + for decl in &program.declarations { + match decl { + Declaration::Function(f) => { + for effect in &f.effects { + self.used_effects.insert(effect.name.clone()); + } + self.collect_effects_from_expr(&f.body); + } + Declaration::Let(l) => { + self.collect_effects_from_expr(&l.value); + } + Declaration::Handler(h) => { + self.used_effects.insert(h.effect.name.clone()); + for imp in &h.implementations { + self.collect_effects_from_expr(&imp.body); + } + } + _ => {} + } + } + } + + /// Recursively collect effect names from an expression + fn collect_effects_from_expr(&mut self, expr: &Expr) { + match expr { + Expr::EffectOp { effect, args, .. } => { + self.used_effects.insert(effect.name.clone()); + for arg in args { + self.collect_effects_from_expr(arg); + } + } + Expr::Run { expr, handlers, .. } => { + self.collect_effects_from_expr(expr); + for (effect, handler) in handlers { + self.used_effects.insert(effect.name.clone()); + self.collect_effects_from_expr(handler); + } + } + Expr::Call { func, args, .. } => { + self.collect_effects_from_expr(func); + for arg in args { + self.collect_effects_from_expr(arg); + } + } + Expr::Lambda { body, effects, .. } => { + for effect in effects { + self.used_effects.insert(effect.name.clone()); + } + self.collect_effects_from_expr(body); + } + Expr::Let { value, body, .. } => { + self.collect_effects_from_expr(value); + self.collect_effects_from_expr(body); + } + Expr::If { condition, then_branch, else_branch, .. } => { + self.collect_effects_from_expr(condition); + self.collect_effects_from_expr(then_branch); + self.collect_effects_from_expr(else_branch); + } + Expr::Match { scrutinee, arms, .. } => { + self.collect_effects_from_expr(scrutinee); + for arm in arms { + self.collect_effects_from_expr(&arm.body); + if let Some(guard) = &arm.guard { + self.collect_effects_from_expr(guard); + } + } + } + Expr::Block { statements, result, .. } => { + for stmt in statements { + match stmt { + Statement::Expr(e) => self.collect_effects_from_expr(e), + Statement::Let { value, .. } => self.collect_effects_from_expr(value), + } + } + self.collect_effects_from_expr(result); + } + Expr::BinaryOp { left, right, .. } => { + self.collect_effects_from_expr(left); + self.collect_effects_from_expr(right); + } + Expr::UnaryOp { operand, .. } => { + self.collect_effects_from_expr(operand); + } + Expr::Field { object, .. } => { + self.collect_effects_from_expr(object); + } + Expr::TupleIndex { object, .. } => { + self.collect_effects_from_expr(object); + } + Expr::Record { spread, fields, .. } => { + if let Some(s) = spread { + self.collect_effects_from_expr(s); + } + for (_, expr) in fields { + self.collect_effects_from_expr(expr); + } + } + Expr::Tuple { elements, .. } | Expr::List { elements, .. } => { + for el in elements { + self.collect_effects_from_expr(el); + } + } + Expr::Resume { value, .. } => { + self.collect_effects_from_expr(value); + } + Expr::Literal(_) | Expr::Var(_) => {} + } + } + + /// Emit the Lux runtime, tree-shaken based on used effects fn emit_runtime(&mut self) { + let uses_console = self.used_effects.contains("Console"); + let uses_random = self.used_effects.contains("Random"); + let uses_time = self.used_effects.contains("Time"); + let uses_http = self.used_effects.contains("Http"); + let uses_dom = self.used_effects.contains("Dom"); + let uses_html = self.used_effects.contains("Html") || uses_dom; + self.writeln("// Lux Runtime"); self.writeln("const Lux = {"); self.indent += 1; - // Option helpers + // Core helpers — always emitted self.writeln("Some: (value) => ({ tag: \"Some\", value }),"); self.writeln("None: () => ({ tag: \"None\" }),"); self.writeln(""); - - // Result helpers self.writeln("Ok: (value) => ({ tag: \"Ok\", value }),"); self.writeln("Err: (error) => ({ tag: \"Err\", error }),"); self.writeln(""); - - // List helpers self.writeln("Cons: (head, tail) => [head, ...tail],"); self.writeln("Nil: () => [],"); self.writeln(""); - // Default handlers for effects + // Default handlers — only include effects that are used self.writeln("defaultHandlers: {"); self.indent += 1; - // Console effect + if uses_console { + self.emit_console_handler(); + } + if uses_random { + self.emit_random_handler(); + } + if uses_time { + self.emit_time_handler(); + } + if uses_http { + self.emit_http_handler(); + } + if uses_dom { + self.emit_dom_handler(); + } + + self.indent -= 1; + self.writeln("},"); + + // HTML rendering — only if Html or Dom effects are used + if uses_html { + self.emit_html_helpers(); + } + + // TEA runtime — only if Dom is used + if uses_dom { + self.emit_tea_runtime(); + } + + self.indent -= 1; + self.writeln("};"); + self.writeln(""); + } + + fn emit_console_handler(&mut self) { self.writeln("Console: {"); self.indent += 1; self.writeln("print: (msg) => console.log(msg),"); @@ -207,8 +362,9 @@ impl JsBackend { self.writeln("readInt: () => parseInt(Lux.defaultHandlers.Console.readLine(), 10)"); self.indent -= 1; self.writeln("},"); + } - // Random effect + fn emit_random_handler(&mut self) { self.writeln("Random: {"); self.indent += 1; self.writeln("int: (min, max) => Math.floor(Math.random() * (max - min + 1)) + min,"); @@ -216,16 +372,18 @@ impl JsBackend { self.writeln("float: () => Math.random()"); self.indent -= 1; self.writeln("},"); + } - // Time effect + fn emit_time_handler(&mut self) { self.writeln("Time: {"); self.indent += 1; self.writeln("now: () => Date.now(),"); self.writeln("sleep: (ms) => new Promise(resolve => setTimeout(resolve, ms))"); self.indent -= 1; self.writeln("},"); + } - // Http effect (browser/Node compatible) + fn emit_http_handler(&mut self) { self.writeln("Http: {"); self.indent += 1; self.writeln("get: async (url) => {"); @@ -287,8 +445,9 @@ impl JsBackend { self.writeln("}"); self.indent -= 1; self.writeln("},"); + } - // Dom effect (browser only - stubs for Node.js) + fn emit_dom_handler(&mut self) { self.writeln("Dom: {"); self.indent += 1; @@ -316,7 +475,6 @@ impl JsBackend { self.indent -= 1; self.writeln("},"); - // Element creation self.writeln("createElement: (tag) => {"); self.indent += 1; self.writeln("if (typeof document === 'undefined') return null;"); @@ -331,7 +489,6 @@ impl JsBackend { self.indent -= 1; self.writeln("},"); - // DOM manipulation self.writeln("appendChild: (parent, child) => {"); self.indent += 1; self.writeln("if (parent && child) parent.appendChild(child);"); @@ -356,7 +513,6 @@ impl JsBackend { self.indent -= 1; self.writeln("},"); - // Content self.writeln("setTextContent: (el, text) => {"); self.indent += 1; self.writeln("if (el) el.textContent = text;"); @@ -381,7 +537,6 @@ impl JsBackend { self.indent -= 1; self.writeln("},"); - // Attributes self.writeln("setAttribute: (el, name, value) => {"); self.indent += 1; self.writeln("if (el) el.setAttribute(name, value);"); @@ -408,7 +563,6 @@ impl JsBackend { self.indent -= 1; self.writeln("},"); - // Classes self.writeln("addClass: (el, className) => {"); self.indent += 1; self.writeln("if (el) el.classList.add(className);"); @@ -433,7 +587,6 @@ impl JsBackend { self.indent -= 1; self.writeln("},"); - // Styles self.writeln("setStyle: (el, property, value) => {"); self.indent += 1; self.writeln("if (el) el.style[property] = value;"); @@ -446,7 +599,6 @@ impl JsBackend { self.indent -= 1; self.writeln("},"); - // Form elements self.writeln("getValue: (el) => {"); self.indent += 1; self.writeln("return el ? el.value : '';"); @@ -471,7 +623,6 @@ impl JsBackend { self.indent -= 1; self.writeln("},"); - // Events self.writeln("addEventListener: (el, event, handler) => {"); self.indent += 1; self.writeln("if (el) el.addEventListener(event, handler);"); @@ -484,7 +635,6 @@ impl JsBackend { self.indent -= 1; self.writeln("},"); - // Focus self.writeln("focus: (el) => {"); self.indent += 1; self.writeln("if (el && el.focus) el.focus();"); @@ -497,7 +647,6 @@ impl JsBackend { self.indent -= 1; self.writeln("},"); - // Document self.writeln("getBody: () => {"); self.indent += 1; self.writeln("if (typeof document === 'undefined') return null;"); @@ -512,7 +661,6 @@ impl JsBackend { self.indent -= 1; self.writeln("},"); - // Window self.writeln("getWindow: () => {"); self.indent += 1; self.writeln("if (typeof window === 'undefined') return null;"); @@ -545,7 +693,6 @@ impl JsBackend { self.indent -= 1; self.writeln("},"); - // Scroll self.writeln("scrollTo: (x, y) => {"); self.indent += 1; self.writeln("if (typeof window !== 'undefined') window.scrollTo(x, y);"); @@ -558,7 +705,6 @@ impl JsBackend { self.indent -= 1; self.writeln("},"); - // Dimensions self.writeln("getBoundingClientRect: (el) => {"); self.indent += 1; self.writeln("if (!el) return { top: 0, left: 0, width: 0, height: 0, right: 0, bottom: 0 };"); @@ -574,13 +720,11 @@ impl JsBackend { self.indent -= 1; self.writeln("}"); - self.indent -= 1; - self.writeln("}"); - self.indent -= 1; self.writeln("},"); + } - // HTML rendering helpers + fn emit_html_helpers(&mut self) { self.writeln(""); self.writeln("// HTML rendering"); self.writeln("renderHtml: (node) => {"); @@ -682,8 +826,9 @@ impl JsBackend { self.writeln("return el;"); self.indent -= 1; self.writeln("},"); + } - // TEA (The Elm Architecture) runtime + fn emit_tea_runtime(&mut self) { self.writeln(""); self.writeln("// The Elm Architecture (TEA) runtime"); self.writeln("app: (config) => {"); @@ -727,7 +872,6 @@ impl JsBackend { self.indent -= 1; self.writeln("},"); - // Simple app (for string-based views like the counter example) self.writeln(""); self.writeln("// Simple TEA app (string-based view)"); self.writeln("simpleApp: (config) => {"); @@ -757,7 +901,6 @@ impl JsBackend { self.indent -= 1; self.writeln("},"); - // Diff and patch (basic implementation for view_deps optimization) self.writeln(""); self.writeln("// Basic diff - checks if model fields changed"); self.writeln("hasChanged: (oldModel, newModel, ...paths) => {"); @@ -777,11 +920,7 @@ impl JsBackend { self.writeln("}"); self.writeln("return false;"); self.indent -= 1; - self.writeln("}"); - - self.indent -= 1; - self.writeln("};"); - self.writeln(""); + self.writeln("},"); } /// Collect type information from a type declaration @@ -3920,7 +4059,7 @@ line3" #[test] fn test_js_runtime_generated() { - // Test that the Lux runtime is properly generated + // Test that the Lux runtime core is always generated use crate::parser::Parser; let source = r#" @@ -3931,21 +4070,51 @@ line3" let mut backend = JsBackend::new(); let js_code = backend.generate(&program).expect("Should generate"); - // Check that Lux runtime includes key functions + // Core runtime is always present assert!(js_code.contains("const Lux = {"), "Lux object should be defined"); assert!(js_code.contains("Some:"), "Option Some should be defined"); assert!(js_code.contains("None:"), "Option None should be defined"); - assert!(js_code.contains("renderHtml:"), "renderHtml should be defined"); - assert!(js_code.contains("renderToDom:"), "renderToDom should be defined"); - assert!(js_code.contains("escapeHtml:"), "escapeHtml should be defined"); - assert!(js_code.contains("app:"), "TEA app should be defined"); - assert!(js_code.contains("simpleApp:"), "simpleApp should be defined"); - assert!(js_code.contains("hasChanged:"), "hasChanged should be defined"); + + // Console-only program should NOT include Dom, Html, or TEA sections + assert!(!js_code.contains("Dom:"), "Dom handler should not be in Console-only program"); + assert!(!js_code.contains("renderHtml:"), "renderHtml should not be in Console-only program"); + assert!(!js_code.contains("app:"), "TEA app should not be in Console-only program"); + assert!(!js_code.contains("Http:"), "Http should not be in Console-only program"); + + // Console should be present + assert!(js_code.contains("Console:"), "Console handler should exist"); + } + + #[test] + fn test_js_runtime_tree_shaking_all_effects() { + // Test that all effects are included when all are used + use crate::parser::Parser; + + let source = r#" + fn main(): Unit with {Console, Dom} = { + Console.print("Hello") + let _ = Dom.getElementById("app") + () + } + "#; + + let program = Parser::parse_source(source).expect("Should parse"); + let mut backend = JsBackend::new(); + let js_code = backend.generate(&program).expect("Should generate"); + + assert!(js_code.contains("Console:"), "Console handler should exist"); + assert!(js_code.contains("Dom:"), "Dom handler should exist"); + assert!(js_code.contains("renderHtml:"), "renderHtml should be defined when Dom is used"); + assert!(js_code.contains("renderToDom:"), "renderToDom should be defined when Dom is used"); + assert!(js_code.contains("escapeHtml:"), "escapeHtml should be defined when Dom is used"); + assert!(js_code.contains("app:"), "TEA app should be defined when Dom is used"); + assert!(js_code.contains("simpleApp:"), "simpleApp should be defined when Dom is used"); + assert!(js_code.contains("hasChanged:"), "hasChanged should be defined when Dom is used"); } #[test] fn test_js_runtime_default_handlers() { - // Test that default handlers are properly generated + // Test that only used effect handlers are generated use crate::parser::Parser; let source = r#" @@ -3956,12 +4125,12 @@ line3" let mut backend = JsBackend::new(); let js_code = backend.generate(&program).expect("Should generate"); - // Check that default handlers include all effects + // Only Console should be present assert!(js_code.contains("Console:"), "Console handler should exist"); - assert!(js_code.contains("Random:"), "Random handler should exist"); - assert!(js_code.contains("Time:"), "Time handler should exist"); - assert!(js_code.contains("Http:"), "Http handler should exist"); - assert!(js_code.contains("Dom:"), "Dom handler should exist"); + assert!(!js_code.contains("Random:"), "Random handler should not exist in Console-only program"); + assert!(!js_code.contains("Time:"), "Time handler should not exist in Console-only program"); + assert!(!js_code.contains("Http:"), "Http handler should not exist in Console-only program"); + assert!(!js_code.contains("Dom:"), "Dom handler should not exist in Console-only program"); } #[test] diff --git a/src/lexer.rs b/src/lexer.rs index c973bec..cf93325 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -42,6 +42,7 @@ pub enum TokenKind { Effect, Handler, Run, + Handle, Resume, Type, True, @@ -140,6 +141,7 @@ impl fmt::Display for TokenKind { TokenKind::Effect => write!(f, "effect"), TokenKind::Handler => write!(f, "handler"), TokenKind::Run => write!(f, "run"), + TokenKind::Handle => write!(f, "handle"), TokenKind::Resume => write!(f, "resume"), TokenKind::Type => write!(f, "type"), TokenKind::Import => write!(f, "import"), @@ -771,6 +773,7 @@ impl<'a> Lexer<'a> { "effect" => TokenKind::Effect, "handler" => TokenKind::Handler, "run" => TokenKind::Run, + "handle" => TokenKind::Handle, "resume" => TokenKind::Resume, "type" => TokenKind::Type, "import" => TokenKind::Import, @@ -789,6 +792,8 @@ impl<'a> Lexer<'a> { "commutative" => TokenKind::Commutative, "where" => TokenKind::Where, "assume" => TokenKind::Assume, + "and" => TokenKind::And, + "or" => TokenKind::Or, "true" => TokenKind::Bool(true), "false" => TokenKind::Bool(false), _ => TokenKind::Ident(ident.to_string()), diff --git a/src/main.rs b/src/main.rs index d3ea16d..9879de8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -193,10 +193,12 @@ fn main() { eprintln!(" lux compile --run"); eprintln!(" lux compile --emit-c [-o file.c]"); eprintln!(" lux compile --target js [-o file.js]"); + eprintln!(" lux compile --watch"); std::process::exit(1); } let run_after = args.iter().any(|a| a == "--run"); let emit_c = args.iter().any(|a| a == "--emit-c"); + let watch = args.iter().any(|a| a == "--watch"); let target_js = args.iter() .position(|a| a == "--target") .and_then(|i| args.get(i + 1)) @@ -212,6 +214,16 @@ fn main() { } else { compile_to_c(&args[2], output_path, run_after, emit_c); } + + if watch { + // Build the args to replay for each recompilation (without --watch) + let compile_args: Vec = args.iter() + .skip(1) + .filter(|a| a.as_str() != "--watch") + .cloned() + .collect(); + watch_and_rerun(&args[2], &compile_args); + } } "repl" => { // Start REPL @@ -1351,6 +1363,64 @@ fn watch_file(path: &str) { } } +fn watch_and_rerun(path: &str, compile_args: &[String]) { + use std::time::{Duration, SystemTime}; + use std::path::Path; + + let file_path = Path::new(path); + if !file_path.exists() { + eprintln!("File not found: {}", path); + std::process::exit(1); + } + + println!(); + println!("Watching {} for changes (Ctrl+C to stop)...", path); + + let mut last_modified = std::fs::metadata(file_path) + .and_then(|m| m.modified()) + .unwrap_or(SystemTime::UNIX_EPOCH); + + loop { + std::thread::sleep(Duration::from_millis(500)); + + let modified = match std::fs::metadata(file_path).and_then(|m| m.modified()) { + Ok(m) => m, + Err(_) => continue, + }; + + if modified > last_modified { + last_modified = modified; + + // Clear screen + print!("\x1B[2J\x1B[H"); + + println!("=== Compiling {} ===", path); + println!(); + + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args(compile_args) + .status(); + + match result { + Ok(status) if status.success() => { + println!(); + println!("=== Success ==="); + } + Ok(_) => { + println!(); + println!("=== Failed ==="); + } + Err(e) => { + eprintln!("Error running compiler: {}", e); + } + } + + println!(); + println!("Watching for changes..."); + } + } +} + fn serve_static_files(dir: &str, port: u16) { use std::io::{Write, BufRead, BufReader}; use std::net::TcpListener; diff --git a/src/parser.rs b/src/parser.rs index 37d4d85..3d54860 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -245,6 +245,7 @@ impl Parser { TokenKind::Trait => Ok(Declaration::Trait(self.parse_trait_decl(visibility, doc)?)), TokenKind::Impl => Ok(Declaration::Impl(self.parse_impl_decl()?)), TokenKind::Run => Err(self.error("Bare 'run' expressions are not allowed at top level. Use 'let _ = run ...' or 'let result = run ...'")), + TokenKind::Handle => Err(self.error("Bare 'handle' expressions are not allowed at top level. Use 'let _ = handle ...' or 'let result = handle ...'")), _ => Err(self.error("Expected declaration (fn, effect, handler, type, trait, impl, or let)")), } } @@ -1775,6 +1776,7 @@ impl Parser { TokenKind::Let => self.parse_let_expr(), TokenKind::Fn => self.parse_lambda_expr(), TokenKind::Run => self.parse_run_expr(), + TokenKind::Handle => self.parse_handle_expr(), TokenKind::Resume => self.parse_resume_expr(), // Delimiters @@ -2151,6 +2153,40 @@ impl Parser { }) } + fn parse_handle_expr(&mut self) -> Result { + let start = self.current_span(); + self.expect(TokenKind::Handle)?; + + let expr = Box::new(self.parse_call_expr()?); + + self.expect(TokenKind::With)?; + self.expect(TokenKind::LBrace)?; + self.skip_newlines(); + + let mut handlers = Vec::new(); + while !self.check(TokenKind::RBrace) { + let effect = self.parse_ident()?; + self.expect(TokenKind::Eq)?; + let handler = self.parse_expr()?; + handlers.push((effect, handler)); + + self.skip_newlines(); + if self.check(TokenKind::Comma) { + self.advance(); + } + self.skip_newlines(); + } + + let end = self.current_span(); + self.expect(TokenKind::RBrace)?; + + Ok(Expr::Run { + expr, + handlers, + span: start.merge(end), + }) + } + fn parse_resume_expr(&mut self) -> Result { let start = self.current_span(); self.expect(TokenKind::Resume)?;