diff --git a/crates/opy-compiler/src/lib.rs b/crates/opy-compiler/src/lib.rs index bf67a6a..977d7f0 100644 --- a/crates/opy-compiler/src/lib.rs +++ b/crates/opy-compiler/src/lib.rs @@ -236,7 +236,8 @@ impl Compiler { )); } reject_unlowered_directives(hir)?; - let mut lowering = Lowering::new(self, hir)?; + let expanded_hir = expand_macros(hir)?; + let mut lowering = Lowering::new(self, &expanded_hir)?; lowering.copy_files()?; lowering.lower_declarations()?; lowering.lower_rules()?; @@ -347,6 +348,383 @@ fn reject_unlowered_directives(hir: &hir::Program) -> Result<(), IntegrationErro Ok(()) } +type MacroBindings = HashMap; + +struct MacroExpander { + macros: HashMap, Vec)>, + stack: Vec, +} + +fn expand_macros(program: &hir::Program) -> Result { + let macros = program + .declarations + .iter() + .filter_map(|declaration| match declaration { + hir::Declaration::Macro { + name, args, body, .. + } => Some((name.clone(), (args.clone(), body.clone()))), + _ => None, + }) + .collect(); + let mut expander = MacroExpander { + macros, + stack: Vec::new(), + }; + let mut expanded = program.clone(); + let bindings = MacroBindings::new(); + + for declaration in &mut expanded.declarations { + match declaration { + hir::Declaration::GlobalVariable { initializer, .. } + | hir::Declaration::PlayerVariable { initializer, .. } => { + if let Some(initializer) = initializer { + **initializer = expander.expand_expr(initializer, &bindings)?; + } + } + _ => {} + } + } + for entry in &mut expanded.rules { + match entry { + RuleEntry::Rule(rule) => { + for argument in &mut rule.event.args { + *argument = expander.expand_expr(argument, &bindings)?; + } + for condition in &mut rule.conditions { + *condition = expander.expand_expr(condition, &bindings)?; + } + rule.actions = expander.expand_stmts(&rule.actions, &bindings)?; + } + RuleEntry::SubroutineDef { body, .. } => { + *body = expander.expand_stmts(body, &bindings)?; + } + } + } + Ok(expanded) +} + +impl MacroExpander { + fn expand_stmts( + &mut self, + statements: &[Stmt], + bindings: &MacroBindings, + ) -> Result, IntegrationError> { + let mut expanded = Vec::new(); + for statement in statements { + if let Stmt::Expr { expr, .. } = statement { + if let Expr::MacroCall { name, args, span } = expr.as_ref() { + let args = args + .iter() + .map(|arg| self.expand_expr(arg, bindings)) + .collect::, _>>()?; + expanded.extend(self.expand_macro_body(name, &args, *span)?); + continue; + } + } + expanded.push(self.expand_stmt(statement, bindings)?); + } + Ok(expanded) + } + + fn expand_stmt( + &mut self, + statement: &Stmt, + bindings: &MacroBindings, + ) -> Result { + Ok(match statement { + Stmt::Expr { expr, span } => Stmt::Expr { + expr: Box::new(self.expand_expr(expr, bindings)?), + span: *span, + }, + Stmt::Assign { + target, + value, + span, + } => Stmt::Assign { + target: Box::new(self.expand_expr(target, bindings)?), + value: Box::new(self.expand_expr(value, bindings)?), + span: *span, + }, + Stmt::If { + branches, + r#else, + span, + } => Stmt::If { + branches: branches + .iter() + .map(|branch| { + Ok(hir::types::IfBranch { + condition: Box::new(self.expand_expr(&branch.condition, bindings)?), + body: self.expand_stmts(&branch.body, bindings)?, + }) + }) + .collect::, IntegrationError>>()?, + r#else: r#else + .as_ref() + .map(|body| self.expand_stmts(body, bindings)) + .transpose()?, + span: *span, + }, + Stmt::For { + variable, + iterable, + body, + span, + } => Stmt::For { + variable: Box::new(self.expand_expr(variable, bindings)?), + iterable: Box::new(self.expand_expr(iterable, bindings)?), + body: self.expand_stmts(body, bindings)?, + span: *span, + }, + Stmt::While { + condition, + body, + span, + } => Stmt::While { + condition: Box::new(self.expand_expr(condition, bindings)?), + body: self.expand_stmts(body, bindings)?, + span: *span, + }, + Stmt::DoWhile { + condition, + body, + span, + } => Stmt::DoWhile { + condition: Box::new(self.expand_expr(condition, bindings)?), + body: self.expand_stmts(body, bindings)?, + span: *span, + }, + Stmt::Switch { value, arms, span } => Stmt::Switch { + value: Box::new(self.expand_expr(value, bindings)?), + arms: arms + .iter() + .map(|arm| match arm { + SwitchArm::Case { value, body, span } => Ok(SwitchArm::Case { + value: Box::new(self.expand_expr(value, bindings)?), + body: self.expand_stmts(body, bindings)?, + span: *span, + }), + SwitchArm::Default { body, span } => Ok(SwitchArm::Default { + body: self.expand_stmts(body, bindings)?, + span: *span, + }), + }) + .collect::, IntegrationError>>()?, + span: *span, + }, + Stmt::Break { .. } | Stmt::CallSubroutine { .. } | Stmt::Pass { .. } => { + statement.clone() + } + }) + } + + fn expand_expr( + &mut self, + expression: &Expr, + bindings: &MacroBindings, + ) -> Result { + match expression { + Expr::MacroParam { name, span } => bindings.get(name).cloned().ok_or_else(|| { + IntegrationError::new( + "unsupported-integration-surface", + format!("macro parameter '{name}' has no expansion binding"), + *span, + ) + }), + Expr::MacroCall { name, args, span } => { + let args = args + .iter() + .map(|arg| self.expand_expr(arg, bindings)) + .collect::, _>>()?; + let body = self.expand_macro_body(name, &args, *span)?; + if body.len() != 1 { + return Err(IntegrationError::new( + "macro-invalid", + format!("macro '{name}' must produce one expression in value position"), + *span, + )); + } + match body.into_iter().next().expect("one macro body statement") { + Stmt::Expr { expr, .. } => Ok(*expr), + _ => Err(IntegrationError::new( + "macro-invalid", + format!("macro '{name}' must produce an expression in value position"), + *span, + )), + } + } + Expr::Array { elements, span } => Ok(Expr::Array { + elements: elements + .iter() + .map(|element| self.expand_expr(element, bindings)) + .collect::, _>>()?, + span: *span, + }), + Expr::Dict { entries, span } => Ok(Expr::Dict { + entries: entries + .iter() + .map(|entry| { + Ok(hir::DictEntry { + key: Box::new(self.expand_expr(&entry.key, bindings)?), + value: Box::new(self.expand_expr(&entry.value, bindings)?), + span: entry.span, + }) + }) + .collect::, IntegrationError>>()?, + span: *span, + }), + Expr::Comprehension { + element, + variable, + variable_span, + index, + index_span, + iterable, + condition, + span, + } => Ok(Expr::Comprehension { + element: Box::new(self.expand_expr(element, bindings)?), + variable: variable.clone(), + variable_span: *variable_span, + index: index.clone(), + index_span: *index_span, + iterable: Box::new(self.expand_expr(iterable, bindings)?), + condition: condition + .as_ref() + .map(|condition| self.expand_expr(condition, bindings).map(Box::new)) + .transpose()?, + span: *span, + }), + Expr::Lambda { + params, + param_spans, + body, + span, + } => Ok(Expr::Lambda { + params: params.clone(), + param_spans: param_spans.clone(), + body: Box::new(self.expand_expr(body, bindings)?), + span: *span, + }), + Expr::Vector { x, y, z, span } => Ok(Expr::Vector { + x: Box::new(self.expand_expr(x, bindings)?), + y: Box::new(self.expand_expr(y, bindings)?), + z: Box::new(self.expand_expr(z, bindings)?), + span: *span, + }), + Expr::PlayerVar { player, name, span } => Ok(Expr::PlayerVar { + player: Box::new(self.expand_expr(player, bindings)?), + name: name.clone(), + span: *span, + }), + Expr::Member { + receiver, + member, + member_span, + span, + } => Ok(Expr::Member { + receiver: Box::new(self.expand_expr(receiver, bindings)?), + member: member.clone(), + member_span: *member_span, + span: *span, + }), + Expr::Call { name, args, span } => Ok(Expr::Call { + name: name.clone(), + args: args + .iter() + .map(|arg| self.expand_expr(arg, bindings)) + .collect::, _>>()?, + span: *span, + }), + Expr::ReceiverCall { + receiver, + name, + args, + span, + } => Ok(Expr::ReceiverCall { + receiver: Box::new(self.expand_expr(receiver, bindings)?), + name: name.clone(), + args: args + .iter() + .map(|arg| self.expand_expr(arg, bindings)) + .collect::, _>>()?, + span: *span, + }), + Expr::Binary { + op, + left, + right, + span, + } => Ok(Expr::Binary { + op: op.clone(), + left: Box::new(self.expand_expr(left, bindings)?), + right: Box::new(self.expand_expr(right, bindings)?), + span: *span, + }), + Expr::Unary { op, operand, span } => Ok(Expr::Unary { + op: op.clone(), + operand: Box::new(self.expand_expr(operand, bindings)?), + span: *span, + }), + Expr::Index { array, index, span } => Ok(Expr::Index { + array: Box::new(self.expand_expr(array, bindings)?), + index: Box::new(self.expand_expr(index, bindings)?), + span: *span, + }), + Expr::Format { text, args, span } => Ok(Expr::Format { + text: text.clone(), + args: args + .iter() + .map(|arg| self.expand_expr(arg, bindings)) + .collect::, _>>()?, + span: *span, + }), + _ => Ok(expression.clone()), + } + } + + fn expand_macro_body( + &mut self, + name: &str, + args: &[Expr], + span: Option, + ) -> Result, IntegrationError> { + let Some((params, body)) = self.macros.get(name).cloned() else { + return Err(IntegrationError::new( + "unsupported-integration-surface", + format!("macro '{name}' has no declaration"), + span, + )); + }; + if params.len() != args.len() { + return Err(IntegrationError::new( + "macro-arity", + format!( + "macro '{name}' expects {} argument(s) but got {}", + params.len(), + args.len() + ), + span, + )); + } + if self.stack.iter().any(|active| active == name) { + return Err(IntegrationError::new( + "macro-recursion", + format!("recursive macro expansion detected for '{name}'"), + span, + )); + } + let mut bindings = MacroBindings::new(); + for (param, arg) in params.into_iter().zip(args.iter()) { + bindings.insert(param, arg.clone()); + } + self.stack.push(name.to_string()); + let result = self.expand_stmts(&body, &bindings); + self.stack.pop(); + result + } +} + /// A validated WIR program and its emitted Workshop artifact. pub struct CompilationArtifact { pub wir: Program, @@ -660,7 +1038,8 @@ impl<'a> Lowering<'a> { } } hir::Declaration::Macro { .. } => { - // Macro definitions are expanded by the source implementation; ignored during WIR lowering. + // Macro definitions are retained for source tooling; calls + // are expanded before this WIR lowering pass. } } } diff --git a/crates/opy-compiler/tests/issue_85_preprocessing.rs b/crates/opy-compiler/tests/issue_85_preprocessing.rs new file mode 100644 index 0000000..de822c7 --- /dev/null +++ b/crates/opy-compiler/tests/issue_85_preprocessing.rs @@ -0,0 +1,114 @@ +//! Public compile-path regression coverage for issue #85. + +use std::path::{Path, PathBuf}; + +use opy_compiler::Compiler; +use workshop_rs::catalog::Locale; +use workshop_rs::wir::{Action, RuleId, Value}; + +fn fixture_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../compatibility/fixtures/synthetic/preprocessing") +} + +#[test] +fn included_macro_call_reaches_canonical_workshop_output() { + let dir = fixture_dir(); + let source = std::fs::read_to_string(dir.join("source.opy")).unwrap(); + let artifact = Compiler::new() + .unwrap() + .compile_source(&source, "source.opy", &dir, &Locale::new("en-US")) + .expect("the included macro must lower through the public compile path"); + let rule = artifact + .wir + .rules + .get(RuleId::from_index(0)) + .expect("fixture has one rule"); + let Action::Debug { value, .. } = artifact + .wir + .actions + .get(rule.actions[0]) + .expect("fixture has one debug action") + else { + panic!("macro must lower to a debug action"); + }; + let Value::Call { name, args } = &artifact.wir.values.get(*value).unwrap().value else { + panic!("macro result must lower to a canonical value call"); + }; + assert_eq!(name, "add"); + assert_eq!(args.len(), 2); + assert!(args.iter().all(|id| matches!( + &artifact.wir.values.get(*id).unwrap().value, + Value::Number { value, .. } if *value == 1.0 + ))); +} + +#[test] +fn statement_macro_expands_into_multiple_canonical_actions() { + let source = "macro emit(value):\n debug(value)\n debug(value)\n\nrule \"r\":\n @Event global\n emit(1)\n"; + let artifact = Compiler::new() + .unwrap() + .compile_source(source, "source.opy", Path::new("."), &Locale::new("en-US")) + .expect("statement macro must expand before WIR lowering"); + let rule = artifact + .wir + .rules + .get(RuleId::from_index(0)) + .expect("source has one rule"); + assert_eq!( + rule.actions + .iter() + .filter(|id| matches!(artifact.wir.actions.get(**id), Some(Action::Debug { .. }))) + .count(), + 2 + ); +} + +#[test] +fn recursive_macro_expansion_is_a_structured_diagnostic() { + let source = + "macro recurse():\n recurse()\n\nrule \"r\":\n @Event global\n recurse()\n"; + let error = match Compiler::new().unwrap().compile_source( + source, + "source.opy", + Path::new("."), + &Locale::new("en-US"), + ) { + Ok(_) => panic!("recursive macro expansion must fail"), + Err(error) => error, + }; + assert_eq!(error.diagnostic.code, "macro-recursion"); + assert!(error.diagnostic.span.is_some()); +} + +#[test] +fn macro_arity_failure_is_a_structured_diagnostic() { + let source = + "macro double(value):\n debug(value)\n\nrule \"r\":\n @Event global\n double()\n"; + let error = match Compiler::new().unwrap().compile_source( + source, + "source.opy", + Path::new("."), + &Locale::new("en-US"), + ) { + Ok(_) => panic!("macro arity failure must be reported"), + Err(error) => error, + }; + assert_eq!(error.diagnostic.code, "macro-arity"); + assert_eq!(error.diagnostic.span.unwrap().start.line, 6); +} + +#[test] +fn failed_preprocessing_keeps_a_structured_source_diagnostic() { + let error = match Compiler::new().unwrap().compile_source( + "#!include \"missing.opy\"\nrule \"r\":\n @Event global\n pass\n", + "source.opy", + Path::new("."), + &Locale::new("en-US"), + ) { + Ok(_) => panic!("missing includes must fail in preprocessing"), + Err(error) => error, + }; + assert_eq!(error.diagnostic.code, "include-not-found"); + assert_eq!(error.diagnostic.span.unwrap().start.line, 1); +}