From 9b7c9fe5c65a0e17577f08065da80891e9ef5852 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sun, 15 Mar 2026 10:52:36 -0300 Subject: [PATCH 01/14] feat: introduce user-defined functions --- crates/tx3-lang/src/analyzing.rs | 167 +++++- crates/tx3-lang/src/ast.rs | 53 +- crates/tx3-lang/src/lowering.rs | 179 +++--- crates/tx3-lang/src/parsing.rs | 90 ++- crates/tx3-lang/src/tx3.pest | 9 +- examples/functions.ast | 926 +++++++++++++++++++++++++++++ examples/functions.tx3 | 46 ++ examples/functions.use_complex.tir | 120 ++++ examples/functions.use_double.tir | 99 +++ 9 files changed, 1582 insertions(+), 107 deletions(-) create mode 100644 examples/functions.ast create mode 100644 examples/functions.tx3 create mode 100644 examples/functions.use_complex.tir create mode 100644 examples/functions.use_double.tir diff --git a/crates/tx3-lang/src/analyzing.rs b/crates/tx3-lang/src/analyzing.rs index 55e50b02..32dba248 100644 --- a/crates/tx3-lang/src/analyzing.rs +++ b/crates/tx3-lang/src/analyzing.rs @@ -169,7 +169,7 @@ impl Error { Symbol::AssetDef(asset) => format!("AssetDef({})", asset.name.value), Symbol::EnvVar(name, _) => format!("EnvVar({})", name), Symbol::ParamVar(name, _) => format!("ParamVar({})", name), - Symbol::Function(name) => format!("Function({})", name), + Symbol::FunctionDef(fn_def) => format!("FunctionDef({})", fn_def.name.value), Symbol::LocalExpr(_) => "LocalExpr".to_string(), Symbol::Input(_) => "Input".to_string(), Symbol::Output(_) => "Output".to_string(), @@ -304,7 +304,63 @@ macro_rules! bail_report { }; } -const BUILTIN_FUNCTIONS: &[&str] = &["min_utxo", "tip_slot", "slot_to_time", "time_to_slot"]; +fn builtin_fn_defs() -> Vec { + vec![ + FnDef { + name: Identifier::new("min_utxo"), + parameters: ParameterList { + parameters: vec![ParamDef { + name: Identifier::new("output"), + r#type: Type::Int, + }], + span: Span::DUMMY, + }, + return_type: Type::AnyAsset, + body: None, + span: Span::DUMMY, + scope: None, + }, + FnDef { + name: Identifier::new("tip_slot"), + parameters: ParameterList { + parameters: vec![], + span: Span::DUMMY, + }, + return_type: Type::Int, + body: None, + span: Span::DUMMY, + scope: None, + }, + FnDef { + name: Identifier::new("slot_to_time"), + parameters: ParameterList { + parameters: vec![ParamDef { + name: Identifier::new("slot"), + r#type: Type::Int, + }], + span: Span::DUMMY, + }, + return_type: Type::Int, + body: None, + span: Span::DUMMY, + scope: None, + }, + FnDef { + name: Identifier::new("time_to_slot"), + parameters: ParameterList { + parameters: vec![ParamDef { + name: Identifier::new("time"), + r#type: Type::Int, + }], + span: Span::DUMMY, + }, + return_type: Type::Int, + body: None, + span: Span::DUMMY, + scope: None, + }, + ] +} impl Scope { pub fn new(parent: Option>) -> Self { @@ -377,6 +433,13 @@ impl Scope { ); } + pub fn track_fn_def(&mut self, fn_def: &FnDef) { + self.symbols.insert( + fn_def.name.value.clone(), + Symbol::FunctionDef(Box::new(fn_def.clone())), + ); + } + pub fn track_local_expr(&mut self, name: &str, expr: DataExpr) { self.symbols .insert(name.to_string(), Symbol::LocalExpr(Box::new(expr))); @@ -410,8 +473,6 @@ impl Scope { Some(symbol.clone()) } else if let Some(parent) = &self.parent { parent.resolve(name) - } else if BUILTIN_FUNCTIONS.contains(&name) { - Some(Symbol::Function(name.to_string())) } else { None } @@ -704,9 +765,6 @@ impl Analyzable for DataExpr { DataExpr::PropertyOp(x) => x.analyze(parent), DataExpr::AnyAssetConstructor(x) => x.analyze(parent), DataExpr::FnCall(x) => x.analyze(parent), - DataExpr::MinUtxo(x) => x.analyze(parent), - DataExpr::SlotToTime(x) => x.analyze(parent), - DataExpr::TimeToSlot(x) => x.analyze(parent), DataExpr::ConcatOp(x) => x.analyze(parent), _ => AnalyzeReport::default(), } @@ -724,9 +782,6 @@ impl Analyzable for DataExpr { DataExpr::PropertyOp(x) => x.is_resolved(), DataExpr::AnyAssetConstructor(x) => x.is_resolved(), DataExpr::FnCall(x) => x.is_resolved(), - DataExpr::MinUtxo(x) => x.is_resolved(), - DataExpr::SlotToTime(x) => x.is_resolved(), - DataExpr::TimeToSlot(x) => x.is_resolved(), DataExpr::ConcatOp(x) => x.is_resolved(), _ => true, } @@ -1210,6 +1265,77 @@ impl Analyzable for LocalsBlock { } } +impl Analyzable for LetBinding { + fn analyze(&mut self, parent: Option>) -> AnalyzeReport { + self.value.analyze(parent) + } + + fn is_resolved(&self) -> bool { + self.value.is_resolved() + } +} + +impl Analyzable for FnBody { + fn analyze(&mut self, parent: Option>) -> AnalyzeReport { + let mut report = AnalyzeReport::default(); + + for binding in &mut self.let_bindings { + report = report + binding.analyze(parent.clone()); + } + + report = report + self.result.analyze(parent); + + report + } + + fn is_resolved(&self) -> bool { + self.let_bindings.is_resolved() && self.result.is_resolved() + } +} + +impl Analyzable for FnDef { + fn analyze(&mut self, parent: Option>) -> AnalyzeReport { + let params_report = self.parameters.analyze(parent.clone()); + let return_type_report = self.return_type.analyze(parent.clone()); + + // Built-in functions have no body — nothing more to analyze + let body = match &mut self.body { + Some(body) => body, + None => return params_report + return_type_report, + }; + + let mut scope = Scope::new(parent); + + for param in self.parameters.parameters.iter() { + scope.track_param_var(¶m.name.value, param.r#type.clone()); + } + + // Add let-bindings sequentially - each binding creates a new scope layer + let mut current_scope = Rc::new(scope); + + let mut bindings_report = AnalyzeReport::default(); + + for binding in &mut body.let_bindings { + bindings_report = bindings_report + binding.analyze(Some(current_scope.clone())); + // Create a new scope layer with this binding available + let mut next_scope = Scope::new(Some(current_scope)); + next_scope.track_local_expr(&binding.name.value, binding.value.clone()); + current_scope = Rc::new(next_scope); + } + + let result_report = body.result.analyze(Some(current_scope.clone())); + + self.scope = Some(current_scope); + + params_report + return_type_report + bindings_report + result_report + } + + fn is_resolved(&self) -> bool { + self.parameters.is_resolved() + && self.body.as_ref().map_or(true, |b| b.is_resolved()) + } +} + impl Analyzable for ParamDef { fn analyze(&mut self, parent: Option>) -> AnalyzeReport { self.r#type.analyze(parent) @@ -1394,6 +1520,14 @@ impl Analyzable for Program { scope.track_alias_def(alias_def); } + for builtin in builtin_fn_defs().iter() { + scope.track_fn_def(builtin); + } + + for fn_def in self.functions.iter() { + scope.track_fn_def(fn_def); + } + self.scope = Some(Rc::new(scope)); let parties = self.parties.analyze(self.scope.clone()); @@ -1409,15 +1543,26 @@ impl Analyzable for Program { let (types, aliases) = resolve_types_and_aliases(scope_rc, &mut types, &mut aliases); + let functions = self.functions.analyze(self.scope.clone()); + + // Re-register analyzed FnDefs so txs resolve to analyzed versions + // (the originals in the scope are pre-analysis clones) + let mut fn_scope = Scope::new(self.scope.take()); + for fn_def in self.functions.iter() { + fn_scope.track_fn_def(fn_def); + } + self.scope = Some(Rc::new(fn_scope)); + let txs = self.txs.analyze(self.scope.clone()); - parties + policies + types + aliases + txs + assets + parties + policies + types + aliases + functions + txs + assets } fn is_resolved(&self) -> bool { self.policies.is_resolved() && self.types.is_resolved() && self.aliases.is_resolved() + && self.functions.is_resolved() && self.txs.is_resolved() && self.assets.is_resolved() } diff --git a/crates/tx3-lang/src/ast.rs b/crates/tx3-lang/src/ast.rs index 54578928..7708b467 100644 --- a/crates/tx3-lang/src/ast.rs +++ b/crates/tx3-lang/src/ast.rs @@ -31,7 +31,7 @@ pub enum Symbol { AliasDef(Box), RecordField(Box), VariantCase(Box), - Function(String), + FunctionDef(Box), Fees, } @@ -119,6 +119,13 @@ impl Symbol { } } + pub fn as_fn_def(&self) -> Option<&FnDef> { + match self { + Symbol::FunctionDef(x) => Some(x.as_ref()), + _ => None, + } + } + pub fn target_type(&self) -> Option { match self { Symbol::ParamVar(_, ty) => Some(ty.as_ref().clone()), @@ -180,6 +187,8 @@ pub struct Program { pub assets: Vec, pub parties: Vec, pub policies: Vec, + #[serde(default)] + pub functions: Vec, pub span: Span, // analysis @@ -734,10 +743,6 @@ pub enum DataExpr { MapConstructor(MapConstructor), AnyAssetConstructor(AnyAssetConstructor), Identifier(Identifier), - MinUtxo(Identifier), - ComputeTipSlot, - SlotToTime(Box), - TimeToSlot(Box), AddOp(AddOp), SubOp(SubOp), ConcatOp(ConcatOp), @@ -777,11 +782,12 @@ impl DataExpr { DataExpr::PropertyOp(x) => x.target_type(), DataExpr::AnyAssetConstructor(x) => x.target_type(), DataExpr::UtxoRef(_) => Some(Type::UtxoRef), - DataExpr::MinUtxo(_) => Some(Type::AnyAsset), - DataExpr::ComputeTipSlot => Some(Type::Int), - DataExpr::SlotToTime(_) => Some(Type::Int), - DataExpr::TimeToSlot(_) => Some(Type::Int), - DataExpr::FnCall(_) => None, // Function call return type determined by symbol resolution + DataExpr::FnCall(call) => call + .callee + .symbol + .as_ref() + .and_then(|s| s.as_fn_def()) + .map(|fn_def| fn_def.return_type.clone()), } } } @@ -963,6 +969,33 @@ pub struct AssetDef { pub span: Span, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct LetBinding { + pub name: Identifier, + pub value: DataExpr, + pub span: Span, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FnBody { + pub let_bindings: Vec, + pub result: Box, + pub span: Span, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FnDef { + pub name: Identifier, + pub parameters: ParameterList, + pub return_type: Type, + pub body: Option, + pub span: Span, + + // analysis + #[serde(skip)] + pub(crate) scope: Option>, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum ChainSpecificBlock { Cardano(crate::cardano::CardanoBlock), diff --git a/crates/tx3-lang/src/lowering.rs b/crates/tx3-lang/src/lowering.rs index a49dfe79..28cea7a7 100644 --- a/crates/tx3-lang/src/lowering.rs +++ b/crates/tx3-lang/src/lowering.rs @@ -412,81 +412,116 @@ impl IntoLower for ast::FnCall { fn into_lower(&self, ctx: &Context) -> Result { let function_name = &self.callee.value; - match function_name.as_str() { - "min_utxo" => { - if self.args.len() != 1 { - return Err(Error::InvalidAst(format!( - "min_utxo expects 1 argument, got {}", - self.args.len() - ))); - } - let arg = self.args[0].into_lower(ctx)?; - Ok(ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeMinUtxo(arg), - ))) - } - "tip_slot" => { - if !self.args.is_empty() { - return Err(Error::InvalidAst(format!( - "tip_slot expects 0 arguments, got {}", - self.args.len() - ))); - } - Ok(ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeTipSlot, - ))) + // Check if callee resolves to a function definition + if let Some(symbol) = self.callee.symbol.as_ref() { + if let Some(fn_def) = symbol.as_fn_def() { + return if fn_def.body.is_some() { + inline_fn_call(fn_def, &self.args, ctx) + } else { + lower_builtin_fn_call(&self.callee.value, &self.args, ctx) + }; } - "slot_to_time" => { - if self.args.len() != 1 { - return Err(Error::InvalidAst(format!( - "slot_to_time expects 1 argument, got {}", - self.args.len() - ))); - } - let arg = self.args[0].into_lower(ctx)?; - Ok(ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeSlotToTime(arg), - ))) + } + + // Try to coerce as asset - if it fails, we'll get an error + match coerce_identifier_into_asset_def(&self.callee) { + Ok(asset_def) => { + let policy = asset_def.policy.into_lower(ctx)?; + let asset_name = asset_def.asset_name.into_lower(ctx)?; + let amount = self.args[0].into_lower(ctx)?; + + Ok(ir::Expression::Assets(vec![ir::AssetExpr { + policy, + asset_name, + amount, + }])) } - "time_to_slot" => { - if self.args.len() != 1 { - return Err(Error::InvalidAst(format!( - "time_to_slot expects 1 argument, got {}", - self.args.len() - ))); - } + Err(_) => Err(Error::InvalidAst(format!( + "unknown function: {}", + function_name + ))), + } + } +} + +fn lower_builtin_fn_call( + name: &str, + args: &[ast::DataExpr], + ctx: &Context, +) -> Result { + match name { + "min_utxo" => { + let arg = args[0].into_lower(ctx)?; + Ok(ir::Expression::EvalCompiler(Box::new( + ir::CompilerOp::ComputeMinUtxo(arg), + ))) + } + "tip_slot" => Ok(ir::Expression::EvalCompiler(Box::new( + ir::CompilerOp::ComputeTipSlot, + ))), + "slot_to_time" => { + let arg = args[0].into_lower(ctx)?; + Ok(ir::Expression::EvalCompiler(Box::new( + ir::CompilerOp::ComputeSlotToTime(arg), + ))) + } + "time_to_slot" => { + let arg = args[0].into_lower(ctx)?; + Ok(ir::Expression::EvalCompiler(Box::new( + ir::CompilerOp::ComputeTimeToSlot(arg), + ))) + } + _ => Err(Error::InvalidAst(format!("unknown built-in function: {}", name))), + } +} - let arg = self.args[0].into_lower(ctx)?; +struct ParamSubstituter<'a> { + subs: &'a std::collections::HashMap, +} - Ok(ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeTimeToSlot(arg), - ))) - } - _ => { - // Try to coerce as asset - if it fails, we'll get an error - - match coerce_identifier_into_asset_def(&self.callee) { - Ok(asset_def) => { - let policy = asset_def.policy.into_lower(ctx)?; - let asset_name = asset_def.asset_name.into_lower(ctx)?; - let amount = self.args[0].into_lower(ctx)?; - - Ok(ir::Expression::Assets(vec![ir::AssetExpr { - policy, - asset_name, - amount, - }])) - } - Err(_) => Err(Error::InvalidAst(format!( - "unknown function: {}", - function_name - ))), +impl tx3_tir::Visitor for ParamSubstituter<'_> { + fn reduce( + &mut self, + expr: ir::Expression, + ) -> Result { + if let ir::Expression::EvalParam(ref param) = expr { + if let ir::Param::ExpectValue(name, _) = param.as_ref() { + if let Some(replacement) = self.subs.get(name) { + return Ok(replacement.clone()); } } } + Ok(expr) } } +fn inline_fn_call( + fn_def: &ast::FnDef, + args: &[ast::DataExpr], + ctx: &Context, +) -> Result { + let body = fn_def.body.as_ref().ok_or_else(|| { + Error::InvalidAst(format!( + "cannot inline built-in function '{}'", + fn_def.name.value + )) + })?; + + let lowered_body = body.result.into_lower(ctx)?; + + // Build substitution map: lowercased param name → lowered arg + let mut subs = std::collections::HashMap::new(); + for (param, arg) in fn_def.parameters.parameters.iter().zip(args) { + subs.insert(param.name.value.to_lowercase(), arg.into_lower(ctx)?); + } + + use tx3_tir::Node; + let mut visitor = ParamSubstituter { subs: &subs }; + lowered_body + .apply(&mut visitor) + .map_err(|e| Error::InvalidAst(e.to_string())) +} + impl IntoLower for ast::PropertyOp { type Output = ir::Expression; @@ -566,18 +601,6 @@ impl IntoLower for ast::DataExpr { ast::DataExpr::PropertyOp(x) => x.into_lower(ctx)?, ast::DataExpr::UtxoRef(x) => x.into_lower(ctx)?, ast::DataExpr::FnCall(x) => x.into_lower(ctx)?, - ast::DataExpr::MinUtxo(x) => ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeMinUtxo(x.into_lower(ctx)?), - )), - ast::DataExpr::ComputeTipSlot => { - ir::Expression::EvalCompiler(Box::new(ir::CompilerOp::ComputeTipSlot)) - } - ast::DataExpr::SlotToTime(x) => ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeSlotToTime(x.into_lower(ctx)?), - )), - ast::DataExpr::TimeToSlot(x) => ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeTimeToSlot(x.into_lower(ctx)?), - )), }; Ok(out) @@ -1030,4 +1053,6 @@ mod tests { test_lowering!(list_concat); test_lowering!(buidler_fest_2026); + + test_lowering!(functions); } diff --git a/crates/tx3-lang/src/parsing.rs b/crates/tx3-lang/src/parsing.rs index 2b34fc1c..60dbd67e 100644 --- a/crates/tx3-lang/src/parsing.rs +++ b/crates/tx3-lang/src/parsing.rs @@ -12,10 +12,8 @@ use pest::{ }; use pest_derive::Parser; -use crate::{ - ast::*, - cardano::{PlutusWitnessBlock, PlutusWitnessField}, -}; +use crate::ast::*; + #[derive(Parser)] #[grammar = "tx3.pest"] pub(crate) struct Tx3Grammar; @@ -94,6 +92,7 @@ impl AstNode for Program { aliases: Vec::new(), parties: Vec::new(), policies: Vec::new(), + functions: Vec::new(), scope: None, span, }; @@ -108,6 +107,7 @@ impl AstNode for Program { Rule::alias_def => program.aliases.push(AliasDef::parse(pair)?), Rule::party_def => program.parties.push(PartyDef::parse(pair)?), Rule::policy_def => program.policies.push(PolicyDef::parse(pair)?), + Rule::fn_def => program.functions.push(FnDef::parse(pair)?), Rule::EOI => break, x => unreachable!("Unexpected rule in program: {:?}", x), } @@ -911,6 +911,81 @@ impl AstNode for crate::ast::FnCall { } } +impl AstNode for LetBinding { + const RULE: Rule = Rule::let_binding; + + fn parse(pair: Pair) -> Result { + let span = pair.as_span().into(); + let mut inner = pair.into_inner(); + + let name = Identifier::parse(inner.next().unwrap())?; + let value = DataExpr::parse(inner.next().unwrap())?; + + Ok(LetBinding { name, value, span }) + } + + fn span(&self) -> &Span { + &self.span + } +} + +impl AstNode for FnBody { + const RULE: Rule = Rule::fn_body; + + fn parse(pair: Pair) -> Result { + let span = pair.as_span().into(); + let inner = pair.into_inner(); + + let mut let_bindings = Vec::new(); + let mut result_expr = None; + + for pair in inner { + match pair.as_rule() { + Rule::let_binding => let_bindings.push(LetBinding::parse(pair)?), + Rule::data_expr => result_expr = Some(DataExpr::parse(pair)?), + x => unreachable!("Unexpected rule in fn_body: {:?}", x), + } + } + + Ok(FnBody { + let_bindings, + result: Box::new(result_expr.expect("fn_body must have a result expression")), + span, + }) + } + + fn span(&self) -> &Span { + &self.span + } +} + +impl AstNode for FnDef { + const RULE: Rule = Rule::fn_def; + + fn parse(pair: Pair) -> Result { + let span = pair.as_span().into(); + let mut inner = pair.into_inner(); + + let name = Identifier::parse(inner.next().unwrap())?; + let parameters = ParameterList::parse(inner.next().unwrap())?; + let return_type = Type::parse(inner.next().unwrap())?; + let body = FnBody::parse(inner.next().unwrap())?; + + Ok(FnDef { + name, + parameters, + return_type, + body: Some(body), + span, + scope: None, + }) + } + + fn span(&self) -> &Span { + &self.span + } +} + impl AstNode for RecordConstructorField { const RULE: Rule = Rule::record_constructor_field; @@ -1268,10 +1343,6 @@ impl AstNode for DataExpr { DataExpr::NegateOp(x) => &x.span, DataExpr::PropertyOp(x) => &x.span, DataExpr::UtxoRef(x) => x.span(), - DataExpr::MinUtxo(x) => x.span(), - DataExpr::SlotToTime(x) => x.span(), - DataExpr::TimeToSlot(x) => x.span(), - DataExpr::ComputeTipSlot => &Span::DUMMY, // TODO DataExpr::FnCall(x) => &x.span, } } @@ -2616,6 +2687,7 @@ mod tests { env: None, assets: vec![], policies: vec![], + functions: vec![], span: Span::DUMMY, scope: None, } @@ -2817,4 +2889,6 @@ mod tests { test_parsing!(list_concat); test_parsing!(buidler_fest_2026); + + test_parsing!(functions); } diff --git a/crates/tx3-lang/src/tx3.pest b/crates/tx3-lang/src/tx3.pest index a01471ff..a9286593 100644 --- a/crates/tx3-lang/src/tx3.pest +++ b/crates/tx3-lang/src/tx3.pest @@ -445,6 +445,13 @@ env_def = { "env" ~ "{" ~ (env_field ~ ",")+ ~ "}" } +// Function definitions +let_binding = { "let" ~ identifier ~ "=" ~ data_expr ~ ";" } +fn_body = { let_binding* ~ data_expr } +fn_def = { + "fn" ~ identifier ~ parameter_list ~ "->" ~ type ~ "{" ~ fn_body ~ "}" +} + // Transaction definition tx_def = { "tx" ~ identifier ~ parameter_list ~ "{" ~ tx_body_block* ~ "}" @@ -453,6 +460,6 @@ tx_def = { // Program program = { SOI ~ - (env_def | asset_def | party_def | policy_def | type_def | tx_def)* ~ + (env_def | asset_def | party_def | policy_def | type_def | fn_def | tx_def)* ~ EOI } diff --git a/examples/functions.ast b/examples/functions.ast new file mode 100644 index 00000000..3097d1ca --- /dev/null +++ b/examples/functions.ast @@ -0,0 +1,926 @@ +{ + "env": null, + "txs": [ + { + "name": { + "value": "use_double", + "span": { + "dummy": false, + "start": 422, + "end": 432 + } + }, + "parameters": { + "parameters": [ + { + "name": { + "value": "amount", + "span": { + "dummy": false, + "start": 433, + "end": 439 + } + }, + "type": "Int" + } + ], + "span": { + "dummy": false, + "start": 432, + "end": 445 + } + }, + "locals": null, + "references": [], + "inputs": [ + { + "name": "source", + "many": false, + "fields": [ + { + "From": { + "Identifier": { + "value": "Sender", + "span": { + "dummy": false, + "start": 481, + "end": 487 + } + } + } + }, + { + "MinAmount": { + "FnCall": { + "callee": { + "value": "Ada", + "span": { + "dummy": false, + "start": 509, + "end": 512 + } + }, + "args": [ + { + "Identifier": { + "value": "amount", + "span": { + "dummy": false, + "start": 513, + "end": 519 + } + } + } + ], + "span": { + "dummy": false, + "start": 509, + "end": 520 + } + } + } + } + ], + "span": { + "dummy": false, + "start": 452, + "end": 527 + } + } + ], + "outputs": [ + { + "name": null, + "optional": false, + "fields": [ + { + "To": { + "Identifier": { + "value": "Receiver", + "span": { + "dummy": false, + "start": 553, + "end": 561 + } + } + } + }, + { + "Amount": { + "FnCall": { + "callee": { + "value": "Ada", + "span": { + "dummy": false, + "start": 579, + "end": 582 + } + }, + "args": [ + { + "FnCall": { + "callee": { + "value": "double", + "span": { + "dummy": false, + "start": 583, + "end": 589 + } + }, + "args": [ + { + "Identifier": { + "value": "amount", + "span": { + "dummy": false, + "start": 590, + "end": 596 + } + } + } + ], + "span": { + "dummy": false, + "start": 583, + "end": 597 + } + } + } + ], + "span": { + "dummy": false, + "start": 579, + "end": 598 + } + } + } + } + ], + "span": { + "dummy": false, + "start": 532, + "end": 605 + } + } + ], + "validity": null, + "mints": [], + "burns": [], + "signers": null, + "adhoc": [], + "span": { + "dummy": false, + "start": 419, + "end": 607 + }, + "collateral": [], + "metadata": null + }, + { + "name": { + "value": "use_complex", + "span": { + "dummy": false, + "start": 612, + "end": 623 + } + }, + "parameters": { + "parameters": [], + "span": { + "dummy": false, + "start": 623, + "end": 625 + } + }, + "locals": null, + "references": [], + "inputs": [ + { + "name": "source", + "many": false, + "fields": [ + { + "From": { + "Identifier": { + "value": "Sender", + "span": { + "dummy": false, + "start": 661, + "end": 667 + } + } + } + }, + { + "MinAmount": { + "FnCall": { + "callee": { + "value": "Ada", + "span": { + "dummy": false, + "start": 689, + "end": 692 + } + }, + "args": [ + { + "Number": 2000000 + } + ], + "span": { + "dummy": false, + "start": 689, + "end": 701 + } + } + } + } + ], + "span": { + "dummy": false, + "start": 632, + "end": 708 + } + } + ], + "outputs": [ + { + "name": null, + "optional": false, + "fields": [ + { + "To": { + "Identifier": { + "value": "Receiver", + "span": { + "dummy": false, + "start": 734, + "end": 742 + } + } + } + }, + { + "Amount": { + "FnCall": { + "callee": { + "value": "Ada", + "span": { + "dummy": false, + "start": 760, + "end": 763 + } + }, + "args": [ + { + "FnCall": { + "callee": { + "value": "complex", + "span": { + "dummy": false, + "start": 764, + "end": 771 + } + }, + "args": [ + { + "Number": 50 + }, + { + "Number": 75 + } + ], + "span": { + "dummy": false, + "start": 764, + "end": 779 + } + } + } + ], + "span": { + "dummy": false, + "start": 760, + "end": 780 + } + } + } + } + ], + "span": { + "dummy": false, + "start": 713, + "end": 787 + } + } + ], + "validity": null, + "mints": [], + "burns": [], + "signers": null, + "adhoc": [], + "span": { + "dummy": false, + "start": 609, + "end": 789 + }, + "collateral": [], + "metadata": null + } + ], + "types": [ + { + "name": { + "value": "PoolState", + "span": { + "dummy": false, + "start": 36, + "end": 45 + } + }, + "cases": [ + { + "name": { + "value": "Default", + "span": { + "dummy": true, + "start": 0, + "end": 0 + } + }, + "fields": [ + { + "name": { + "value": "pair_a", + "span": { + "dummy": false, + "start": 52, + "end": 58 + } + }, + "type": "Int", + "span": { + "dummy": false, + "start": 52, + "end": 63 + } + }, + { + "name": { + "value": "pair_b", + "span": { + "dummy": false, + "start": 69, + "end": 75 + } + }, + "type": "Int", + "span": { + "dummy": false, + "start": 69, + "end": 80 + } + } + ], + "span": { + "dummy": false, + "start": 31, + "end": 83 + } + } + ], + "span": { + "dummy": false, + "start": 31, + "end": 83 + } + } + ], + "aliases": [], + "assets": [], + "parties": [ + { + "name": { + "value": "Sender", + "span": { + "dummy": false, + "start": 6, + "end": 12 + } + }, + "span": { + "dummy": false, + "start": 0, + "end": 13 + } + }, + { + "name": { + "value": "Receiver", + "span": { + "dummy": false, + "start": 20, + "end": 28 + } + }, + "span": { + "dummy": false, + "start": 14, + "end": 29 + } + } + ], + "policies": [], + "functions": [ + { + "name": { + "value": "double", + "span": { + "dummy": false, + "start": 88, + "end": 94 + } + }, + "parameters": { + "parameters": [ + { + "name": { + "value": "x", + "span": { + "dummy": false, + "start": 95, + "end": 96 + } + }, + "type": "Int" + } + ], + "span": { + "dummy": false, + "start": 94, + "end": 102 + } + }, + "return_type": "Int", + "body": { + "let_bindings": [], + "result": { + "AddOp": { + "lhs": { + "Identifier": { + "value": "x", + "span": { + "dummy": false, + "start": 116, + "end": 117 + } + } + }, + "rhs": { + "Identifier": { + "value": "x", + "span": { + "dummy": false, + "start": 120, + "end": 121 + } + } + }, + "span": { + "dummy": false, + "start": 118, + "end": 119 + } + } + }, + "span": { + "dummy": false, + "start": 116, + "end": 122 + } + }, + "span": { + "dummy": false, + "start": 85, + "end": 123 + } + }, + { + "name": { + "value": "complex", + "span": { + "dummy": false, + "start": 128, + "end": 135 + } + }, + "parameters": { + "parameters": [ + { + "name": { + "value": "a", + "span": { + "dummy": false, + "start": 136, + "end": 137 + } + }, + "type": "Int" + }, + { + "name": { + "value": "b", + "span": { + "dummy": false, + "start": 144, + "end": 145 + } + }, + "type": "Int" + } + ], + "span": { + "dummy": false, + "start": 135, + "end": 151 + } + }, + "return_type": "Int", + "body": { + "let_bindings": [ + { + "name": { + "value": "base", + "span": { + "dummy": false, + "start": 169, + "end": 173 + } + }, + "value": { + "AddOp": { + "lhs": { + "Identifier": { + "value": "a", + "span": { + "dummy": false, + "start": 176, + "end": 177 + } + } + }, + "rhs": { + "Identifier": { + "value": "b", + "span": { + "dummy": false, + "start": 180, + "end": 181 + } + } + }, + "span": { + "dummy": false, + "start": 178, + "end": 179 + } + } + }, + "span": { + "dummy": false, + "start": 165, + "end": 182 + } + }, + { + "name": { + "value": "adjusted", + "span": { + "dummy": false, + "start": 191, + "end": 199 + } + }, + "value": { + "SubOp": { + "lhs": { + "Identifier": { + "value": "base", + "span": { + "dummy": false, + "start": 202, + "end": 206 + } + } + }, + "rhs": { + "Number": 100 + }, + "span": { + "dummy": false, + "start": 207, + "end": 208 + } + } + }, + "span": { + "dummy": false, + "start": 187, + "end": 213 + } + } + ], + "result": { + "AddOp": { + "lhs": { + "Identifier": { + "value": "adjusted", + "span": { + "dummy": false, + "start": 218, + "end": 226 + } + } + }, + "rhs": { + "Identifier": { + "value": "adjusted", + "span": { + "dummy": false, + "start": 229, + "end": 237 + } + } + }, + "span": { + "dummy": false, + "start": 227, + "end": 228 + } + } + }, + "span": { + "dummy": false, + "start": 165, + "end": 238 + } + }, + "span": { + "dummy": false, + "start": 125, + "end": 239 + } + }, + { + "name": { + "value": "adjust_pool", + "span": { + "dummy": false, + "start": 244, + "end": 255 + } + }, + "parameters": { + "parameters": [ + { + "name": { + "value": "pool", + "span": { + "dummy": false, + "start": 256, + "end": 260 + } + }, + "type": { + "Custom": { + "value": "PoolState", + "span": { + "dummy": true, + "start": 0, + "end": 0 + } + } + } + }, + { + "name": { + "value": "delta_a", + "span": { + "dummy": false, + "start": 273, + "end": 280 + } + }, + "type": "Int" + }, + { + "name": { + "value": "delta_b", + "span": { + "dummy": false, + "start": 287, + "end": 294 + } + }, + "type": "Int" + } + ], + "span": { + "dummy": false, + "start": 255, + "end": 300 + } + }, + "return_type": { + "Custom": { + "value": "PoolState", + "span": { + "dummy": true, + "start": 0, + "end": 0 + } + } + }, + "body": { + "let_bindings": [], + "result": { + "StructConstructor": { + "type": { + "value": "PoolState", + "span": { + "dummy": false, + "start": 320, + "end": 329 + } + }, + "case": { + "name": { + "value": "Default", + "span": { + "dummy": true, + "start": 0, + "end": 0 + } + }, + "fields": [ + { + "name": { + "value": "pair_a", + "span": { + "dummy": false, + "start": 340, + "end": 346 + } + }, + "value": { + "AddOp": { + "lhs": { + "PropertyOp": { + "operand": { + "Identifier": { + "value": "pool", + "span": { + "dummy": false, + "start": 348, + "end": 352 + } + } + }, + "property": { + "Identifier": { + "value": "pair_a", + "span": { + "dummy": false, + "start": 353, + "end": 359 + } + } + }, + "span": { + "dummy": false, + "start": 352, + "end": 359 + } + } + }, + "rhs": { + "Identifier": { + "value": "delta_a", + "span": { + "dummy": false, + "start": 362, + "end": 369 + } + } + }, + "span": { + "dummy": false, + "start": 360, + "end": 361 + } + } + }, + "span": { + "dummy": false, + "start": 340, + "end": 369 + } + }, + { + "name": { + "value": "pair_b", + "span": { + "dummy": false, + "start": 379, + "end": 385 + } + }, + "value": { + "SubOp": { + "lhs": { + "PropertyOp": { + "operand": { + "Identifier": { + "value": "pool", + "span": { + "dummy": false, + "start": 387, + "end": 391 + } + } + }, + "property": { + "Identifier": { + "value": "pair_b", + "span": { + "dummy": false, + "start": 392, + "end": 398 + } + } + }, + "span": { + "dummy": false, + "start": 391, + "end": 398 + } + } + }, + "rhs": { + "Identifier": { + "value": "delta_b", + "span": { + "dummy": false, + "start": 401, + "end": 408 + } + } + }, + "span": { + "dummy": false, + "start": 399, + "end": 400 + } + } + }, + "span": { + "dummy": false, + "start": 379, + "end": 408 + } + } + ], + "spread": null, + "span": { + "dummy": false, + "start": 330, + "end": 415 + } + }, + "span": { + "dummy": false, + "start": 320, + "end": 415 + } + } + }, + "span": { + "dummy": false, + "start": 320, + "end": 416 + } + }, + "span": { + "dummy": false, + "start": 241, + "end": 417 + } + } + ], + "span": { + "dummy": false, + "start": 0, + "end": 790 + } +} \ No newline at end of file diff --git a/examples/functions.tx3 b/examples/functions.tx3 new file mode 100644 index 00000000..d575ad8e --- /dev/null +++ b/examples/functions.tx3 @@ -0,0 +1,46 @@ +party Sender; +party Receiver; + +type PoolState { + pair_a: Int, + pair_b: Int, +} + +fn double(x: Int) -> Int { + x + x +} + +fn complex(a: Int, b: Int) -> Int { + let base = a + b; + let adjusted = base - 100; + adjusted + adjusted +} + +fn adjust_pool(pool: PoolState, delta_a: Int, delta_b: Int) -> PoolState { + PoolState { + pair_a: pool.pair_a + delta_a, + pair_b: pool.pair_b - delta_b, + } +} + +tx use_double(amount: Int) { + input source { + from: Sender, + min_amount: Ada(amount), + } + output { + to: Receiver, + amount: Ada(double(amount)), + } +} + +tx use_complex() { + input source { + from: Sender, + min_amount: Ada(2000000), + } + output { + to: Receiver, + amount: Ada(complex(50, 75)), + } +} diff --git a/examples/functions.use_complex.tir b/examples/functions.use_complex.tir new file mode 100644 index 00000000..e1ee1e7b --- /dev/null +++ b/examples/functions.use_complex.tir @@ -0,0 +1,120 @@ +{ + "fees": { + "EvalParam": "ExpectFees" + }, + "references": [], + "inputs": [ + { + "name": "source", + "utxos": { + "EvalParam": { + "ExpectInput": [ + "source", + { + "address": { + "EvalParam": { + "ExpectValue": [ + "sender", + "Address" + ] + } + }, + "min_amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "Number": 2000000 + } + } + ] + }, + "ref": "None", + "many": false, + "collateral": false + } + ] + } + }, + "redeemer": "None" + } + ], + "outputs": [ + { + "address": { + "EvalParam": { + "ExpectValue": [ + "receiver", + "Address" + ] + } + }, + "datum": "None", + "amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "EvalBuiltIn": { + "Add": [ + { + "EvalBuiltIn": { + "Sub": [ + { + "EvalBuiltIn": { + "Add": [ + { + "Number": 50 + }, + { + "Number": 75 + } + ] + } + }, + { + "Number": 100 + } + ] + } + }, + { + "EvalBuiltIn": { + "Sub": [ + { + "EvalBuiltIn": { + "Add": [ + { + "Number": 50 + }, + { + "Number": 75 + } + ] + } + }, + { + "Number": 100 + } + ] + } + } + ] + } + } + } + ] + }, + "optional": false + } + ], + "validity": null, + "mints": [], + "burns": [], + "adhoc": [], + "collateral": [], + "signers": null, + "metadata": [] +} \ No newline at end of file diff --git a/examples/functions.use_double.tir b/examples/functions.use_double.tir new file mode 100644 index 00000000..32eb07b6 --- /dev/null +++ b/examples/functions.use_double.tir @@ -0,0 +1,99 @@ +{ + "fees": { + "EvalParam": "ExpectFees" + }, + "references": [], + "inputs": [ + { + "name": "source", + "utxos": { + "EvalParam": { + "ExpectInput": [ + "source", + { + "address": { + "EvalParam": { + "ExpectValue": [ + "sender", + "Address" + ] + } + }, + "min_amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "EvalParam": { + "ExpectValue": [ + "amount", + "Int" + ] + } + } + } + ] + }, + "ref": "None", + "many": false, + "collateral": false + } + ] + } + }, + "redeemer": "None" + } + ], + "outputs": [ + { + "address": { + "EvalParam": { + "ExpectValue": [ + "receiver", + "Address" + ] + } + }, + "datum": "None", + "amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "EvalBuiltIn": { + "Add": [ + { + "EvalParam": { + "ExpectValue": [ + "amount", + "Int" + ] + } + }, + { + "EvalParam": { + "ExpectValue": [ + "amount", + "Int" + ] + } + } + ] + } + } + } + ] + }, + "optional": false + } + ], + "validity": null, + "mints": [], + "burns": [], + "adhoc": [], + "collateral": [], + "signers": null, + "metadata": [] +} \ No newline at end of file From 51972c47748e8d92133468ff5cc31f68184a8a39 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sat, 30 May 2026 18:16:05 -0300 Subject: [PATCH 02/14] test: cover functions parsing, record-returning inlining, and builtin lowering - exercise record-returning + property-access inlining via a use_pool tx in examples/functions.tx3 (nested make_pool/adjust_pool calls) - add test_lowering for tip_slot and posix_time so the builtin-as-FnDef reframing is guarded by golden TIR Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/tx3-lang/src/lowering.rs | 4 + examples/functions.ast | 589 ++++++++++++++++---- examples/functions.tx3 | 19 + examples/functions.use_pool.tir | 186 +++++++ examples/posix_time.create_timestamp_tx.tir | 133 +++++ examples/tip_slot.create_timestamp_tx.tir | 134 +++++ 6 files changed, 958 insertions(+), 107 deletions(-) create mode 100644 examples/functions.use_pool.tir create mode 100644 examples/posix_time.create_timestamp_tx.tir create mode 100644 examples/tip_slot.create_timestamp_tx.tir diff --git a/crates/tx3-lang/src/lowering.rs b/crates/tx3-lang/src/lowering.rs index b057f8de..70c56785 100644 --- a/crates/tx3-lang/src/lowering.rs +++ b/crates/tx3-lang/src/lowering.rs @@ -1071,6 +1071,10 @@ mod tests { test_lowering!(min_utxo); + test_lowering!(tip_slot); + + test_lowering!(posix_time); + test_lowering!(donation); test_lowering!(list_concat); diff --git a/examples/functions.ast b/examples/functions.ast index 3097d1ca..10ea4ee5 100644 --- a/examples/functions.ast +++ b/examples/functions.ast @@ -6,8 +6,8 @@ "value": "use_double", "span": { "dummy": false, - "start": 422, - "end": 432 + "start": 529, + "end": 539 } }, "parameters": { @@ -17,8 +17,8 @@ "value": "amount", "span": { "dummy": false, - "start": 433, - "end": 439 + "start": 540, + "end": 546 } }, "type": "Int" @@ -26,8 +26,8 @@ ], "span": { "dummy": false, - "start": 432, - "end": 445 + "start": 539, + "end": 552 } }, "locals": null, @@ -43,8 +43,8 @@ "value": "Sender", "span": { "dummy": false, - "start": 481, - "end": 487 + "start": 588, + "end": 594 } } } @@ -56,8 +56,8 @@ "value": "Ada", "span": { "dummy": false, - "start": 509, - "end": 512 + "start": 616, + "end": 619 } }, "args": [ @@ -66,16 +66,16 @@ "value": "amount", "span": { "dummy": false, - "start": 513, - "end": 519 + "start": 620, + "end": 626 } } } ], "span": { "dummy": false, - "start": 509, - "end": 520 + "start": 616, + "end": 627 } } } @@ -83,8 +83,8 @@ ], "span": { "dummy": false, - "start": 452, - "end": 527 + "start": 559, + "end": 634 } } ], @@ -99,8 +99,8 @@ "value": "Receiver", "span": { "dummy": false, - "start": 553, - "end": 561 + "start": 660, + "end": 668 } } } @@ -112,8 +112,8 @@ "value": "Ada", "span": { "dummy": false, - "start": 579, - "end": 582 + "start": 686, + "end": 689 } }, "args": [ @@ -123,8 +123,8 @@ "value": "double", "span": { "dummy": false, - "start": 583, - "end": 589 + "start": 690, + "end": 696 } }, "args": [ @@ -133,24 +133,24 @@ "value": "amount", "span": { "dummy": false, - "start": 590, - "end": 596 + "start": 697, + "end": 703 } } } ], "span": { "dummy": false, - "start": 583, - "end": 597 + "start": 690, + "end": 704 } } } ], "span": { "dummy": false, - "start": 579, - "end": 598 + "start": 686, + "end": 705 } } } @@ -158,8 +158,8 @@ ], "span": { "dummy": false, - "start": 532, - "end": 605 + "start": 639, + "end": 712 } } ], @@ -170,8 +170,8 @@ "adhoc": [], "span": { "dummy": false, - "start": 419, - "end": 607 + "start": 526, + "end": 714 }, "collateral": [], "metadata": null @@ -181,16 +181,16 @@ "value": "use_complex", "span": { "dummy": false, - "start": 612, - "end": 623 + "start": 719, + "end": 730 } }, "parameters": { "parameters": [], "span": { "dummy": false, - "start": 623, - "end": 625 + "start": 730, + "end": 732 } }, "locals": null, @@ -206,8 +206,8 @@ "value": "Sender", "span": { "dummy": false, - "start": 661, - "end": 667 + "start": 768, + "end": 774 } } } @@ -219,8 +219,8 @@ "value": "Ada", "span": { "dummy": false, - "start": 689, - "end": 692 + "start": 796, + "end": 799 } }, "args": [ @@ -230,8 +230,8 @@ ], "span": { "dummy": false, - "start": 689, - "end": 701 + "start": 796, + "end": 808 } } } @@ -239,8 +239,8 @@ ], "span": { "dummy": false, - "start": 632, - "end": 708 + "start": 739, + "end": 815 } } ], @@ -255,8 +255,8 @@ "value": "Receiver", "span": { "dummy": false, - "start": 734, - "end": 742 + "start": 841, + "end": 849 } } } @@ -268,8 +268,8 @@ "value": "Ada", "span": { "dummy": false, - "start": 760, - "end": 763 + "start": 867, + "end": 870 } }, "args": [ @@ -279,8 +279,8 @@ "value": "complex", "span": { "dummy": false, - "start": 764, - "end": 771 + "start": 871, + "end": 878 } }, "args": [ @@ -293,16 +293,16 @@ ], "span": { "dummy": false, - "start": 764, - "end": 779 + "start": 871, + "end": 886 } } } ], "span": { "dummy": false, - "start": 760, - "end": 780 + "start": 867, + "end": 887 } } } @@ -310,8 +310,8 @@ ], "span": { "dummy": false, - "start": 713, - "end": 787 + "start": 820, + "end": 894 } } ], @@ -322,8 +322,234 @@ "adhoc": [], "span": { "dummy": false, - "start": 609, - "end": 789 + "start": 716, + "end": 896 + }, + "collateral": [], + "metadata": null + }, + { + "name": { + "value": "use_pool", + "span": { + "dummy": false, + "start": 901, + "end": 909 + } + }, + "parameters": { + "parameters": [ + { + "name": { + "value": "delta_a", + "span": { + "dummy": false, + "start": 910, + "end": 917 + } + }, + "type": "Int" + }, + { + "name": { + "value": "delta_b", + "span": { + "dummy": false, + "start": 924, + "end": 931 + } + }, + "type": "Int" + } + ], + "span": { + "dummy": false, + "start": 909, + "end": 937 + } + }, + "locals": null, + "references": [], + "inputs": [ + { + "name": "source", + "many": false, + "fields": [ + { + "From": { + "Identifier": { + "value": "Sender", + "span": { + "dummy": false, + "start": 973, + "end": 979 + } + } + } + }, + { + "MinAmount": { + "FnCall": { + "callee": { + "value": "Ada", + "span": { + "dummy": false, + "start": 1001, + "end": 1004 + } + }, + "args": [ + { + "Number": 2000000 + } + ], + "span": { + "dummy": false, + "start": 1001, + "end": 1013 + } + } + } + } + ], + "span": { + "dummy": false, + "start": 944, + "end": 1020 + } + } + ], + "outputs": [ + { + "name": null, + "optional": false, + "fields": [ + { + "To": { + "Identifier": { + "value": "Receiver", + "span": { + "dummy": false, + "start": 1046, + "end": 1054 + } + } + } + }, + { + "Amount": { + "SubOp": { + "lhs": { + "Identifier": { + "value": "source", + "span": { + "dummy": false, + "start": 1072, + "end": 1078 + } + } + }, + "rhs": { + "Identifier": { + "value": "fees", + "span": { + "dummy": false, + "start": 1081, + "end": 1085 + } + } + }, + "span": { + "dummy": false, + "start": 1079, + "end": 1080 + } + } + } + }, + { + "Datum": { + "FnCall": { + "callee": { + "value": "adjust_pool", + "span": { + "dummy": false, + "start": 1102, + "end": 1113 + } + }, + "args": [ + { + "FnCall": { + "callee": { + "value": "make_pool", + "span": { + "dummy": false, + "start": 1114, + "end": 1123 + } + }, + "args": [ + { + "Number": 1000 + }, + { + "Number": 2000 + } + ], + "span": { + "dummy": false, + "start": 1114, + "end": 1135 + } + } + }, + { + "Identifier": { + "value": "delta_a", + "span": { + "dummy": false, + "start": 1137, + "end": 1144 + } + } + }, + { + "Identifier": { + "value": "delta_b", + "span": { + "dummy": false, + "start": 1146, + "end": 1153 + } + } + } + ], + "span": { + "dummy": false, + "start": 1102, + "end": 1154 + } + } + } + } + ], + "span": { + "dummy": false, + "start": 1025, + "end": 1161 + } + } + ], + "validity": null, + "mints": [], + "burns": [], + "signers": null, + "adhoc": [], + "span": { + "dummy": false, + "start": 898, + "end": 1163 }, "collateral": [], "metadata": null @@ -674,11 +900,160 @@ }, { "name": { - "value": "adjust_pool", + "value": "make_pool", "span": { "dummy": false, "start": 244, - "end": 255 + "end": 253 + } + }, + "parameters": { + "parameters": [ + { + "name": { + "value": "a", + "span": { + "dummy": false, + "start": 254, + "end": 255 + } + }, + "type": "Int" + }, + { + "name": { + "value": "b", + "span": { + "dummy": false, + "start": 262, + "end": 263 + } + }, + "type": "Int" + } + ], + "span": { + "dummy": false, + "start": 253, + "end": 269 + } + }, + "return_type": { + "Custom": { + "value": "PoolState", + "span": { + "dummy": true, + "start": 0, + "end": 0 + } + } + }, + "body": { + "let_bindings": [], + "result": { + "StructConstructor": { + "type": { + "value": "PoolState", + "span": { + "dummy": false, + "start": 289, + "end": 298 + } + }, + "case": { + "name": { + "value": "Default", + "span": { + "dummy": true, + "start": 0, + "end": 0 + } + }, + "fields": [ + { + "name": { + "value": "pair_a", + "span": { + "dummy": false, + "start": 309, + "end": 315 + } + }, + "value": { + "Identifier": { + "value": "a", + "span": { + "dummy": false, + "start": 317, + "end": 318 + } + } + }, + "span": { + "dummy": false, + "start": 309, + "end": 318 + } + }, + { + "name": { + "value": "pair_b", + "span": { + "dummy": false, + "start": 328, + "end": 334 + } + }, + "value": { + "Identifier": { + "value": "b", + "span": { + "dummy": false, + "start": 336, + "end": 337 + } + } + }, + "span": { + "dummy": false, + "start": 328, + "end": 337 + } + } + ], + "spread": null, + "span": { + "dummy": false, + "start": 299, + "end": 344 + } + }, + "span": { + "dummy": false, + "start": 289, + "end": 344 + } + } + }, + "span": { + "dummy": false, + "start": 289, + "end": 345 + } + }, + "span": { + "dummy": false, + "start": 241, + "end": 346 + } + }, + { + "name": { + "value": "adjust_pool", + "span": { + "dummy": false, + "start": 351, + "end": 362 } }, "parameters": { @@ -688,8 +1063,8 @@ "value": "pool", "span": { "dummy": false, - "start": 256, - "end": 260 + "start": 363, + "end": 367 } }, "type": { @@ -708,8 +1083,8 @@ "value": "delta_a", "span": { "dummy": false, - "start": 273, - "end": 280 + "start": 380, + "end": 387 } }, "type": "Int" @@ -719,8 +1094,8 @@ "value": "delta_b", "span": { "dummy": false, - "start": 287, - "end": 294 + "start": 394, + "end": 401 } }, "type": "Int" @@ -728,8 +1103,8 @@ ], "span": { "dummy": false, - "start": 255, - "end": 300 + "start": 362, + "end": 407 } }, "return_type": { @@ -750,8 +1125,8 @@ "value": "PoolState", "span": { "dummy": false, - "start": 320, - "end": 329 + "start": 427, + "end": 436 } }, "case": { @@ -769,8 +1144,8 @@ "value": "pair_a", "span": { "dummy": false, - "start": 340, - "end": 346 + "start": 447, + "end": 453 } }, "value": { @@ -782,8 +1157,8 @@ "value": "pool", "span": { "dummy": false, - "start": 348, - "end": 352 + "start": 455, + "end": 459 } } }, @@ -792,15 +1167,15 @@ "value": "pair_a", "span": { "dummy": false, - "start": 353, - "end": 359 + "start": 460, + "end": 466 } } }, "span": { "dummy": false, - "start": 352, - "end": 359 + "start": 459, + "end": 466 } } }, @@ -809,22 +1184,22 @@ "value": "delta_a", "span": { "dummy": false, - "start": 362, - "end": 369 + "start": 469, + "end": 476 } } }, "span": { "dummy": false, - "start": 360, - "end": 361 + "start": 467, + "end": 468 } } }, "span": { "dummy": false, - "start": 340, - "end": 369 + "start": 447, + "end": 476 } }, { @@ -832,8 +1207,8 @@ "value": "pair_b", "span": { "dummy": false, - "start": 379, - "end": 385 + "start": 486, + "end": 492 } }, "value": { @@ -845,8 +1220,8 @@ "value": "pool", "span": { "dummy": false, - "start": 387, - "end": 391 + "start": 494, + "end": 498 } } }, @@ -855,15 +1230,15 @@ "value": "pair_b", "span": { "dummy": false, - "start": 392, - "end": 398 + "start": 499, + "end": 505 } } }, "span": { "dummy": false, - "start": 391, - "end": 398 + "start": 498, + "end": 505 } } }, @@ -872,55 +1247,55 @@ "value": "delta_b", "span": { "dummy": false, - "start": 401, - "end": 408 + "start": 508, + "end": 515 } } }, "span": { "dummy": false, - "start": 399, - "end": 400 + "start": 506, + "end": 507 } } }, "span": { "dummy": false, - "start": 379, - "end": 408 + "start": 486, + "end": 515 } } ], "spread": null, "span": { "dummy": false, - "start": 330, - "end": 415 + "start": 437, + "end": 522 } }, "span": { "dummy": false, - "start": 320, - "end": 415 + "start": 427, + "end": 522 } } }, "span": { "dummy": false, - "start": 320, - "end": 416 + "start": 427, + "end": 523 } }, "span": { "dummy": false, - "start": 241, - "end": 417 + "start": 348, + "end": 524 } } ], "span": { "dummy": false, "start": 0, - "end": 790 + "end": 1164 } } \ No newline at end of file diff --git a/examples/functions.tx3 b/examples/functions.tx3 index d575ad8e..86f4db4c 100644 --- a/examples/functions.tx3 +++ b/examples/functions.tx3 @@ -16,6 +16,13 @@ fn complex(a: Int, b: Int) -> Int { adjusted + adjusted } +fn make_pool(a: Int, b: Int) -> PoolState { + PoolState { + pair_a: a, + pair_b: b, + } +} + fn adjust_pool(pool: PoolState, delta_a: Int, delta_b: Int) -> PoolState { PoolState { pair_a: pool.pair_a + delta_a, @@ -44,3 +51,15 @@ tx use_complex() { amount: Ada(complex(50, 75)), } } + +tx use_pool(delta_a: Int, delta_b: Int) { + input source { + from: Sender, + min_amount: Ada(2000000), + } + output { + to: Receiver, + amount: source - fees, + datum: adjust_pool(make_pool(1000, 2000), delta_a, delta_b), + } +} diff --git a/examples/functions.use_pool.tir b/examples/functions.use_pool.tir new file mode 100644 index 00000000..f0a1bfb5 --- /dev/null +++ b/examples/functions.use_pool.tir @@ -0,0 +1,186 @@ +{ + "fees": { + "EvalParam": "ExpectFees" + }, + "references": [], + "inputs": [ + { + "name": "source", + "utxos": { + "EvalParam": { + "ExpectInput": [ + "source", + { + "address": { + "EvalParam": { + "ExpectValue": [ + "sender", + "Address" + ] + } + }, + "min_amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "Number": 2000000 + } + } + ] + }, + "ref": "None", + "many": false, + "collateral": false + } + ] + } + }, + "redeemer": "None" + } + ], + "outputs": [ + { + "address": { + "EvalParam": { + "ExpectValue": [ + "receiver", + "Address" + ] + } + }, + "datum": { + "Struct": { + "constructor": 0, + "fields": [ + { + "EvalBuiltIn": { + "Add": [ + { + "EvalBuiltIn": { + "Property": [ + { + "Struct": { + "constructor": 0, + "fields": [ + { + "Number": 1000 + }, + { + "Number": 2000 + } + ] + } + }, + { + "Number": 0 + } + ] + } + }, + { + "EvalParam": { + "ExpectValue": [ + "delta_a", + "Int" + ] + } + } + ] + } + }, + { + "EvalBuiltIn": { + "Sub": [ + { + "EvalBuiltIn": { + "Property": [ + { + "Struct": { + "constructor": 0, + "fields": [ + { + "Number": 1000 + }, + { + "Number": 2000 + } + ] + } + }, + { + "Number": 1 + } + ] + } + }, + { + "EvalParam": { + "ExpectValue": [ + "delta_b", + "Int" + ] + } + } + ] + } + } + ] + } + }, + "amount": { + "EvalBuiltIn": { + "Sub": [ + { + "EvalCoerce": { + "IntoAssets": { + "EvalParam": { + "ExpectInput": [ + "source", + { + "address": { + "EvalParam": { + "ExpectValue": [ + "sender", + "Address" + ] + } + }, + "min_amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "Number": 2000000 + } + } + ] + }, + "ref": "None", + "many": false, + "collateral": false + } + ] + } + } + } + }, + { + "EvalParam": "ExpectFees" + } + ] + } + }, + "optional": false + } + ], + "validity": null, + "mints": [], + "burns": [], + "adhoc": [], + "collateral": [], + "signers": null, + "metadata": [] +} \ No newline at end of file diff --git a/examples/posix_time.create_timestamp_tx.tir b/examples/posix_time.create_timestamp_tx.tir new file mode 100644 index 00000000..32c4c686 --- /dev/null +++ b/examples/posix_time.create_timestamp_tx.tir @@ -0,0 +1,133 @@ +{ + "fees": { + "EvalParam": "ExpectFees" + }, + "references": [], + "inputs": [ + { + "name": "source", + "utxos": { + "EvalParam": { + "ExpectInput": [ + "source", + { + "address": { + "EvalParam": { + "ExpectValue": [ + "sender", + "Address" + ] + } + }, + "min_amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "Number": 2000000 + } + } + ] + }, + "ref": "None", + "many": false, + "collateral": false + } + ] + } + }, + "redeemer": "None" + } + ], + "outputs": [ + { + "address": { + "EvalParam": { + "ExpectValue": [ + "sender", + "Address" + ] + } + }, + "datum": { + "Struct": { + "constructor": 0, + "fields": [ + { + "EvalCompiler": { + "ComputeSlotToTime": { + "EvalCompiler": "ComputeTipSlot" + } + } + }, + { + "EvalCompiler": { + "ComputeTimeToSlot": { + "EvalParam": { + "ExpectValue": [ + "deadline", + "Int" + ] + } + } + } + } + ] + } + }, + "amount": { + "EvalBuiltIn": { + "Sub": [ + { + "EvalCoerce": { + "IntoAssets": { + "EvalParam": { + "ExpectInput": [ + "source", + { + "address": { + "EvalParam": { + "ExpectValue": [ + "sender", + "Address" + ] + } + }, + "min_amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "Number": 2000000 + } + } + ] + }, + "ref": "None", + "many": false, + "collateral": false + } + ] + } + } + } + }, + { + "EvalParam": "ExpectFees" + } + ] + } + }, + "optional": false + } + ], + "validity": null, + "mints": [], + "burns": [], + "adhoc": [], + "collateral": [], + "signers": null, + "metadata": [] +} \ No newline at end of file diff --git a/examples/tip_slot.create_timestamp_tx.tir b/examples/tip_slot.create_timestamp_tx.tir new file mode 100644 index 00000000..3b607c56 --- /dev/null +++ b/examples/tip_slot.create_timestamp_tx.tir @@ -0,0 +1,134 @@ +{ + "fees": { + "EvalParam": "ExpectFees" + }, + "references": [], + "inputs": [ + { + "name": "source", + "utxos": { + "EvalParam": { + "ExpectInput": [ + "source", + { + "address": { + "EvalParam": { + "ExpectValue": [ + "sender", + "Address" + ] + } + }, + "min_amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "Number": 2000000 + } + } + ] + }, + "ref": "None", + "many": false, + "collateral": false + } + ] + } + }, + "redeemer": "None" + } + ], + "outputs": [ + { + "address": { + "EvalParam": { + "ExpectValue": [ + "sender", + "Address" + ] + } + }, + "datum": { + "Struct": { + "constructor": 0, + "fields": [ + { + "EvalCompiler": "ComputeTipSlot" + }, + { + "EvalBuiltIn": { + "Add": [ + { + "EvalCompiler": "ComputeTipSlot" + }, + { + "EvalParam": { + "ExpectValue": [ + "validity_period", + "Int" + ] + } + } + ] + } + } + ] + } + }, + "amount": { + "EvalBuiltIn": { + "Sub": [ + { + "EvalCoerce": { + "IntoAssets": { + "EvalParam": { + "ExpectInput": [ + "source", + { + "address": { + "EvalParam": { + "ExpectValue": [ + "sender", + "Address" + ] + } + }, + "min_amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "Number": 2000000 + } + } + ] + }, + "ref": "None", + "many": false, + "collateral": false + } + ] + } + } + } + }, + { + "EvalParam": "ExpectFees" + } + ] + } + }, + "optional": false + } + ], + "validity": null, + "mints": [], + "burns": [], + "adhoc": [], + "collateral": [], + "signers": null, + "metadata": [] +} \ No newline at end of file From 68cec90ed6126eed4f96fa6fc5a2db226f647a27 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sat, 30 May 2026 18:20:36 -0300 Subject: [PATCH 03/14] docs(spec): specify user-defined functions in v1beta0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add fn_def to the grammar (§4.2.6), reserve fn/let (§3.6), add the domain-model §2.5 Functions overview, type-system §5.6.2 user-defined functions, static-semantics scoping/no-duplicate/no-recursion rules (§6.1, §6.2), and the inlining evaluation semantics (§7.13.5). Also reframe the four built-ins as functions sharing one call syntax and namespace, and drop the now-removed bare 'tip_slot' identifier form. Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/v1beta0/01-introduction.md | 4 +-- specs/v1beta0/02-domain-model.md | 41 ++++++++++++++++++----- specs/v1beta0/03-lexical-structure.md | 10 +++--- specs/v1beta0/04-syntactic-grammar.md | 24 +++++++++++-- specs/v1beta0/05-type-system.md | 36 ++++++++++++++++---- specs/v1beta0/06-static-semantics.md | 29 +++++++++++++--- specs/v1beta0/07-transaction-semantics.md | 33 ++++++++++++++++++ specs/v1beta0/09-conformance.md | 2 +- 8 files changed, 150 insertions(+), 29 deletions(-) diff --git a/specs/v1beta0/01-introduction.md b/specs/v1beta0/01-introduction.md index 4acbbdf5..7f168cf7 100644 --- a/specs/v1beta0/01-introduction.md +++ b/specs/v1beta0/01-introduction.md @@ -4,8 +4,8 @@ Tx3 is a domain-specific language for describing **transaction templates** on UTxO-based blockchains. A Tx3 program declares the shape of one or more -transactions together with the parties, assets, policies, types, and -environment values they refer to. It is not a general-purpose programming +transactions together with the parties, assets, policies, types, functions, +and environment values they refer to. It is not a general-purpose programming language and contains no constructs for unbounded loops, recursion, or side-effects. diff --git a/specs/v1beta0/02-domain-model.md b/specs/v1beta0/02-domain-model.md index 482e0029..5cf31573 100644 --- a/specs/v1beta0/02-domain-model.md +++ b/specs/v1beta0/02-domain-model.md @@ -16,6 +16,8 @@ source files. The top-level definitions, in their declared kinds, are: - *policy definitions* (`policy`): named minting/spending policies, either declared by hash literal or constructed from constituent fields. - *type definitions* (`type`): records, sum types (variants), and aliases. +- *function definitions* (`fn`): named, pure helpers that compute a data value + from typed parameters; see §2.5. - *transaction templates* (`tx`): parameterised templates for transactions. A program declares at most one `env` block. All other top-level kinds may @@ -54,13 +56,16 @@ a *data expression*. Data expressions cover: built-ins; - a restricted set of operators: addition (`+`), subtraction (`-`), prefix negation (`!`), property access (`.`), and indexing (`[…]`); -- a small set of built-in functions: `min_utxo`, `tip_slot`, `slot_to_time`, - `time_to_slot`, plus `concat` and the `AnyAsset` constructor. +- calls to *functions* — both the built-ins `min_utxo`, `tip_slot`, + `slot_to_time`, `time_to_slot` (plus `concat` and the `AnyAsset` constructor) + and user-defined functions (`fn`, §2.5) — using the same call syntax. -The expression language is intentionally restricted. It has no control flow, -no user-defined functions, no comparison operators, and no boolean -combinators. Everything that requires computation beyond simple arithmetic -and aggregation is the job of the resolver, not the source language. +The expression language is intentionally restricted. It has no control flow, no +comparison operators, and no boolean combinators. User-defined functions +(§2.5) are pure and non-recursive, and are eliminated by inlining before +resolution (§7); they add naming and reuse, not new runtime computation. +Everything that requires computation beyond simple arithmetic and aggregation +is the job of the resolver, not the source language. ## 2.4 Inputs, outputs, references, and named values @@ -82,7 +87,27 @@ Within the body of a `tx`, these names live in a single shared scope (see §6.1). Names need not be declared before use; in particular, output names may be referenced from other blocks regardless of textual order. -## 2.5 The compilation pipeline (informative) +## 2.5 Functions + +A *function* is a named, pure helper that maps typed parameters to a single +data value. A function declares a parameter list, an explicit return type, and +a body consisting of zero or more `let`-bindings followed by a result +expression (§4.2.6). Functions exist purely to name and reuse data-expression +fragments; they do not introduce new runtime behaviour. + +The language exposes a fixed set of *built-in* functions (`min_utxo`, +`tip_slot`, `slot_to_time`, `time_to_slot`) whose results are computed by the +resolver. *User-defined* functions are written with `fn` and are eliminated by +the compiler through inlining: each call site is replaced by the function's +result expression with the arguments substituted for the parameters (§7). As a +consequence: + +- functions MUST NOT be recursive, directly or indirectly; +- a function body is a data expression and MUST NOT contain transaction blocks; +- built-in and user-defined functions share one call syntax and one namespace + (§6.1), and are invoked wherever a data expression is allowed. + +## 2.6 The compilation pipeline (informative) A conforming compiler processes a Tx3 program in the following phases. The spec is normative only over the first two; lowering is described informatively @@ -99,7 +124,7 @@ Errors detected in phase 1 are *syntax errors*. Errors detected in phase 2 are *semantic errors*. A conforming compiler MUST emit at least one diagnostic for any program that violates a rule from §3–§8 (see §9). -## 2.6 Chain genericity +## 2.7 Chain genericity The core of the language (§3–§7) is intended to be chain-agnostic. Constructs that are specific to a particular target chain live under a dedicated diff --git a/specs/v1beta0/03-lexical-structure.md b/specs/v1beta0/03-lexical-structure.md index 82410c8a..703e33b0 100644 --- a/specs/v1beta0/03-lexical-structure.md +++ b/specs/v1beta0/03-lexical-structure.md @@ -60,8 +60,8 @@ where it becomes the *documentation string* of that construct: - a `party_def` (§4.2.3); - an `env_field` (§4.2.1); -- a `parameter` of a `tx_def` (§4.2.6); -- a `tx_def` (§4.2.6). +- a `parameter` of a `tx_def` (§4.2.7); +- a `tx_def` (§4.2.7). A `///` line that does not immediately precede one of the constructs above is a syntax error. Conforming compilers MAY relax this restriction as an @@ -182,9 +182,9 @@ user-defined identifiers: ``` env asset party policy type tx -input output mint burn reference collateral -validity signers metadata locals cardano bitcoin -true false +fn let input output mint burn +reference collateral validity signers metadata locals +cardano bitcoin true false Int Bool Bytes AnyAsset Address UtxoRef List Map ``` diff --git a/specs/v1beta0/04-syntactic-grammar.md b/specs/v1beta0/04-syntactic-grammar.md index c7bd8b46..62f7c14c 100644 --- a/specs/v1beta0/04-syntactic-grammar.md +++ b/specs/v1beta0/04-syntactic-grammar.md @@ -24,6 +24,7 @@ top_level_def ::= | party_def | policy_def | type_def + | fn_def | tx_def ``` @@ -111,7 +112,25 @@ Notes: variant-case field is required by the grammar above and is conformant Tx3 style. -### 4.2.6 Transaction +### 4.2.6 Function + +``` +fn_def ::= "fn" identifier parameter_list "->" type "{" fn_body "}" +fn_body ::= let_binding* data_expr +let_binding ::= "let" identifier "=" data_expr ";" +``` + +A *function definition* introduces a named, pure helper that computes a data +value. The `parameter_list` (§4.2.7) MAY be empty. The `type` after `->` is the +declared return type. A function body is an optional sequence of `let`-bindings +followed by a single result `data_expr`, whose static type MUST match the +declared return type (§5.7). Functions are top-level only: a function body MUST +NOT contain transaction blocks, and functions MUST NOT be nested. Function calls +use the `fn_call` production (§4.4) and MAY appear anywhere a `data_expr` is +valid. Static-semantic rules for functions are given in §6, and their +evaluation by inlining is specified in §7. + +### 4.2.7 Transaction ``` tx_def ::= docstring? "tx" identifier parameter_list "{" tx_body_block* "}" @@ -120,7 +139,8 @@ parameter ::= docstring? identifier ":" type ``` A *transaction definition* declares a parameterised template. The -`parameter_list` MAY be empty. Trailing commas are permitted. +`parameter_list` MAY be empty. Trailing commas are permitted. The +`parameter_list` production above is shared by `fn_def` (§4.2.6). ## 4.3 Types diff --git a/specs/v1beta0/05-type-system.md b/specs/v1beta0/05-type-system.md index d07908a5..25586039 100644 --- a/specs/v1beta0/05-type-system.md +++ b/specs/v1beta0/05-type-system.md @@ -197,10 +197,18 @@ The built-in `AnyAsset(policy, asset_name, amount)` yields a value of type `AnyAsset`. Its arguments MUST have types `Bytes`, `Bytes`, and `Int` respectively. -## 5.6 Built-in functions +## 5.6 Functions -The following identifiers, when used in `fn_call` position (§4.4), denote -built-in functions provided by every conforming compiler: +A *function* is invoked in `fn_call` position (§4.4). The result type of a call +is the function's declared (or, for built-ins, defined) return type. Each +argument MUST be assignable (§5.9) to the type of the corresponding parameter, +and the number of arguments MUST equal the number of parameters. Both built-in +and user-defined functions live in the single program-global namespace (§6.1). + +### 5.6.1 Built-in functions + +The following identifiers denote built-in functions provided by every +conforming compiler: | Name | Argument types | Result type | Notes | | ---------------- | ------------------------------- | ----------- | ----- | @@ -210,13 +218,29 @@ built-in functions provided by every conforming compiler: | `time_to_slot` | `(time : Int)` | `Int` | Converts a POSIX time to a slot number. | `min_utxo` is special: its single argument is treated as an identifier -reference rather than a general data expression. `tip_slot` MAY be invoked -either as `tip_slot()` or as the bare identifier `tip_slot` (the latter -form is accepted but using the call form is RECOMMENDED). +reference rather than a general data expression. Built-in functions MUST be +invoked using call syntax (e.g. `tip_slot()`); a bare identifier that resolves +to a built-in function is not a valid data expression. These names occupy the program-global namespace and SHOULD NOT be shadowed by user-defined identifiers. +### 5.6.2 User-defined functions + +A `fn_def` (§4.2.6) introduces a function whose signature is fully explicit: +each parameter is typed, and the return type follows `->`. Within the body, the +parameters are in scope (§6.1), and each `let`-binding introduces a name bound +to the static type of its initializer, visible to subsequent bindings and to +the result expression. The static type of the result expression MUST be +assignable (§5.9) to the declared return type; otherwise the program is +ill-typed. + +A function body is a pure data expression: it MUST NOT reference transaction +inputs, outputs, references, `fees`, or any `tx`-local symbol, and MUST NOT +contain transaction blocks. Functions MUST NOT be recursive (§6). Because +user-defined functions are eliminated by inlining (§7), they introduce no new +values or types beyond those expressible by their bodies. + ## 5.7 Built-in symbols In addition to the built-in functions above, the following bare identifiers diff --git a/specs/v1beta0/06-static-semantics.md b/specs/v1beta0/06-static-semantics.md index a3cb181f..a82f071f 100644 --- a/specs/v1beta0/06-static-semantics.md +++ b/specs/v1beta0/06-static-semantics.md @@ -17,6 +17,8 @@ The scope tree for a program has the following shape: ``` program scope +├── (per fn_def) function scope +│ └── (per let_binding) binding scope ├── (per tx_def) tx scope │ ├── (per struct/property/variant access) inner scope (§6.3) ``` @@ -30,8 +32,16 @@ The *program scope* contains: kind *asset*; - one symbol per `record_def` or `variant_def`, of kind *type*; - one symbol per `alias_def`, of kind *alias*; +- one symbol per `fn_def`, of kind *function*; - the built-in functions `min_utxo`, `tip_slot`, `slot_to_time`, - `time_to_slot` (§5.6). + `time_to_slot`, also of kind *function* (§5.6). + +A *function scope* (one per `fn_def`) extends the program scope with one symbol +per parameter, of kind *param-var*. The body's `let`-bindings are introduced +sequentially: each `let_binding` adds a *local-expr* symbol that is visible to +later bindings and to the result expression, but not to earlier bindings. A +function scope does not extend any `tx` scope, so a function body cannot +reference inputs, outputs, references, locals, or `fees` of any transaction. A *tx scope* (one per `tx_def`) extends the program scope with: @@ -70,13 +80,21 @@ the compiler MUST report a *not-in-scope* error. Within a single scope, an identifier MUST be bound by at most one definition. Multiple `record_def`/`variant_def`/`alias_def`/`party_def`/ -`policy_def`/`asset_def`/`env`-field/parameter/local/input/reference/named -output with the same name are *duplicate definitions* and MUST be -rejected. The error MAY name only the first or last occurrence. +`policy_def`/`asset_def`/`fn_def`/`env`-field/parameter/local/input/reference/ +named output with the same name are *duplicate definitions* and MUST be +rejected. The error MAY name only the first or last occurrence. A user-defined +`fn_def` that reuses the name of a built-in function (§5.6.1) is likewise a +duplicate definition. The implicit `Ada` asset definition is added by the compiler; redeclaring `Ada` at the top level is a duplicate-definition error (§5.8). +Functions MUST NOT be recursive: the call graph induced by `fn_call` +expressions in function bodies MUST be acyclic. A program containing a function +that calls itself, directly or through a cycle of other functions, is not +conforming; a conforming compiler SHOULD reject it with a diagnostic rather +than attempt the non-terminating inlining of §7. + ## 6.3 Type checking Every `data_expr` has a *static type* assigned by the analyzer. The static @@ -103,7 +121,8 @@ type is computed bottom-up as follows: static types of the first entry's key and value. - An `any_asset_constructor` has type `AnyAsset`. - A `concat_constructor` has the static type of its first argument. -- A `fn_call` to a built-in has the type listed in §5.6. +- A `fn_call` to a built-in has the type listed in §5.6.1; a `fn_call` to a + user-defined function has that function's declared return type (§5.6.2). - An `add_op` or `sub_op` has the static type of its left operand. - A `negate_op` has the static type of its operand. - A `property_op` `e.p` has the type of the property `p` on `e`'s type diff --git a/specs/v1beta0/07-transaction-semantics.md b/specs/v1beta0/07-transaction-semantics.md index 59c0ced2..dae629b9 100644 --- a/specs/v1beta0/07-transaction-semantics.md +++ b/specs/v1beta0/07-transaction-semantics.md @@ -309,6 +309,39 @@ implied by the policy. The semantics of `script` and `ref` fields are chain-specific and are described in §8 for Cardano targets. +### 7.13.5 `fn` + +```tx3 +fn double(x: Int) -> Int { + x + x +} + +fn discounted(base: Int, off: Int) -> Int { + let net = base - off; + net +} +``` + +A `fn_def` declares a pure, non-recursive helper (§2.5, §5.6.2). It contributes +no blocks or structure to any transaction; instead, every call to a +user-defined function is **inlined** during lowering: + +1. The function's result expression is taken as a template, with its + `let`-bindings substituted into it (each binding's name replaced by its + initializer, in declaration order). +2. Each parameter occurrence in that template is replaced by the corresponding + call argument expression, evaluated in the caller's scope. +3. The resulting expression is spliced in at the call site, as though the + programmer had written it inline. + +Because arguments are substituted positionally and functions are non-recursive +(§6.2), inlining terminates and yields an ordinary data expression. A call +therefore has no observable effect beyond that of its expansion: user-defined +functions are a source-level convenience and leave no trace in the lowered TIR. +Calls to built-in functions (§5.6.1) are not inlined; they lower to the +corresponding resolver operation (e.g. `min_utxo` → minimum-UTxO computation, +§7.5). + ## 7.14 Cross-block constraints Within a single `tx_def`, the following requirements apply across blocks: diff --git a/specs/v1beta0/09-conformance.md b/specs/v1beta0/09-conformance.md index d29072d3..2aa1eca6 100644 --- a/specs/v1beta0/09-conformance.md +++ b/specs/v1beta0/09-conformance.md @@ -27,7 +27,7 @@ source file: 1. **MUST** report no diagnostics, beyond optional warnings, when given a conforming program. The program MUST be processed up to and including - the static-semantic phase (§2.5) without rejection. + the static-semantic phase (§2.6) without rejection. 2. **MUST** emit at least one diagnostic in the appropriate category (§6.9) for any program that violates a MUST or MUST NOT from §3–§8. 3. **MUST** preserve the meaning of every conforming program when it From c7649c0cc663191a552b6e8dec631501dd611e1b Mon Sep 17 00:00:00 2001 From: Santiago Date: Sat, 30 May 2026 18:47:27 -0300 Subject: [PATCH 04/14] fix(lang): resolve function-to-function calls Function bodies were analyzed against a scope holding pre-analysis clones of the FnDefs, so a call to another function embedded an unanalyzed callee whose body identifiers had no resolved symbols. Lowering inlines the callee's analyzed body, so such calls failed with MissingAnalyzePhase. Analyze functions to a fixed point: each pass re-registers the progressively-analyzed definitions and re-resolves call sites against them. Functions are non-recursive, so the definition count bounds the longest call chain and the iteration terminates. Covered by a new nested_functions example exercising a depth-2 chain. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/tx3-lang/src/analyzing.rs | 23 +- crates/tx3-lang/src/lowering.rs | 2 + crates/tx3-lang/src/parsing.rs | 2 + examples/nested_functions.ast | 473 ++++++++++++++++++++++++++++++ examples/nested_functions.run.tir | 121 ++++++++ examples/nested_functions.tx3 | 25 ++ 6 files changed, 642 insertions(+), 4 deletions(-) create mode 100644 examples/nested_functions.ast create mode 100644 examples/nested_functions.run.tir create mode 100644 examples/nested_functions.tx3 diff --git a/crates/tx3-lang/src/analyzing.rs b/crates/tx3-lang/src/analyzing.rs index 13f0d399..70cf097a 100644 --- a/crates/tx3-lang/src/analyzing.rs +++ b/crates/tx3-lang/src/analyzing.rs @@ -1566,11 +1566,26 @@ impl Analyzable for Program { let (types, aliases) = resolve_types_and_aliases(scope_rc, &mut types, &mut aliases); - let functions = self.functions.analyze(self.scope.clone()); + // Functions may call other functions, and lowering inlines a callee's + // *analyzed* body. A single analysis pass leaves each call site holding + // a pre-analysis clone of its callee (whose own body is unresolved), so + // we analyze to a fixed point: each pass re-registers the + // progressively-analyzed definitions and re-resolves call sites against + // them. Functions are non-recursive (the call graph is acyclic), so the + // number of definitions is a sufficient upper bound on the longest call + // chain and the iteration terminates. + let program_scope = self.scope.clone(); + let mut functions = AnalyzeReport::default(); + for _ in 0..self.functions.len() { + let mut fn_scope = Scope::new(program_scope.clone()); + for fn_def in self.functions.iter() { + fn_scope.track_fn_def(fn_def); + } + functions = self.functions.analyze(Some(Rc::new(fn_scope))); + } - // Re-register analyzed FnDefs so txs resolve to analyzed versions - // (the originals in the scope are pre-analysis clones) - let mut fn_scope = Scope::new(self.scope.take()); + // Final scope: txs resolve calls to the fully-analyzed definitions. + let mut fn_scope = Scope::new(program_scope); for fn_def in self.functions.iter() { fn_scope.track_fn_def(fn_def); } diff --git a/crates/tx3-lang/src/lowering.rs b/crates/tx3-lang/src/lowering.rs index 70c56785..9c66af29 100644 --- a/crates/tx3-lang/src/lowering.rs +++ b/crates/tx3-lang/src/lowering.rs @@ -1083,6 +1083,8 @@ mod tests { test_lowering!(functions); + test_lowering!(nested_functions); + test_lowering!(param_field_shadow); test_lowering!(oracle_reference_datum); diff --git a/crates/tx3-lang/src/parsing.rs b/crates/tx3-lang/src/parsing.rs index c00df36a..77ae8cff 100644 --- a/crates/tx3-lang/src/parsing.rs +++ b/crates/tx3-lang/src/parsing.rs @@ -3046,4 +3046,6 @@ mod tests { test_parsing!(buidler_fest_2026); test_parsing!(functions); + + test_parsing!(nested_functions); } diff --git a/examples/nested_functions.ast b/examples/nested_functions.ast new file mode 100644 index 00000000..6b90394b --- /dev/null +++ b/examples/nested_functions.ast @@ -0,0 +1,473 @@ +{ + "env": null, + "txs": [ + { + "name": { + "value": "run", + "span": { + "dummy": false, + "start": 161, + "end": 164 + } + }, + "parameters": { + "parameters": [ + { + "name": { + "value": "amount", + "span": { + "dummy": false, + "start": 165, + "end": 171 + } + }, + "type": "Int" + } + ], + "span": { + "dummy": false, + "start": 164, + "end": 177 + } + }, + "locals": null, + "references": [], + "inputs": [ + { + "name": "source", + "many": false, + "fields": [ + { + "From": { + "Identifier": { + "value": "Sender", + "span": { + "dummy": false, + "start": 213, + "end": 219 + } + } + } + }, + { + "MinAmount": { + "FnCall": { + "callee": { + "value": "Ada", + "span": { + "dummy": false, + "start": 241, + "end": 244 + } + }, + "args": [ + { + "Identifier": { + "value": "amount", + "span": { + "dummy": false, + "start": 245, + "end": 251 + } + } + } + ], + "span": { + "dummy": false, + "start": 241, + "end": 252 + } + } + } + } + ], + "span": { + "dummy": false, + "start": 184, + "end": 259 + } + } + ], + "outputs": [ + { + "name": null, + "optional": false, + "fields": [ + { + "To": { + "Identifier": { + "value": "Receiver", + "span": { + "dummy": false, + "start": 285, + "end": 293 + } + } + } + }, + { + "Amount": { + "FnCall": { + "callee": { + "value": "Ada", + "span": { + "dummy": false, + "start": 311, + "end": 314 + } + }, + "args": [ + { + "FnCall": { + "callee": { + "value": "inc4", + "span": { + "dummy": false, + "start": 315, + "end": 319 + } + }, + "args": [ + { + "Identifier": { + "value": "amount", + "span": { + "dummy": false, + "start": 320, + "end": 326 + } + } + } + ], + "span": { + "dummy": false, + "start": 315, + "end": 327 + } + } + } + ], + "span": { + "dummy": false, + "start": 311, + "end": 328 + } + } + } + } + ], + "span": { + "dummy": false, + "start": 264, + "end": 335 + } + } + ], + "validity": null, + "mints": [], + "burns": [], + "signers": null, + "adhoc": [], + "span": { + "dummy": false, + "start": 158, + "end": 337 + }, + "collateral": [], + "metadata": null + } + ], + "types": [], + "aliases": [], + "assets": [], + "parties": [ + { + "name": { + "value": "Sender", + "span": { + "dummy": false, + "start": 6, + "end": 12 + } + }, + "span": { + "dummy": false, + "start": 0, + "end": 13 + } + }, + { + "name": { + "value": "Receiver", + "span": { + "dummy": false, + "start": 20, + "end": 28 + } + }, + "span": { + "dummy": false, + "start": 14, + "end": 29 + } + } + ], + "policies": [], + "functions": [ + { + "name": { + "value": "inc", + "span": { + "dummy": false, + "start": 34, + "end": 37 + } + }, + "parameters": { + "parameters": [ + { + "name": { + "value": "x", + "span": { + "dummy": false, + "start": 38, + "end": 39 + } + }, + "type": "Int" + } + ], + "span": { + "dummy": false, + "start": 37, + "end": 45 + } + }, + "return_type": "Int", + "body": { + "let_bindings": [], + "result": { + "AddOp": { + "lhs": { + "Identifier": { + "value": "x", + "span": { + "dummy": false, + "start": 59, + "end": 60 + } + } + }, + "rhs": { + "Number": 1 + }, + "span": { + "dummy": false, + "start": 61, + "end": 62 + } + } + }, + "span": { + "dummy": false, + "start": 59, + "end": 65 + } + }, + "span": { + "dummy": false, + "start": 31, + "end": 66 + } + }, + { + "name": { + "value": "inc2", + "span": { + "dummy": false, + "start": 71, + "end": 75 + } + }, + "parameters": { + "parameters": [ + { + "name": { + "value": "x", + "span": { + "dummy": false, + "start": 76, + "end": 77 + } + }, + "type": "Int" + } + ], + "span": { + "dummy": false, + "start": 75, + "end": 83 + } + }, + "return_type": "Int", + "body": { + "let_bindings": [], + "result": { + "FnCall": { + "callee": { + "value": "inc", + "span": { + "dummy": false, + "start": 97, + "end": 100 + } + }, + "args": [ + { + "FnCall": { + "callee": { + "value": "inc", + "span": { + "dummy": false, + "start": 101, + "end": 104 + } + }, + "args": [ + { + "Identifier": { + "value": "x", + "span": { + "dummy": false, + "start": 105, + "end": 106 + } + } + } + ], + "span": { + "dummy": false, + "start": 101, + "end": 107 + } + } + } + ], + "span": { + "dummy": false, + "start": 97, + "end": 108 + } + } + }, + "span": { + "dummy": false, + "start": 97, + "end": 109 + } + }, + "span": { + "dummy": false, + "start": 68, + "end": 110 + } + }, + { + "name": { + "value": "inc4", + "span": { + "dummy": false, + "start": 115, + "end": 119 + } + }, + "parameters": { + "parameters": [ + { + "name": { + "value": "x", + "span": { + "dummy": false, + "start": 120, + "end": 121 + } + }, + "type": "Int" + } + ], + "span": { + "dummy": false, + "start": 119, + "end": 127 + } + }, + "return_type": "Int", + "body": { + "let_bindings": [], + "result": { + "FnCall": { + "callee": { + "value": "inc2", + "span": { + "dummy": false, + "start": 141, + "end": 145 + } + }, + "args": [ + { + "FnCall": { + "callee": { + "value": "inc2", + "span": { + "dummy": false, + "start": 146, + "end": 150 + } + }, + "args": [ + { + "Identifier": { + "value": "x", + "span": { + "dummy": false, + "start": 151, + "end": 152 + } + } + } + ], + "span": { + "dummy": false, + "start": 146, + "end": 153 + } + } + } + ], + "span": { + "dummy": false, + "start": 141, + "end": 154 + } + } + }, + "span": { + "dummy": false, + "start": 141, + "end": 155 + } + }, + "span": { + "dummy": false, + "start": 112, + "end": 156 + } + } + ], + "span": { + "dummy": false, + "start": 0, + "end": 338 + } +} \ No newline at end of file diff --git a/examples/nested_functions.run.tir b/examples/nested_functions.run.tir new file mode 100644 index 00000000..1041b9f0 --- /dev/null +++ b/examples/nested_functions.run.tir @@ -0,0 +1,121 @@ +{ + "fees": { + "EvalParam": "ExpectFees" + }, + "references": [], + "inputs": [ + { + "name": "source", + "utxos": { + "EvalParam": { + "ExpectInput": [ + "source", + { + "address": { + "EvalParam": { + "ExpectValue": [ + "sender", + "Address" + ] + } + }, + "min_amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "EvalParam": { + "ExpectValue": [ + "amount", + "Int" + ] + } + } + } + ] + }, + "ref": "None", + "many": false, + "collateral": false + } + ] + } + }, + "redeemer": "None" + } + ], + "outputs": [ + { + "address": { + "EvalParam": { + "ExpectValue": [ + "receiver", + "Address" + ] + } + }, + "datum": "None", + "amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "EvalBuiltIn": { + "Add": [ + { + "EvalBuiltIn": { + "Add": [ + { + "EvalBuiltIn": { + "Add": [ + { + "EvalBuiltIn": { + "Add": [ + { + "EvalParam": { + "ExpectValue": [ + "amount", + "Int" + ] + } + }, + { + "Number": 1 + } + ] + } + }, + { + "Number": 1 + } + ] + } + }, + { + "Number": 1 + } + ] + } + }, + { + "Number": 1 + } + ] + } + } + } + ] + }, + "optional": false + } + ], + "validity": null, + "mints": [], + "burns": [], + "adhoc": [], + "collateral": [], + "signers": null, + "metadata": [] +} \ No newline at end of file diff --git a/examples/nested_functions.tx3 b/examples/nested_functions.tx3 new file mode 100644 index 00000000..81a3c794 --- /dev/null +++ b/examples/nested_functions.tx3 @@ -0,0 +1,25 @@ +party Sender; +party Receiver; + +fn inc(x: Int) -> Int { + x + 1 +} + +fn inc2(x: Int) -> Int { + inc(inc(x)) +} + +fn inc4(x: Int) -> Int { + inc2(inc2(x)) +} + +tx run(amount: Int) { + input source { + from: Sender, + min_amount: Ada(amount), + } + output { + to: Receiver, + amount: Ada(inc4(amount)), + } +} From 5e543d3f517093502074ab5506ad868e6b430aa6 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sat, 30 May 2026 19:06:15 -0300 Subject: [PATCH 05/14] refactor(lang): tag built-in functions with a BuiltinFn enum Built-in functions were re-identified at lowering by re-matching the callee name string against a list duplicated from builtin_fn_defs(), with a fallible '_ => unknown built-in' arm. Carry an explicit BuiltinFn kind on FnDef instead: builtin_fn_defs() is now the single source of truth, FnCall lowering dispatches on the kind, and the builtin lowering match is exhaustive (the compiler enforces coverage, so the unreachable error arm is gone). The field is None for user-defined functions and skipped during serialization, so example AST goldens are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/tx3-lang/src/analyzing.rs | 4 +++ crates/tx3-lang/src/ast.rs | 17 +++++++++++++ crates/tx3-lang/src/lowering.rs | 43 ++++++++++---------------------- crates/tx3-lang/src/parsing.rs | 1 + 4 files changed, 35 insertions(+), 30 deletions(-) diff --git a/crates/tx3-lang/src/analyzing.rs b/crates/tx3-lang/src/analyzing.rs index 70cf097a..f11ffb66 100644 --- a/crates/tx3-lang/src/analyzing.rs +++ b/crates/tx3-lang/src/analyzing.rs @@ -319,6 +319,7 @@ fn builtin_fn_defs() -> Vec { }, return_type: Type::AnyAsset, body: None, + builtin: Some(BuiltinFn::MinUtxo), span: Span::DUMMY, scope: None, }, @@ -330,6 +331,7 @@ fn builtin_fn_defs() -> Vec { }, return_type: Type::Int, body: None, + builtin: Some(BuiltinFn::TipSlot), span: Span::DUMMY, scope: None, }, @@ -345,6 +347,7 @@ fn builtin_fn_defs() -> Vec { }, return_type: Type::Int, body: None, + builtin: Some(BuiltinFn::SlotToTime), span: Span::DUMMY, scope: None, }, @@ -360,6 +363,7 @@ fn builtin_fn_defs() -> Vec { }, return_type: Type::Int, body: None, + builtin: Some(BuiltinFn::TimeToSlot), span: Span::DUMMY, scope: None, }, diff --git a/crates/tx3-lang/src/ast.rs b/crates/tx3-lang/src/ast.rs index c00e236e..dfdeb6da 100644 --- a/crates/tx3-lang/src/ast.rs +++ b/crates/tx3-lang/src/ast.rs @@ -994,12 +994,29 @@ pub struct FnBody { pub span: Span, } +/// A function provided by the compiler rather than declared in source. Each +/// variant lowers to a dedicated compiler operation (§7); see +/// `analyzing::builtin_fn_defs` for their signatures. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum BuiltinFn { + MinUtxo, + TipSlot, + SlotToTime, + TimeToSlot, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct FnDef { pub name: Identifier, pub parameters: ParameterList, pub return_type: Type, + /// The inline body of a user-defined function. `None` for built-ins, which + /// carry a `builtin` kind instead. pub body: Option, + /// Set when this is a compiler-provided function; mutually exclusive with + /// `body`. User-defined functions always parse with `builtin: None`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub builtin: Option, pub span: Span, // analysis diff --git a/crates/tx3-lang/src/lowering.rs b/crates/tx3-lang/src/lowering.rs index 9c66af29..05ff3e88 100644 --- a/crates/tx3-lang/src/lowering.rs +++ b/crates/tx3-lang/src/lowering.rs @@ -416,11 +416,10 @@ impl IntoLower for ast::FnCall { // Check if callee resolves to a function definition if let Some(symbol) = self.callee.symbol.as_ref() { if let Some(fn_def) = symbol.as_fn_def() { - return if fn_def.body.is_some() { - inline_fn_call(fn_def, &self.args, ctx) - } else { - lower_builtin_fn_call(&self.callee.value, &self.args, ctx) - }; + if let Some(builtin) = fn_def.builtin { + return lower_builtin_fn_call(builtin, &self.args, ctx); + } + return inline_fn_call(fn_def, &self.args, ctx); } } @@ -446,34 +445,18 @@ impl IntoLower for ast::FnCall { } fn lower_builtin_fn_call( - name: &str, + builtin: ast::BuiltinFn, args: &[ast::DataExpr], ctx: &Context, ) -> Result { - match name { - "min_utxo" => { - let arg = args[0].into_lower(ctx)?; - Ok(ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeMinUtxo(arg), - ))) - } - "tip_slot" => Ok(ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeTipSlot, - ))), - "slot_to_time" => { - let arg = args[0].into_lower(ctx)?; - Ok(ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeSlotToTime(arg), - ))) - } - "time_to_slot" => { - let arg = args[0].into_lower(ctx)?; - Ok(ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeTimeToSlot(arg), - ))) - } - _ => Err(Error::InvalidAst(format!("unknown built-in function: {}", name))), - } + let op = match builtin { + ast::BuiltinFn::MinUtxo => ir::CompilerOp::ComputeMinUtxo(args[0].into_lower(ctx)?), + ast::BuiltinFn::TipSlot => ir::CompilerOp::ComputeTipSlot, + ast::BuiltinFn::SlotToTime => ir::CompilerOp::ComputeSlotToTime(args[0].into_lower(ctx)?), + ast::BuiltinFn::TimeToSlot => ir::CompilerOp::ComputeTimeToSlot(args[0].into_lower(ctx)?), + }; + + Ok(ir::Expression::EvalCompiler(Box::new(op))) } struct ParamSubstituter<'a> { diff --git a/crates/tx3-lang/src/parsing.rs b/crates/tx3-lang/src/parsing.rs index 77ae8cff..09c8caef 100644 --- a/crates/tx3-lang/src/parsing.rs +++ b/crates/tx3-lang/src/parsing.rs @@ -1028,6 +1028,7 @@ impl AstNode for FnDef { parameters, return_type, body: Some(body), + builtin: None, span, scope: None, }) From db36e998f4d7995cf51e0a3d32ecc1cda6e5ec0a Mon Sep 17 00:00:00 2001 From: Santiago Date: Sun, 31 May 2026 09:46:22 -0300 Subject: [PATCH 06/14] refactor(lang): fold function-call lowering into the IntoLower impl Remove the loose lower_builtin_fn_call and inline_fn_call helpers; their logic now lives in impl IntoLower for ast::FnCall alongside the asset constructor case, consistent with how every other AST node lowers. ParamSubstituter remains a tx3_tir::Visitor impl, not a free function. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/tx3-lang/src/lowering.rs | 97 +++++++++++++++------------------ 1 file changed, 45 insertions(+), 52 deletions(-) diff --git a/crates/tx3-lang/src/lowering.rs b/crates/tx3-lang/src/lowering.rs index 05ff3e88..b504fea5 100644 --- a/crates/tx3-lang/src/lowering.rs +++ b/crates/tx3-lang/src/lowering.rs @@ -411,19 +411,52 @@ impl IntoLower for ast::FnCall { type Output = ir::Expression; fn into_lower(&self, ctx: &Context) -> Result { - let function_name = &self.callee.value; + // A callee that resolves to a function definition is either a built-in + // (lowered to a dedicated compiler op) or a user-defined function + // (inlined below). + if let Some(fn_def) = self.callee.symbol.as_ref().and_then(|s| s.as_fn_def()) { + if let Some(builtin) = fn_def.builtin { + let op = match builtin { + ast::BuiltinFn::MinUtxo => { + ir::CompilerOp::ComputeMinUtxo(self.args[0].into_lower(ctx)?) + } + ast::BuiltinFn::TipSlot => ir::CompilerOp::ComputeTipSlot, + ast::BuiltinFn::SlotToTime => { + ir::CompilerOp::ComputeSlotToTime(self.args[0].into_lower(ctx)?) + } + ast::BuiltinFn::TimeToSlot => { + ir::CompilerOp::ComputeTimeToSlot(self.args[0].into_lower(ctx)?) + } + }; - // Check if callee resolves to a function definition - if let Some(symbol) = self.callee.symbol.as_ref() { - if let Some(fn_def) = symbol.as_fn_def() { - if let Some(builtin) = fn_def.builtin { - return lower_builtin_fn_call(builtin, &self.args, ctx); - } - return inline_fn_call(fn_def, &self.args, ctx); + return Ok(ir::Expression::EvalCompiler(Box::new(op))); + } + + // Inline a user-defined function: lower its analyzed body, then + // substitute each parameter with the lowered call argument. + let body = fn_def.body.as_ref().ok_or_else(|| { + Error::InvalidAst(format!( + "function '{}' has neither a body nor a built-in kind", + fn_def.name.value + )) + })?; + + let lowered_body = body.result.into_lower(ctx)?; + + let mut subs = std::collections::HashMap::new(); + for (param, arg) in fn_def.parameters.parameters.iter().zip(&self.args) { + subs.insert(param.name.value.to_lowercase(), arg.into_lower(ctx)?); } + + use tx3_tir::Node; + let mut visitor = ParamSubstituter { subs: &subs }; + return lowered_body + .apply(&mut visitor) + .map_err(|e| Error::InvalidAst(e.to_string())); } - // Try to coerce as asset - if it fails, we'll get an error + // Otherwise the callee must name an asset; treat the call as an asset + // constructor. match coerce_identifier_into_asset_def(&self.callee) { Ok(asset_def) => { let policy = asset_def.policy.into_lower(ctx)?; @@ -438,27 +471,14 @@ impl IntoLower for ast::FnCall { } Err(_) => Err(Error::InvalidAst(format!( "unknown function: {}", - function_name + self.callee.value ))), } } } -fn lower_builtin_fn_call( - builtin: ast::BuiltinFn, - args: &[ast::DataExpr], - ctx: &Context, -) -> Result { - let op = match builtin { - ast::BuiltinFn::MinUtxo => ir::CompilerOp::ComputeMinUtxo(args[0].into_lower(ctx)?), - ast::BuiltinFn::TipSlot => ir::CompilerOp::ComputeTipSlot, - ast::BuiltinFn::SlotToTime => ir::CompilerOp::ComputeSlotToTime(args[0].into_lower(ctx)?), - ast::BuiltinFn::TimeToSlot => ir::CompilerOp::ComputeTimeToSlot(args[0].into_lower(ctx)?), - }; - - Ok(ir::Expression::EvalCompiler(Box::new(op))) -} - +/// TIR visitor used by function inlining to replace each `EvalParam` standing +/// for a function parameter with the lowered call argument. struct ParamSubstituter<'a> { subs: &'a std::collections::HashMap, } @@ -479,33 +499,6 @@ impl tx3_tir::Visitor for ParamSubstituter<'_> { } } -fn inline_fn_call( - fn_def: &ast::FnDef, - args: &[ast::DataExpr], - ctx: &Context, -) -> Result { - let body = fn_def.body.as_ref().ok_or_else(|| { - Error::InvalidAst(format!( - "cannot inline built-in function '{}'", - fn_def.name.value - )) - })?; - - let lowered_body = body.result.into_lower(ctx)?; - - // Build substitution map: lowercased param name → lowered arg - let mut subs = std::collections::HashMap::new(); - for (param, arg) in fn_def.parameters.parameters.iter().zip(args) { - subs.insert(param.name.value.to_lowercase(), arg.into_lower(ctx)?); - } - - use tx3_tir::Node; - let mut visitor = ParamSubstituter { subs: &subs }; - lowered_body - .apply(&mut visitor) - .map_err(|e| Error::InvalidAst(e.to_string())) -} - impl IntoLower for ast::PropertyOp { type Output = ir::Expression; From f030a8bd94c110e8449727b7c7bb9bb08ea6d000 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sun, 31 May 2026 10:29:53 -0300 Subject: [PATCH 07/14] refactor(lang): move built-in call lowering onto BuiltinFn Extract the built-in compiler-op match into an inherent BuiltinFn::lower_call method, so the logic lives on the type rather than inline in FnCall::into_lower. (IntoLower itself can't host it: the trait takes no arguments and three of the four built-ins need their argument.) Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/tx3-lang/src/lowering.rs | 34 +++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/crates/tx3-lang/src/lowering.rs b/crates/tx3-lang/src/lowering.rs index b504fea5..d3e4ce77 100644 --- a/crates/tx3-lang/src/lowering.rs +++ b/crates/tx3-lang/src/lowering.rs @@ -416,20 +416,7 @@ impl IntoLower for ast::FnCall { // (inlined below). if let Some(fn_def) = self.callee.symbol.as_ref().and_then(|s| s.as_fn_def()) { if let Some(builtin) = fn_def.builtin { - let op = match builtin { - ast::BuiltinFn::MinUtxo => { - ir::CompilerOp::ComputeMinUtxo(self.args[0].into_lower(ctx)?) - } - ast::BuiltinFn::TipSlot => ir::CompilerOp::ComputeTipSlot, - ast::BuiltinFn::SlotToTime => { - ir::CompilerOp::ComputeSlotToTime(self.args[0].into_lower(ctx)?) - } - ast::BuiltinFn::TimeToSlot => { - ir::CompilerOp::ComputeTimeToSlot(self.args[0].into_lower(ctx)?) - } - }; - - return Ok(ir::Expression::EvalCompiler(Box::new(op))); + return builtin.lower_call(&self.args, ctx); } // Inline a user-defined function: lower its analyzed body, then @@ -477,6 +464,25 @@ impl IntoLower for ast::FnCall { } } +impl ast::BuiltinFn { + /// Lower a call to this built-in into its dedicated compiler operation. + /// Built-in calls are not inlined; the resolver evaluates the op. + fn lower_call( + self, + args: &[ast::DataExpr], + ctx: &Context, + ) -> Result { + let op = match self { + ast::BuiltinFn::MinUtxo => ir::CompilerOp::ComputeMinUtxo(args[0].into_lower(ctx)?), + ast::BuiltinFn::TipSlot => ir::CompilerOp::ComputeTipSlot, + ast::BuiltinFn::SlotToTime => ir::CompilerOp::ComputeSlotToTime(args[0].into_lower(ctx)?), + ast::BuiltinFn::TimeToSlot => ir::CompilerOp::ComputeTimeToSlot(args[0].into_lower(ctx)?), + }; + + Ok(ir::Expression::EvalCompiler(Box::new(op))) + } +} + /// TIR visitor used by function inlining to replace each `EvalParam` standing /// for a function parameter with the lowered call argument. struct ParamSubstituter<'a> { From bd6e410c6368f096ca7bda7397a285e79fc8f94f Mon Sep 17 00:00:00 2001 From: Santiago Date: Sun, 31 May 2026 10:52:00 -0300 Subject: [PATCH 08/14] refactor(lang): move built-in fn signatures onto BuiltinFn builtin_fn_defs() in analyzing.rs only constructed AST nodes, duplicated the builtin set, and was cross-referenced from ast.rs. Replace it with BuiltinFn::ALL plus BuiltinFn::definition(): the enum is now the single source of truth for the builtin signatures, alongside lower_call(). Analysis registers BuiltinFn::ALL directly. A table-driven definition() drops the per-builtin FnDef boilerplate. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/tx3-lang/src/analyzing.rs | 69 +------------------------------- crates/tx3-lang/src/ast.rs | 56 +++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 69 deletions(-) diff --git a/crates/tx3-lang/src/analyzing.rs b/crates/tx3-lang/src/analyzing.rs index f11ffb66..d9153d58 100644 --- a/crates/tx3-lang/src/analyzing.rs +++ b/crates/tx3-lang/src/analyzing.rs @@ -305,71 +305,6 @@ macro_rules! bail_report { }; } -fn builtin_fn_defs() -> Vec { - vec![ - FnDef { - name: Identifier::new("min_utxo"), - parameters: ParameterList { - parameters: vec![ParamDef { - name: Identifier::new("output"), - r#type: Type::Int, - docstring: None, - }], - span: Span::DUMMY, - }, - return_type: Type::AnyAsset, - body: None, - builtin: Some(BuiltinFn::MinUtxo), - span: Span::DUMMY, - scope: None, - }, - FnDef { - name: Identifier::new("tip_slot"), - parameters: ParameterList { - parameters: vec![], - span: Span::DUMMY, - }, - return_type: Type::Int, - body: None, - builtin: Some(BuiltinFn::TipSlot), - span: Span::DUMMY, - scope: None, - }, - FnDef { - name: Identifier::new("slot_to_time"), - parameters: ParameterList { - parameters: vec![ParamDef { - name: Identifier::new("slot"), - r#type: Type::Int, - docstring: None, - }], - span: Span::DUMMY, - }, - return_type: Type::Int, - body: None, - builtin: Some(BuiltinFn::SlotToTime), - span: Span::DUMMY, - scope: None, - }, - FnDef { - name: Identifier::new("time_to_slot"), - parameters: ParameterList { - parameters: vec![ParamDef { - name: Identifier::new("time"), - r#type: Type::Int, - docstring: None, - }], - span: Span::DUMMY, - }, - return_type: Type::Int, - body: None, - builtin: Some(BuiltinFn::TimeToSlot), - span: Span::DUMMY, - scope: None, - }, - ] -} - impl Scope { pub fn new(parent: Option>) -> Self { Self { @@ -1547,8 +1482,8 @@ impl Analyzable for Program { scope.track_alias_def(alias_def); } - for builtin in builtin_fn_defs().iter() { - scope.track_fn_def(builtin); + for builtin in BuiltinFn::ALL { + scope.track_fn_def(&builtin.definition()); } for fn_def in self.functions.iter() { diff --git a/crates/tx3-lang/src/ast.rs b/crates/tx3-lang/src/ast.rs index dfdeb6da..d0c63420 100644 --- a/crates/tx3-lang/src/ast.rs +++ b/crates/tx3-lang/src/ast.rs @@ -995,8 +995,8 @@ pub struct FnBody { } /// A function provided by the compiler rather than declared in source. Each -/// variant lowers to a dedicated compiler operation (§7); see -/// `analyzing::builtin_fn_defs` for their signatures. +/// variant carries its own signature (`definition`) and lowers to a dedicated +/// compiler operation (`BuiltinFn::lower_call`, §7). #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub enum BuiltinFn { MinUtxo, @@ -1005,6 +1005,58 @@ pub enum BuiltinFn { TimeToSlot, } +impl BuiltinFn { + /// Every built-in. The single source of truth for the set; analysis + /// registers `definition()` for each into the program scope. + pub const ALL: [BuiltinFn; 4] = [ + BuiltinFn::MinUtxo, + BuiltinFn::TipSlot, + BuiltinFn::SlotToTime, + BuiltinFn::TimeToSlot, + ]; + + /// The call-site name of this built-in. + pub fn name(self) -> &'static str { + match self { + BuiltinFn::MinUtxo => "min_utxo", + BuiltinFn::TipSlot => "tip_slot", + BuiltinFn::SlotToTime => "slot_to_time", + BuiltinFn::TimeToSlot => "time_to_slot", + } + } + + /// The synthetic `FnDef` registered for this built-in: a body-less function + /// whose signature the analyzer resolves calls against. + pub fn definition(self) -> FnDef { + let (params, return_type): (&[(&str, Type)], Type) = match self { + BuiltinFn::MinUtxo => (&[("output", Type::Int)], Type::AnyAsset), + BuiltinFn::TipSlot => (&[], Type::Int), + BuiltinFn::SlotToTime => (&[("slot", Type::Int)], Type::Int), + BuiltinFn::TimeToSlot => (&[("time", Type::Int)], Type::Int), + }; + + FnDef { + name: Identifier::new(self.name()), + parameters: ParameterList { + parameters: params + .iter() + .map(|(name, ty)| ParamDef { + name: Identifier::new(*name), + r#type: ty.clone(), + docstring: None, + }) + .collect(), + span: Span::DUMMY, + }, + return_type, + body: None, + builtin: Some(self), + span: Span::DUMMY, + scope: None, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct FnDef { pub name: Identifier, From 9416dbebaba213b825cf281f1c2389e5f38824f7 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sun, 31 May 2026 11:06:50 -0300 Subject: [PATCH 09/14] refactor(lang): extract built-ins into a cross-cutting module Introduce a builtins module where each built-in is a type implementing the Builtin trait, gathering its signature, synthetic FnDef, and lowering in one place. A registry (resolve/all) maps the serializable BuiltinFn key to its impl via an exhaustive match, so adding a key forces its registration at compile time. ast::BuiltinFn keeps only the key enum + ALL; analyzing and lowering delegate to builtins::{all,resolve} instead of carrying per-built-in match arms. Adding a built-in is now a localized change. A guard test checks key/name/signature consistency. Pure refactor: no behavioural change, all goldens unchanged. Per-builtin call analysis (arity/types via the trait) is a planned follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/tx3-lang/src/analyzing.rs | 2 +- crates/tx3-lang/src/ast.rs | 52 ++---------- crates/tx3-lang/src/builtins/mod.rs | 110 ++++++++++++++++++++++++++ crates/tx3-lang/src/builtins/time.rs | 97 +++++++++++++++++++++++ crates/tx3-lang/src/builtins/value.rs | 38 +++++++++ crates/tx3-lang/src/lib.rs | 1 + crates/tx3-lang/src/lowering.rs | 21 +---- 7 files changed, 254 insertions(+), 67 deletions(-) create mode 100644 crates/tx3-lang/src/builtins/mod.rs create mode 100644 crates/tx3-lang/src/builtins/time.rs create mode 100644 crates/tx3-lang/src/builtins/value.rs diff --git a/crates/tx3-lang/src/analyzing.rs b/crates/tx3-lang/src/analyzing.rs index d9153d58..44aefba8 100644 --- a/crates/tx3-lang/src/analyzing.rs +++ b/crates/tx3-lang/src/analyzing.rs @@ -1482,7 +1482,7 @@ impl Analyzable for Program { scope.track_alias_def(alias_def); } - for builtin in BuiltinFn::ALL { + for builtin in crate::builtins::all() { scope.track_fn_def(&builtin.definition()); } diff --git a/crates/tx3-lang/src/ast.rs b/crates/tx3-lang/src/ast.rs index d0c63420..350110e6 100644 --- a/crates/tx3-lang/src/ast.rs +++ b/crates/tx3-lang/src/ast.rs @@ -994,9 +994,9 @@ pub struct FnBody { pub span: Span, } -/// A function provided by the compiler rather than declared in source. Each -/// variant carries its own signature (`definition`) and lowers to a dedicated -/// compiler operation (`BuiltinFn::lower_call`, §7). +/// A function provided by the compiler rather than declared in source. This is +/// only the serializable *key* carried on `FnDef`; each variant's signature, +/// analysis, and lowering live with its [`crate::builtins::Builtin`] impl. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub enum BuiltinFn { MinUtxo, @@ -1006,55 +1006,15 @@ pub enum BuiltinFn { } impl BuiltinFn { - /// Every built-in. The single source of truth for the set; analysis - /// registers `definition()` for each into the program scope. + /// Every built-in key. `crate::builtins::resolve` maps each to its + /// implementation (exhaustively, so this list and the registry stay in + /// sync at compile time). pub const ALL: [BuiltinFn; 4] = [ BuiltinFn::MinUtxo, BuiltinFn::TipSlot, BuiltinFn::SlotToTime, BuiltinFn::TimeToSlot, ]; - - /// The call-site name of this built-in. - pub fn name(self) -> &'static str { - match self { - BuiltinFn::MinUtxo => "min_utxo", - BuiltinFn::TipSlot => "tip_slot", - BuiltinFn::SlotToTime => "slot_to_time", - BuiltinFn::TimeToSlot => "time_to_slot", - } - } - - /// The synthetic `FnDef` registered for this built-in: a body-less function - /// whose signature the analyzer resolves calls against. - pub fn definition(self) -> FnDef { - let (params, return_type): (&[(&str, Type)], Type) = match self { - BuiltinFn::MinUtxo => (&[("output", Type::Int)], Type::AnyAsset), - BuiltinFn::TipSlot => (&[], Type::Int), - BuiltinFn::SlotToTime => (&[("slot", Type::Int)], Type::Int), - BuiltinFn::TimeToSlot => (&[("time", Type::Int)], Type::Int), - }; - - FnDef { - name: Identifier::new(self.name()), - parameters: ParameterList { - parameters: params - .iter() - .map(|(name, ty)| ParamDef { - name: Identifier::new(*name), - r#type: ty.clone(), - docstring: None, - }) - .collect(), - span: Span::DUMMY, - }, - return_type, - body: None, - builtin: Some(self), - span: Span::DUMMY, - scope: None, - } - } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/crates/tx3-lang/src/builtins/mod.rs b/crates/tx3-lang/src/builtins/mod.rs new file mode 100644 index 00000000..9213d9cb --- /dev/null +++ b/crates/tx3-lang/src/builtins/mod.rs @@ -0,0 +1,110 @@ +//! Compiler-provided built-in functions. +//! +//! Each built-in is a zero-sized type implementing [`Builtin`], which gathers +//! its signature, its synthetic [`ast::FnDef`], and its lowering in one place, +//! so adding a built-in is a localized change rather than edits scattered +//! across the analyzer and lowerer. +//! +//! [`ast::BuiltinFn`] is the serializable key carried on `FnDef`; [`resolve`] +//! maps a key to its implementation and is exhaustive, so a new `BuiltinFn` +//! variant will not compile until it is registered here. + +use crate::ast::{self, BuiltinFn, FnDef, Identifier, ParamDef, ParameterList, Span, Type}; +use crate::lowering::{Context, Error as LowerError}; + +use tx3_tir::model::v1beta0 as ir; + +mod time; +mod value; + +/// The type signature of a built-in: named parameters and a return type. +pub struct Signature { + pub params: Vec<(&'static str, Type)>, + pub returns: Type, +} + +/// A compiler-provided function. One implementor per [`BuiltinFn`] variant. +pub trait Builtin: Sync { + /// The serializable key this built-in is registered and resolved under. + fn kind(&self) -> BuiltinFn; + + /// The call-site name (e.g. `"min_utxo"`). + fn name(&self) -> &'static str; + + /// Parameter and return types. + fn signature(&self) -> Signature; + + /// Lower a call to this built-in into its compiler operation. Built-in + /// calls are evaluated by the resolver, not inlined like user functions. + fn lower_call( + &self, + args: &[ast::DataExpr], + ctx: &Context, + ) -> Result; + + /// The synthetic, body-less `FnDef` registered into the program scope so + /// that calls resolve against a real signature. + fn definition(&self) -> FnDef { + let sig = self.signature(); + + FnDef { + name: Identifier::new(self.name()), + parameters: ParameterList { + parameters: sig + .params + .into_iter() + .map(|(name, r#type)| ParamDef { + name: Identifier::new(name), + r#type, + docstring: None, + }) + .collect(), + span: Span::DUMMY, + }, + return_type: sig.returns, + body: None, + builtin: Some(self.kind()), + span: Span::DUMMY, + scope: None, + } + } +} + +/// Map a built-in key to its implementation. Exhaustive by construction. +pub fn resolve(kind: BuiltinFn) -> &'static dyn Builtin { + match kind { + BuiltinFn::MinUtxo => &value::MinUtxo, + BuiltinFn::TipSlot => &time::TipSlot, + BuiltinFn::SlotToTime => &time::SlotToTime, + BuiltinFn::TimeToSlot => &time::TimeToSlot, + } +} + +/// Every built-in, in [`BuiltinFn::ALL`] order. +pub fn all() -> impl Iterator { + BuiltinFn::ALL.into_iter().map(resolve) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn registry_is_consistent() { + let mut names = HashSet::new(); + for kind in BuiltinFn::ALL { + let b = resolve(kind); + // `resolve` returns the implementation for the requested key. + assert_eq!(b.kind(), kind); + // The synthetic definition is tagged with the same key and matches + // the declared signature. + let def = b.definition(); + assert_eq!(def.builtin, Some(kind)); + assert_eq!(def.name.value, b.name()); + assert_eq!(def.parameters.parameters.len(), b.signature().params.len()); + // Names are unique across the set. + assert!(names.insert(b.name()), "duplicate built-in name: {}", b.name()); + } + } +} diff --git a/crates/tx3-lang/src/builtins/time.rs b/crates/tx3-lang/src/builtins/time.rs new file mode 100644 index 00000000..2faefb93 --- /dev/null +++ b/crates/tx3-lang/src/builtins/time.rs @@ -0,0 +1,97 @@ +//! Slot- and time-related built-ins. + +use super::{Builtin, Signature}; +use crate::ast::{self, BuiltinFn, Type}; +use crate::lowering::{Context, Error as LowerError, IntoLower}; + +use tx3_tir::model::v1beta0 as ir; + +/// `tip_slot() -> Int` — the chain tip slot at resolution time. +pub struct TipSlot; + +impl Builtin for TipSlot { + fn kind(&self) -> BuiltinFn { + BuiltinFn::TipSlot + } + + fn name(&self) -> &'static str { + "tip_slot" + } + + fn signature(&self) -> Signature { + Signature { + params: vec![], + returns: Type::Int, + } + } + + fn lower_call( + &self, + _args: &[ast::DataExpr], + _ctx: &Context, + ) -> Result { + Ok(ir::Expression::EvalCompiler(Box::new( + ir::CompilerOp::ComputeTipSlot, + ))) + } +} + +/// `slot_to_time(slot) -> Int` — convert a slot number to a POSIX time. +pub struct SlotToTime; + +impl Builtin for SlotToTime { + fn kind(&self) -> BuiltinFn { + BuiltinFn::SlotToTime + } + + fn name(&self) -> &'static str { + "slot_to_time" + } + + fn signature(&self) -> Signature { + Signature { + params: vec![("slot", Type::Int)], + returns: Type::Int, + } + } + + fn lower_call( + &self, + args: &[ast::DataExpr], + ctx: &Context, + ) -> Result { + Ok(ir::Expression::EvalCompiler(Box::new( + ir::CompilerOp::ComputeSlotToTime(args[0].into_lower(ctx)?), + ))) + } +} + +/// `time_to_slot(time) -> Int` — convert a POSIX time to a slot number. +pub struct TimeToSlot; + +impl Builtin for TimeToSlot { + fn kind(&self) -> BuiltinFn { + BuiltinFn::TimeToSlot + } + + fn name(&self) -> &'static str { + "time_to_slot" + } + + fn signature(&self) -> Signature { + Signature { + params: vec![("time", Type::Int)], + returns: Type::Int, + } + } + + fn lower_call( + &self, + args: &[ast::DataExpr], + ctx: &Context, + ) -> Result { + Ok(ir::Expression::EvalCompiler(Box::new( + ir::CompilerOp::ComputeTimeToSlot(args[0].into_lower(ctx)?), + ))) + } +} diff --git a/crates/tx3-lang/src/builtins/value.rs b/crates/tx3-lang/src/builtins/value.rs new file mode 100644 index 00000000..f4edb6a2 --- /dev/null +++ b/crates/tx3-lang/src/builtins/value.rs @@ -0,0 +1,38 @@ +//! Value- and asset-related built-ins. + +use super::{Builtin, Signature}; +use crate::ast::{self, BuiltinFn, Type}; +use crate::lowering::{Context, Error as LowerError, IntoLower}; + +use tx3_tir::model::v1beta0 as ir; + +/// `min_utxo(output) -> AnyAsset` — the minimum value the named output needs to +/// satisfy the chain's min-UTxO rule. +pub struct MinUtxo; + +impl Builtin for MinUtxo { + fn kind(&self) -> BuiltinFn { + BuiltinFn::MinUtxo + } + + fn name(&self) -> &'static str { + "min_utxo" + } + + fn signature(&self) -> Signature { + Signature { + params: vec![("output", Type::Int)], + returns: Type::AnyAsset, + } + } + + fn lower_call( + &self, + args: &[ast::DataExpr], + ctx: &Context, + ) -> Result { + Ok(ir::Expression::EvalCompiler(Box::new( + ir::CompilerOp::ComputeMinUtxo(args[0].into_lower(ctx)?), + ))) + } +} diff --git a/crates/tx3-lang/src/lib.rs b/crates/tx3-lang/src/lib.rs index 9d044437..1b6b91e4 100644 --- a/crates/tx3-lang/src/lib.rs +++ b/crates/tx3-lang/src/lib.rs @@ -26,6 +26,7 @@ pub mod analyzing; pub mod ast; +pub mod builtins; pub mod lowering; pub mod parsing; diff --git a/crates/tx3-lang/src/lowering.rs b/crates/tx3-lang/src/lowering.rs index d3e4ce77..b8e7a1c2 100644 --- a/crates/tx3-lang/src/lowering.rs +++ b/crates/tx3-lang/src/lowering.rs @@ -416,7 +416,7 @@ impl IntoLower for ast::FnCall { // (inlined below). if let Some(fn_def) = self.callee.symbol.as_ref().and_then(|s| s.as_fn_def()) { if let Some(builtin) = fn_def.builtin { - return builtin.lower_call(&self.args, ctx); + return crate::builtins::resolve(builtin).lower_call(&self.args, ctx); } // Inline a user-defined function: lower its analyzed body, then @@ -464,25 +464,6 @@ impl IntoLower for ast::FnCall { } } -impl ast::BuiltinFn { - /// Lower a call to this built-in into its dedicated compiler operation. - /// Built-in calls are not inlined; the resolver evaluates the op. - fn lower_call( - self, - args: &[ast::DataExpr], - ctx: &Context, - ) -> Result { - let op = match self { - ast::BuiltinFn::MinUtxo => ir::CompilerOp::ComputeMinUtxo(args[0].into_lower(ctx)?), - ast::BuiltinFn::TipSlot => ir::CompilerOp::ComputeTipSlot, - ast::BuiltinFn::SlotToTime => ir::CompilerOp::ComputeSlotToTime(args[0].into_lower(ctx)?), - ast::BuiltinFn::TimeToSlot => ir::CompilerOp::ComputeTimeToSlot(args[0].into_lower(ctx)?), - }; - - Ok(ir::Expression::EvalCompiler(Box::new(op))) - } -} - /// TIR visitor used by function inlining to replace each `EvalParam` standing /// for a function parameter with the lowered call argument. struct ParamSubstituter<'a> { From 693518a30dbf58069d6df61edffdd746e3dcf4e8 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sun, 31 May 2026 11:20:51 -0300 Subject: [PATCH 10/14] refactor(lang): declare regular built-ins via a table macro Add builtin_boilerplate!, a declarative table that generates the struct + impl Builtin for the common shape (lower the named arguments, feed them to a CompilerOp). The four current built-ins collapse to a four-row table; value.rs/time.rs are folded in. The op references parameters by name, so the compiler checks them against the signature, and a missing argument now yields a diagnostic instead of an index panic. Hand-written impls still coexist behind resolve() for built-ins that need custom analysis or non-CompilerOp lowering. Cap the builtins module at pub(crate) (it exposes the crate-private lowering::Context). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/tx3-lang/src/builtins/mod.rs | 96 ++++++++++++++++++++++---- crates/tx3-lang/src/builtins/time.rs | 97 --------------------------- crates/tx3-lang/src/builtins/value.rs | 38 ----------- crates/tx3-lang/src/lib.rs | 2 +- 4 files changed, 85 insertions(+), 148 deletions(-) delete mode 100644 crates/tx3-lang/src/builtins/time.rs delete mode 100644 crates/tx3-lang/src/builtins/value.rs diff --git a/crates/tx3-lang/src/builtins/mod.rs b/crates/tx3-lang/src/builtins/mod.rs index 9213d9cb..3b367ead 100644 --- a/crates/tx3-lang/src/builtins/mod.rs +++ b/crates/tx3-lang/src/builtins/mod.rs @@ -1,22 +1,21 @@ //! Compiler-provided built-in functions. //! //! Each built-in is a zero-sized type implementing [`Builtin`], which gathers -//! its signature, its synthetic [`ast::FnDef`], and its lowering in one place, -//! so adding a built-in is a localized change rather than edits scattered -//! across the analyzer and lowerer. -//! +//! its signature, its synthetic [`ast::FnDef`], and its lowering in one place. //! [`ast::BuiltinFn`] is the serializable key carried on `FnDef`; [`resolve`] //! maps a key to its implementation and is exhaustive, so a new `BuiltinFn` //! variant will not compile until it is registered here. +//! +//! Built-ins whose lowering is "lower the arguments and feed them to a +//! [`ir::CompilerOp`]" are declared with the [`builtin_boilerplate!`] table +//! below. A built-in that needs custom analysis or lowering can instead be a +//! hand-written `impl Builtin`; both kinds coexist behind [`resolve`]. use crate::ast::{self, BuiltinFn, FnDef, Identifier, ParamDef, ParameterList, Span, Type}; -use crate::lowering::{Context, Error as LowerError}; +use crate::lowering::{Context, Error as LowerError, IntoLower}; use tx3_tir::model::v1beta0 as ir; -mod time; -mod value; - /// The type signature of a built-in: named parameters and a return type. pub struct Signature { pub params: Vec<(&'static str, Type)>, @@ -70,13 +69,86 @@ pub trait Builtin: Sync { } } +/// Declare the boilerplate for built-ins whose lowering is "bind each named +/// parameter to its lowered argument, then construct an [`ir::CompilerOp`]". +/// +/// Each row is: +/// ```text +/// Variant => "name" (param: Type, …) -> ReturnType => CompilerOp(param, …); +/// ``` +/// The operation references the parameters by name, so the names are checked +/// against the signature at compile time. Parameter and return types must be +/// bare [`ast::Type`] variant idents (e.g. `Int`, `AnyAsset`); a built-in that +/// needs a richer type or non-`CompilerOp` lowering is written by hand instead. +macro_rules! builtin_boilerplate { + ( + $( + $variant:ident => $name:literal + ( $( $param:ident : $pty:ident ),* $(,)? ) -> $ret:ident + => $op:ident $(( $( $oparg:ident ),* ))? ; + )* + ) => { + $( + pub struct $variant; + + impl Builtin for $variant { + fn kind(&self) -> BuiltinFn { + BuiltinFn::$variant + } + + fn name(&self) -> &'static str { + $name + } + + fn signature(&self) -> Signature { + Signature { + params: vec![ $( (stringify!($param), Type::$pty) ),* ], + returns: Type::$ret, + } + } + + // A nullary built-in (e.g. `tip_slot`) uses neither `args` nor + // `ctx`; allow that here so the table stays uniform. + #[allow(unused_mut, unused_variables)] + fn lower_call( + &self, + args: &[ast::DataExpr], + ctx: &Context, + ) -> Result { + let mut args = args.iter(); + $( + let $param = args + .next() + .ok_or_else(|| LowerError::InvalidAst(format!( + "built-in '{}' expects argument '{}'", + $name, + stringify!($param), + )))? + .into_lower(ctx)?; + )* + Ok(ir::Expression::EvalCompiler(Box::new( + ir::CompilerOp::$op $(( $( $oparg ),* ))? + ))) + } + } + )* + }; +} + +builtin_boilerplate! { + MinUtxo => "min_utxo" (output: Int) -> AnyAsset => ComputeMinUtxo(output); + TipSlot => "tip_slot" () -> Int => ComputeTipSlot; + SlotToTime => "slot_to_time" (slot: Int) -> Int => ComputeSlotToTime(slot); + TimeToSlot => "time_to_slot" (time: Int) -> Int => ComputeTimeToSlot(time); +} + /// Map a built-in key to its implementation. Exhaustive by construction. pub fn resolve(kind: BuiltinFn) -> &'static dyn Builtin { match kind { - BuiltinFn::MinUtxo => &value::MinUtxo, - BuiltinFn::TipSlot => &time::TipSlot, - BuiltinFn::SlotToTime => &time::SlotToTime, - BuiltinFn::TimeToSlot => &time::TimeToSlot, + BuiltinFn::MinUtxo => &MinUtxo, + BuiltinFn::TipSlot => &TipSlot, + BuiltinFn::SlotToTime => &SlotToTime, + BuiltinFn::TimeToSlot => &TimeToSlot, } } diff --git a/crates/tx3-lang/src/builtins/time.rs b/crates/tx3-lang/src/builtins/time.rs deleted file mode 100644 index 2faefb93..00000000 --- a/crates/tx3-lang/src/builtins/time.rs +++ /dev/null @@ -1,97 +0,0 @@ -//! Slot- and time-related built-ins. - -use super::{Builtin, Signature}; -use crate::ast::{self, BuiltinFn, Type}; -use crate::lowering::{Context, Error as LowerError, IntoLower}; - -use tx3_tir::model::v1beta0 as ir; - -/// `tip_slot() -> Int` — the chain tip slot at resolution time. -pub struct TipSlot; - -impl Builtin for TipSlot { - fn kind(&self) -> BuiltinFn { - BuiltinFn::TipSlot - } - - fn name(&self) -> &'static str { - "tip_slot" - } - - fn signature(&self) -> Signature { - Signature { - params: vec![], - returns: Type::Int, - } - } - - fn lower_call( - &self, - _args: &[ast::DataExpr], - _ctx: &Context, - ) -> Result { - Ok(ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeTipSlot, - ))) - } -} - -/// `slot_to_time(slot) -> Int` — convert a slot number to a POSIX time. -pub struct SlotToTime; - -impl Builtin for SlotToTime { - fn kind(&self) -> BuiltinFn { - BuiltinFn::SlotToTime - } - - fn name(&self) -> &'static str { - "slot_to_time" - } - - fn signature(&self) -> Signature { - Signature { - params: vec![("slot", Type::Int)], - returns: Type::Int, - } - } - - fn lower_call( - &self, - args: &[ast::DataExpr], - ctx: &Context, - ) -> Result { - Ok(ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeSlotToTime(args[0].into_lower(ctx)?), - ))) - } -} - -/// `time_to_slot(time) -> Int` — convert a POSIX time to a slot number. -pub struct TimeToSlot; - -impl Builtin for TimeToSlot { - fn kind(&self) -> BuiltinFn { - BuiltinFn::TimeToSlot - } - - fn name(&self) -> &'static str { - "time_to_slot" - } - - fn signature(&self) -> Signature { - Signature { - params: vec![("time", Type::Int)], - returns: Type::Int, - } - } - - fn lower_call( - &self, - args: &[ast::DataExpr], - ctx: &Context, - ) -> Result { - Ok(ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeTimeToSlot(args[0].into_lower(ctx)?), - ))) - } -} diff --git a/crates/tx3-lang/src/builtins/value.rs b/crates/tx3-lang/src/builtins/value.rs deleted file mode 100644 index f4edb6a2..00000000 --- a/crates/tx3-lang/src/builtins/value.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Value- and asset-related built-ins. - -use super::{Builtin, Signature}; -use crate::ast::{self, BuiltinFn, Type}; -use crate::lowering::{Context, Error as LowerError, IntoLower}; - -use tx3_tir::model::v1beta0 as ir; - -/// `min_utxo(output) -> AnyAsset` — the minimum value the named output needs to -/// satisfy the chain's min-UTxO rule. -pub struct MinUtxo; - -impl Builtin for MinUtxo { - fn kind(&self) -> BuiltinFn { - BuiltinFn::MinUtxo - } - - fn name(&self) -> &'static str { - "min_utxo" - } - - fn signature(&self) -> Signature { - Signature { - params: vec![("output", Type::Int)], - returns: Type::AnyAsset, - } - } - - fn lower_call( - &self, - args: &[ast::DataExpr], - ctx: &Context, - ) -> Result { - Ok(ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeMinUtxo(args[0].into_lower(ctx)?), - ))) - } -} diff --git a/crates/tx3-lang/src/lib.rs b/crates/tx3-lang/src/lib.rs index 1b6b91e4..43d19b0c 100644 --- a/crates/tx3-lang/src/lib.rs +++ b/crates/tx3-lang/src/lib.rs @@ -26,7 +26,7 @@ pub mod analyzing; pub mod ast; -pub mod builtins; +pub(crate) mod builtins; pub mod lowering; pub mod parsing; From 8663bb585acd8308a12e2ec29c9bdd100291f97a Mon Sep 17 00:00:00 2001 From: Santiago Date: Sun, 31 May 2026 11:24:44 -0300 Subject: [PATCH 11/14] refactor(lang): make builtin_boilerplate a single-entry macro Invoke it once per built-in as an individual statement rather than a table block. Same generated impl; clearer one-line-per-built-in form. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/tx3-lang/src/builtins/mod.rs | 101 +++++++++++++--------------- 1 file changed, 48 insertions(+), 53 deletions(-) diff --git a/crates/tx3-lang/src/builtins/mod.rs b/crates/tx3-lang/src/builtins/mod.rs index 3b367ead..a46dfa41 100644 --- a/crates/tx3-lang/src/builtins/mod.rs +++ b/crates/tx3-lang/src/builtins/mod.rs @@ -69,78 +69,73 @@ pub trait Builtin: Sync { } } -/// Declare the boilerplate for built-ins whose lowering is "bind each named -/// parameter to its lowered argument, then construct an [`ir::CompilerOp`]". +/// Declare a built-in whose lowering is "bind each named parameter to its +/// lowered argument, then construct an [`ir::CompilerOp`]". /// -/// Each row is: /// ```text -/// Variant => "name" (param: Type, …) -> ReturnType => CompilerOp(param, …); +/// builtin_boilerplate!(Variant, "name", (param: Type, …) -> ReturnType => CompilerOp(param, …)); /// ``` +/// /// The operation references the parameters by name, so the names are checked /// against the signature at compile time. Parameter and return types must be /// bare [`ast::Type`] variant idents (e.g. `Int`, `AnyAsset`); a built-in that /// needs a richer type or non-`CompilerOp` lowering is written by hand instead. macro_rules! builtin_boilerplate { ( - $( - $variant:ident => $name:literal - ( $( $param:ident : $pty:ident ),* $(,)? ) -> $ret:ident - => $op:ident $(( $( $oparg:ident ),* ))? ; - )* + $variant:ident, + $name:literal, + ( $( $param:ident : $pty:ident ),* $(,)? ) -> $ret:ident + => $op:ident $(( $( $oparg:ident ),* ))? ) => { - $( - pub struct $variant; + pub struct $variant; - impl Builtin for $variant { - fn kind(&self) -> BuiltinFn { - BuiltinFn::$variant - } + impl Builtin for $variant { + fn kind(&self) -> BuiltinFn { + BuiltinFn::$variant + } - fn name(&self) -> &'static str { - $name - } + fn name(&self) -> &'static str { + $name + } - fn signature(&self) -> Signature { - Signature { - params: vec![ $( (stringify!($param), Type::$pty) ),* ], - returns: Type::$ret, - } + fn signature(&self) -> Signature { + Signature { + params: vec![ $( (stringify!($param), Type::$pty) ),* ], + returns: Type::$ret, } + } - // A nullary built-in (e.g. `tip_slot`) uses neither `args` nor - // `ctx`; allow that here so the table stays uniform. - #[allow(unused_mut, unused_variables)] - fn lower_call( - &self, - args: &[ast::DataExpr], - ctx: &Context, - ) -> Result { - let mut args = args.iter(); - $( - let $param = args - .next() - .ok_or_else(|| LowerError::InvalidAst(format!( - "built-in '{}' expects argument '{}'", - $name, - stringify!($param), - )))? - .into_lower(ctx)?; - )* - Ok(ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::$op $(( $( $oparg ),* ))? - ))) - } + // A nullary built-in (e.g. `tip_slot`) uses neither `args` nor + // `ctx`; allow that so every invocation expands uniformly. + #[allow(unused_mut, unused_variables)] + fn lower_call( + &self, + args: &[ast::DataExpr], + ctx: &Context, + ) -> Result { + let mut args = args.iter(); + $( + let $param = args + .next() + .ok_or_else(|| LowerError::InvalidAst(format!( + "built-in '{}' expects argument '{}'", + $name, + stringify!($param), + )))? + .into_lower(ctx)?; + )* + Ok(ir::Expression::EvalCompiler(Box::new( + ir::CompilerOp::$op $(( $( $oparg ),* ))? + ))) } - )* + } }; } -builtin_boilerplate! { - MinUtxo => "min_utxo" (output: Int) -> AnyAsset => ComputeMinUtxo(output); - TipSlot => "tip_slot" () -> Int => ComputeTipSlot; - SlotToTime => "slot_to_time" (slot: Int) -> Int => ComputeSlotToTime(slot); - TimeToSlot => "time_to_slot" (time: Int) -> Int => ComputeTimeToSlot(time); -} +builtin_boilerplate!(MinUtxo, "min_utxo", (output: Int) -> AnyAsset => ComputeMinUtxo(output)); +builtin_boilerplate!(TipSlot, "tip_slot", () -> Int => ComputeTipSlot); +builtin_boilerplate!(SlotToTime, "slot_to_time", (slot: Int) -> Int => ComputeSlotToTime(slot)); +builtin_boilerplate!(TimeToSlot, "time_to_slot", (time: Int) -> Int => ComputeTimeToSlot(time)); /// Map a built-in key to its implementation. Exhaustive by construction. pub fn resolve(kind: BuiltinFn) -> &'static dyn Builtin { From 665e0d089b41677ae0ef3ab66194bd8789576e35 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sun, 31 May 2026 14:43:36 -0300 Subject: [PATCH 12/14] refactor(lang): give FnCall its own target_type method The DataExpr::FnCall arm was the only one with an inline body; move the return-type lookup onto FnCall::target_type so the match delegates uniformly like every other variant. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/tx3-lang/src/ast.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/tx3-lang/src/ast.rs b/crates/tx3-lang/src/ast.rs index 350110e6..4003882b 100644 --- a/crates/tx3-lang/src/ast.rs +++ b/crates/tx3-lang/src/ast.rs @@ -739,6 +739,15 @@ pub struct FnCall { pub span: Span, } +impl FnCall { + /// The static type of the call: the callee function's declared return type, + /// or `None` until the callee is resolved (§6.3). + pub fn target_type(&self) -> Option { + let fn_def = self.callee.symbol.as_ref()?.as_fn_def()?; + Some(fn_def.return_type.clone()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum DataExpr { None, @@ -791,12 +800,7 @@ impl DataExpr { DataExpr::PropertyOp(x) => x.target_type(), DataExpr::AnyAssetConstructor(x) => x.target_type(), DataExpr::UtxoRef(_) => Some(Type::UtxoRef), - DataExpr::FnCall(call) => call - .callee - .symbol - .as_ref() - .and_then(|s| s.as_fn_def()) - .map(|fn_def| fn_def.return_type.clone()), + DataExpr::FnCall(x) => x.target_type(), } } } From a2b1c80f8e5f019e6fac818b0d1eeedbacc18501 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sun, 31 May 2026 14:47:35 -0300 Subject: [PATCH 13/14] docs(spec): correct fn nesting wording and a cross-reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'functions MUST NOT be nested' was ambiguous (it read as if nested calls were disallowed, which they are not) and redundant with the grammar (fn_def appears only in top_level_def). Reword to state that fn_def is a top-level definition and clarify that nested *calls* are allowed. Also fix the return-type-match cross-reference from §5.7 to §5.6.2. Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/v1beta0/04-syntactic-grammar.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/specs/v1beta0/04-syntactic-grammar.md b/specs/v1beta0/04-syntactic-grammar.md index 62f7c14c..135cdc69 100644 --- a/specs/v1beta0/04-syntactic-grammar.md +++ b/specs/v1beta0/04-syntactic-grammar.md @@ -124,11 +124,13 @@ A *function definition* introduces a named, pure helper that computes a data value. The `parameter_list` (§4.2.7) MAY be empty. The `type` after `->` is the declared return type. A function body is an optional sequence of `let`-bindings followed by a single result `data_expr`, whose static type MUST match the -declared return type (§5.7). Functions are top-level only: a function body MUST -NOT contain transaction blocks, and functions MUST NOT be nested. Function calls -use the `fn_call` production (§4.4) and MAY appear anywhere a `data_expr` is -valid. Static-semantic rules for functions are given in §6, and their -evaluation by inlining is specified in §7. +declared return type (§5.6.2). A `fn_def` is a top-level definition (§4.1): it +cannot be declared inside another function or a `tx`, and its body is a +`data_expr`, so it contains no transaction blocks. Function *calls*, by +contrast, use the `fn_call` production (§4.4) and MAY appear anywhere a +`data_expr` is valid — including as arguments to other calls. Static-semantic +rules for functions are given in §6, and their evaluation by inlining is +specified in §7. ### 4.2.7 Transaction From d26bbecdbe7dded5821f40692ac2321e0f0a7c92 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sun, 31 May 2026 14:56:48 -0300 Subject: [PATCH 14/14] fix(lang): reject function calls with the wrong argument count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FnCall::analyze validated the callee and each argument but never the argument count, so a wrong-arity call slipped through: too few arguments left a parameter unsubstituted at inlining (a leaked EvalParam in the TIR — a silent miscompile), too many were silently dropped, and a zero-arg built-in call panicked on args[0]. Add a structured ArityError and check, in FnCall::analyze, that any call resolving to a function (user-defined or built-in) passes exactly as many arguments as it declares. Asset constructors and unresolved callees are unaffected. Covered by too-few / too-many / correct / built-in tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/tx3-lang/src/analyzing.rs | 177 ++++++++++++++++++++++++++++++- 1 file changed, 176 insertions(+), 1 deletion(-) diff --git a/crates/tx3-lang/src/analyzing.rs b/crates/tx3-lang/src/analyzing.rs index 44aefba8..1da32103 100644 --- a/crates/tx3-lang/src/analyzing.rs +++ b/crates/tx3-lang/src/analyzing.rs @@ -53,6 +53,21 @@ pub struct InvalidTargetTypeError { span: Span, } +#[derive(Debug, thiserror::Error, miette::Diagnostic, PartialEq, Eq, Clone)] +#[error("function '{name}' expects {expected} argument(s), but got {got}")] +#[diagnostic(code(tx3::arity_mismatch))] +pub struct ArityError { + pub name: String, + pub expected: usize, + pub got: usize, + + #[source_code] + src: Option, + + #[label] + span: Span, +} + #[derive(Debug, thiserror::Error, miette::Diagnostic, PartialEq, Eq, Clone)] #[error("optional output ({name}) cannot have a datum")] #[diagnostic(code(tx3::optional_output_datum))] @@ -126,6 +141,10 @@ pub enum Error { #[error(transparent)] #[diagnostic(transparent)] InvalidOptionalOutput(#[from] OptionalOutputError), + + #[error(transparent)] + #[diagnostic(transparent)] + Arity(#[from] ArityError), } impl Error { @@ -137,6 +156,7 @@ impl Error { Self::MetadataSizeLimitExceeded(x) => &x.span, Self::MetadataInvalidKeyType(x) => &x.span, Self::InvalidOptionalOutput(x) => &x.span, + Self::Arity(x) => &x.span, _ => &Span::DUMMY, } } @@ -150,6 +170,21 @@ impl Error { } } + pub fn arity( + name: String, + expected: usize, + got: usize, + ast: &impl crate::parsing::AstNode, + ) -> Self { + Self::Arity(ArityError { + name, + expected, + got, + src: None, + span: ast.span().clone(), + }) + } + pub fn not_in_scope(name: String, ast: &impl crate::parsing::AstNode) -> Self { Self::NotInScope(NotInScopeError { name, @@ -755,7 +790,27 @@ impl Analyzable for crate::ast::FnCall { args_report = args_report + arg.analyze(parent.clone()); } - callee + args_report + let mut report = callee + args_report; + + // Arity check: a call whose callee resolves to a function (user-defined + // or built-in) must pass exactly as many arguments as it declares. + // Skipped when the callee does not resolve to a function (e.g. an asset + // constructor, or an unresolved name already reported above). + let signature = self + .callee + .symbol + .as_ref() + .and_then(|s| s.as_fn_def()) + .map(|fn_def| (fn_def.name.value.clone(), fn_def.parameters.parameters.len())); + + if let Some((name, expected)) = signature { + let got = self.args.len(); + if expected != got { + report = report + Error::arity(name, expected, got, self).into(); + } + } + + report } fn is_resolved(&self) -> bool { @@ -1760,6 +1815,126 @@ mod tests { assert!(report.errors.is_empty()); } + #[test] + fn test_fn_call_too_few_args() { + let mut ast = crate::parsing::parse_string( + r#" + party Alice; + + fn double(x: Int) -> Int { + x + x + } + + tx t() { + input source { + from: Alice, + min_amount: Ada(2), + } + output { + to: Alice, + amount: Ada(double()), + } + } + "#, + ) + .unwrap(); + + let report = analyze(&mut ast); + + assert!(report.errors.iter().any(|e| matches!( + e, + Error::Arity(a) if a.name == "double" && a.expected == 1 && a.got == 0 + ))); + } + + #[test] + fn test_fn_call_too_many_args() { + let mut ast = crate::parsing::parse_string( + r#" + party Alice; + + fn double(x: Int) -> Int { + x + x + } + + tx t() { + input source { + from: Alice, + min_amount: Ada(2), + } + output { + to: Alice, + amount: Ada(double(1, 2)), + } + } + "#, + ) + .unwrap(); + + let report = analyze(&mut ast); + + assert!(report.errors.iter().any(|e| matches!( + e, + Error::Arity(a) if a.name == "double" && a.expected == 1 && a.got == 2 + ))); + } + + #[test] + fn test_fn_call_correct_arity_ok() { + let mut ast = crate::parsing::parse_string( + r#" + party Alice; + + fn double(x: Int) -> Int { + x + x + } + + tx t() { + input source { + from: Alice, + min_amount: Ada(2), + } + output { + to: Alice, + amount: Ada(double(2)), + } + } + "#, + ) + .unwrap(); + + let report = analyze(&mut ast); + assert!(!report.errors.iter().any(|e| matches!(e, Error::Arity(_)))); + } + + #[test] + fn test_builtin_call_wrong_arity() { + let mut ast = crate::parsing::parse_string( + r#" + party Alice; + + tx t() { + input source { + from: Alice, + min_amount: Ada(2), + } + output { + to: Alice, + amount: min_utxo(), + } + } + "#, + ) + .unwrap(); + + let report = analyze(&mut ast); + + assert!(report.errors.iter().any(|e| matches!( + e, + Error::Arity(a) if a.name == "min_utxo" && a.expected == 1 && a.got == 0 + ))); + } + #[test] fn test_metadata_value_size_validation_string_within_limit() { let mut ast = crate::parsing::parse_string(