diff --git a/shared/yeast-macros/src/ast.rs b/shared/yeast-macros/src/ast.rs index f400257fd1d4..a08d56e9661d 100644 --- a/shared/yeast-macros/src/ast.rs +++ b/shared/yeast-macros/src/ast.rs @@ -100,6 +100,15 @@ pub(crate) enum Template { Node(Node), /// An embedded Rust block such as `{args}`. Splice(syn::Block), + /// Ordered alternatives such as `[(identifier #{name}) (missing)]`. + Choice(Vec), +} + +pub(crate) struct Alternative { + /// Template to construct when the guard and dependencies succeed. + pub(crate) template: Template, + /// Optional postfix guard such as `if { name.is_some() }`. + pub(crate) guard: Option, } pub(crate) struct Node { @@ -121,15 +130,13 @@ pub(crate) enum Content { pub(crate) enum Field { /// A named field such as `argument: {args}` or - /// `label: (identifier #{name})?`. + /// `label: (identifier #{name})`. Named { /// The field name before `:`, such as `argument`. name: Ident, /// The node or Rust splice after `:`, such as `(identifier "x")` or /// `{args}`. value: Template, - /// Whether the field value has a trailing `?`. - optional: bool, }, /// Source-location metadata such as `location: {source}`. Location { diff --git a/shared/yeast-macros/src/lib.rs b/shared/yeast-macros/src/lib.rs index 75d737372965..ad686403a3d4 100644 --- a/shared/yeast-macros/src/lib.rs +++ b/shared/yeast-macros/src/lib.rs @@ -52,6 +52,8 @@ pub fn query(input: TokenStream) -> TokenStream { /// Build a single AST node from a template, returning its `OutputId`. /// +/// A top-level choice returns `Option`. +/// /// # Template syntax /// /// ```text @@ -63,36 +65,43 @@ pub fn query(input: TokenStream) -> TokenStream { /// `Option`, iterator chains) splice /// their elements /// field: {expr} - extend a named field with `{expr}`'s ids -/// field: (kind ...)? - set the field only if every `#{expr}` -/// beneath it has a value (see below) /// location: {expr} - override this node's source range with the /// span of one or more nodes; may be repeated +/// field: [(kind ...)] - set the field only if the alternative can +/// be constructed (see below) +/// field: [(a) (b)] - use the first constructible alternative +/// field: [(a) if {guard} (b)] - skip an alternative when its guard is false /// ``` /// -/// # Optional fields +/// # Template choices /// -/// A `?` on a field's value makes that field fallible: if a `#{expr}` -/// anywhere beneath it interpolates an absent value — an `Option` that is -/// `None` — the subtree is abandoned and the field is left unset. This -/// replaces the surrounding `Option::map` that would otherwise be needed: +/// Brackets contain one or more ordered alternatives. For each alternative, +/// its optional `if {guard}` is evaluated first, followed by every `#{expr}` +/// dependency beneath it. An absent dependency — an `Option` that is `None` — +/// skips the alternative without constructing any of its template nodes. The +/// first alternative whose guard and dependencies succeed is constructed: /// /// ```text /// (break_expr label: {lbl.map(|l| tree!((identifier #{l})))}) // before -/// (break_expr label: (identifier #{lbl})?) // after +/// (break_expr label: [(identifier #{lbl})]) // after /// ``` /// -/// The value may be nested arbitrarily deeply, and a nested `?` catches -/// first, so an inner absent value need not discard the outer node: +/// Dependencies may be nested arbitrarily deeply. A nested choice handles its +/// own dependencies, so an inner absent value need not discard an outer node: /// /// ```text -/// (parameter pattern: (name_pattern identifier: (identifier #{name}))? type: {ty}) +/// (parameter pattern: [ +/// (name_pattern identifier: [(identifier #{name})]) +/// ] type: {ty}) /// ``` /// -/// Only `#{expr}` propagates absence. A `{expr}` splice is unaffected, since -/// yielding no ids already leaves a field unset, and `?` is rejected on one. -/// Outside a `?`, interpolating an `Option` with `#{expr}` remains a compile -/// error, so the choice between "unset the field" and "unwrap it" stays -/// explicit. +/// Only `#{expr}` is treated as a dependency. A `{expr}` splice is unaffected, +/// since yielding no ids already leaves its field unset. Outside a choice, +/// interpolating an `Option` with `#{expr}` remains a compile error. +/// +/// A choice used as a field or list item contributes zero or one node. A +/// top-level choice passed to `tree!` returns `Option`; ordinary +/// templates continue to return `OutputId`. /// /// Uses the `BuildCtx` binding named `ctx` from the surrounding scope: /// diff --git a/shared/yeast-macros/src/lower.rs b/shared/yeast-macros/src/lower.rs index 6bf22c7e8b9e..6ed98725a086 100644 --- a/shared/yeast-macros/src/lower.rs +++ b/shared/yeast-macros/src/lower.rs @@ -1,19 +1,32 @@ use proc_macro2::{Ident, Span, TokenStream}; use quote::quote; +use std::cell::RefCell; use std::sync::atomic::{AtomicUsize, Ordering}; -use syn::Lifetime; -use crate::ast::{Cardinality, Content, Field, Node, Pattern, Replacement, Rule, Template}; +use crate::ast::{ + Alternative, Cardinality, Content, Field, Node, Pattern, Replacement, Rule, Template, +}; +#[derive(Clone, Copy, Default)] struct LoweringContext<'a> { - /// Enclosing optional-field label to exit when a computed value is absent. - fallible_scope: Option<&'a Lifetime>, + dependency_scope: Option>, } -fn fresh_fallible_label() -> Lifetime { +#[derive(Clone, Copy)] +struct DependencyScope<'a> { + preparations: &'a RefCell>, +} + +struct DependencyPreparation { + value: Ident, + source_range: Ident, + expression: TokenStream, +} + +fn fresh_prepared_ident(suffix: &str) -> Ident { static COUNTER: AtomicUsize = AtomicUsize::new(0); let n = COUNTER.fetch_add(1, Ordering::Relaxed); - Lifetime::new(&format!("'__yeast_field_{n}"), Span::call_site()) + Ident::new(&format!("__yeast_prepared_{suffix}_{n}"), Span::call_site()) } #[derive(Clone)] @@ -286,20 +299,19 @@ fn capture_bindings<'a>( impl Template { pub(crate) fn lower_root(&self) -> TokenStream { - self.lower(&LoweringContext { - fallible_scope: None, - }) + match self { + Template::Choice(alternatives) => Self::lower_choice(alternatives), + _ => self.lower_single(LoweringContext::default()), + } } pub(crate) fn lower_list(templates: &[Self]) -> TokenStream { - let context = LoweringContext { - fallible_scope: None, - }; + let context = LoweringContext::default(); let items = templates .iter() .map(|template| match template { Template::Node(node) => { - let node = node.lower(&context); + let node = node.lower(context); quote! { __nodes.push(#node); } } Template::Splice(block) => { @@ -310,6 +322,14 @@ impl Template { ctx.extend_field_ids(__value, &mut __nodes); } } + Template::Choice(alternatives) => { + let pick = Self::lower_choice(alternatives); + quote! { + if let ::std::option::Option::Some(__id) = #pick { + __nodes.push(__id); + } + } + } }) .collect::>(); @@ -322,19 +342,102 @@ impl Template { } } - fn lower(&self, context: &LoweringContext<'_>) -> TokenStream { + fn lower_single(&self, context: LoweringContext<'_>) -> TokenStream { match self { Template::Node(node) => node.lower(context), Template::Splice(block) => { let block = rust_block(block); quote! { ::std::convert::Into::::into(#block) } } + Template::Choice(_) => { + unreachable!("choices are lowered through Template::lower_choice") + } + } + } + + fn lower_choice(alternatives: &[Alternative]) -> TokenStream { + let pick = alternatives.iter().rev().fold( + quote! { ::std::option::Option::None }, + |fallback, alternative| { + alternative.lower_candidate(fallback) + }, + ); + quote! {{ #pick }} + } +} + +impl Alternative { + fn lower_candidate(&self, fallback: TokenStream) -> TokenStream { + let preparations = RefCell::new(Vec::new()); + let context = LoweringContext { + dependency_scope: Some(DependencyScope { + preparations: &preparations, + }), + }; + let value = self.template.lower_single(context); + let preparations = preparations.into_inner(); + let prepared_values = preparations + .iter() + .flat_map(|preparation| { + [ + preparation.value.clone(), + preparation.source_range.clone(), + ] + }) + .collect::>(); + let prepared = quote! { + ::std::option::Option::Some((#(#prepared_values),*)) + }; + + // Nest dependency matches around preparation in reverse, so they are + // evaluated in source order. Construction happens only after the + // resulting option has been matched, keeping it in a separate branch + // from the fallback. + let prepared = preparations.into_iter().rev().fold( + prepared, + |candidate, preparation| { + let DependencyPreparation { + value, + source_range, + expression, + } = preparation; + // Do not use `map`/`and_then` here: `candidate` may contain + // embedded user Rust, and putting it in a closure would change + // the target of `return` and the result type seen by `?`. + quote! { + match #expression { + ::std::option::Option::Some((#value, #source_range)) => #candidate, + ::std::option::Option::None => ::std::option::Option::None, + } + } + }, + ); + let prepared = if let Some(guard) = &self.guard { + let guard = rust_block(guard); + quote! { + if #guard { + #prepared + } else { + ::std::option::Option::None + } + } + } else { + prepared + }; + + quote! { + match #prepared { + ::std::option::Option::Some((#(#prepared_values),*)) => { + ::std::option::Option::Some(#value) + } + ::std::option::Option::None => #fallback, + } } } } impl Node { - fn lower(&self, context: &LoweringContext<'_>) -> TokenStream { + fn lower(&self, context: LoweringContext<'_>) -> TokenStream { let ctx = Ident::new("ctx", Span::call_site()); let kind_str = self.kind.to_string(); @@ -343,25 +446,38 @@ impl Node { Content::Static(lit) => quote! { #ctx.literal(#kind_str, #lit) }, Content::Computed(block) => { let block = rust_block(block); - if let Some(label) = context.fallible_scope { + if let Some(scope) = context.dependency_scope { + let value = fresh_prepared_ident("value"); + let source_range = fresh_prepared_ident("source_range"); + scope.preparations.borrow_mut().push(DependencyPreparation { + value: value.clone(), + source_range: source_range.clone(), + expression: quote! { + { + let __expr = #block; + yeast::MaybeYeastValue::maybe_yeast_value(&__expr).map( + |__value_ref| { + ( + yeast::YeastDisplay::yeast_to_string( + __value_ref, + &#ctx, + ), + yeast::YeastSourceRange::yeast_source_range( + __value_ref, + &#ctx, + ), + ) + }, + ) + } + }, + }); quote! { - { - let __expr = #block; - let ::std::option::Option::Some(__value_ref) = - yeast::MaybeYeastValue::maybe_yeast_value(&__expr) - else { - break #label ::std::option::Option::None; - }; - let __value = - yeast::YeastDisplay::yeast_to_string(__value_ref, &#ctx); - let __source_range = - yeast::YeastSourceRange::yeast_source_range(__value_ref, &#ctx); - #ctx.literal_with_source_range( - #kind_str, - &__value, - __source_range, - ) - } + #ctx.literal_with_source_range( + #kind_str, + &#value, + #source_range, + ) } } else { quote! { @@ -399,11 +515,7 @@ impl Node { #ctx.extend_location_ranges(__value, &mut __location_ranges); }); } - Field::Named { - name, - value, - optional, - } => { + Field::Named { name, value } => { let field_name = name.to_string(); let field_str = field_name .strip_prefix("r#") @@ -413,10 +525,8 @@ impl Node { &format!("__field_{field_str}_{field_counter}"), Span::call_site(), ); - match value { Template::Splice(block) => { - debug_assert!(!optional); let block = rust_block(block); stmts.push(quote! { let mut #temp: Vec = Vec::new(); @@ -430,16 +540,10 @@ impl Node { } }); } - Template::Node(child) if *optional => { - let label = fresh_fallible_label(); - let value = child.lower(&LoweringContext { - fallible_scope: Some(&label), - }); + Template::Choice(alternatives) => { + let pick = Template::lower_choice(alternatives); stmts.push(quote! { - #[allow(unused_labels)] - let #temp: ::std::option::Option = #label: { - ::std::option::Option::Some(#value) - }; + let #temp: ::std::option::Option = #pick; }); field_args.push(quote! { if let ::std::option::Option::Some(__id) = #temp { diff --git a/shared/yeast-macros/src/template_parse.rs b/shared/yeast-macros/src/template_parse.rs index 782407466a46..5d63a1e45c27 100644 --- a/shared/yeast-macros/src/template_parse.rs +++ b/shared/yeast-macros/src/template_parse.rs @@ -1,7 +1,7 @@ use proc_macro2::{Delimiter, Ident, Literal, Span, TokenStream, TokenTree}; use std::iter::Peekable; -use crate::ast::{Content, Field, Node, Template}; +use crate::ast::{Alternative, Content, Field, Node, Template}; type Tokens = Peekable; type Result = std::result::Result; @@ -9,7 +9,6 @@ type Result = std::result::Result; pub(crate) fn parse_template(input: TokenStream) -> Result