From 452ab944eb3a5b9c51b0beee67a1bdf20393e0f2 Mon Sep 17 00:00:00 2001 From: Taus Date: Fri, 6 Nov 2026 13:27:47 +0000 Subject: [PATCH 1/5] yeast: Isolate interpolation contexts Wraps each interpolation is a small bit of code that clones the user context before running the block of code, and restores it afterwards. We only do this for blocks that actually mention the `ctx` variable. Other cases (the vast majority) just inline the block directly, so that there's no unnecessary overhead in the most common case. The `ctx` check is not perfect -- it won't notice cases where you (outside the interpolation) rebind it to a different variable, say, but it should be a good enough test for the way we currently write rules. --- shared/yeast-macros/src/lower.rs | 128 +++++++++++++++++++++---------- 1 file changed, 89 insertions(+), 39 deletions(-) diff --git a/shared/yeast-macros/src/lower.rs b/shared/yeast-macros/src/lower.rs index 6cf8767e2f1b..b26e4c569d1d 100644 --- a/shared/yeast-macros/src/lower.rs +++ b/shared/yeast-macros/src/lower.rs @@ -1,5 +1,5 @@ -use proc_macro2::{Ident, Span, TokenStream}; -use quote::quote; +use proc_macro2::{Ident, Span, TokenStream, TokenTree}; +use quote::{quote, ToTokens}; use std::cell::RefCell; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -324,12 +324,16 @@ impl Template { quote! { __nodes.push(#node); } } Template::Splice(block) => { + let uses_ctx = block_uses_ctx(block); let block = rust_block(block); - quote! { - let __value = ::std::iter::IntoIterator::into_iter(#block) - .collect::>(); - ctx.extend_field_ids(__value, &mut __nodes); - } + isolated_interpolation( + uses_ctx, + quote! { + let __value = ::std::iter::IntoIterator::into_iter(#block) + .collect::>(); + ctx.extend_field_ids(__value, &mut __nodes); + }, + ) } Template::Choice(alternatives) => { let pick = Self::lower_choice(alternatives); @@ -355,8 +359,14 @@ impl Template { match self { Template::Node(node) => node.lower(context), Template::Splice(block) => { + let uses_ctx = block_uses_ctx(block); let block = rust_block(block); - quote! { ::std::convert::Into::::into(#block) } + isolated_interpolation( + uses_ctx, + quote! { + ::std::convert::Into::::into(#block) + }, + ) } Template::Choice(_) => { unreachable!("choices are lowered through Template::lower_choice") @@ -453,35 +463,38 @@ impl Node { let node = match content { Content::Static(lit) => quote! { #ctx.literal(#kind_str, #lit) }, Content::Computed(block) => { + let uses_ctx = block_uses_ctx(block); let block = rust_block(block); if let Some(scope) = context.choice_alternative_scope { let value = fresh_prepared_ident("value"); let source_range = fresh_prepared_ident("source_range"); + let expression = isolated_interpolation( + uses_ctx, + 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, + ), + ) + }, + ) + }, + ); scope .interpolations .borrow_mut() .push(GuardingInterpolation { 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, - ), - ) - }, - ) - } - }, + expression, }); quote! { #ctx.literal_with_source_range( @@ -491,8 +504,9 @@ impl Node { ) } } else { - quote! { - { + isolated_interpolation( + uses_ctx, + quote! { let __expr = #block; let __value = yeast::YeastDisplay::yeast_to_string(&__expr, &#ctx); @@ -503,8 +517,8 @@ impl Node { &__value, __source_range, ) - } - } + }, + ) } } Content::Inherited => quote! { #ctx.inherited_literal(#kind_str) }, @@ -524,12 +538,16 @@ impl Node { for (field_counter, field) in self.fields.iter().enumerate() { match field { Field::Location { block } => { + let uses_ctx = block_uses_ctx(block); let block = rust_block(block); - stmts.push(quote! { - let __value = ::std::iter::IntoIterator::into_iter(#block) - .collect::>(); - #ctx.extend_location_ranges(__value, &mut __location_ranges); - }); + stmts.push(isolated_interpolation( + uses_ctx, + quote! { + let __value = ::std::iter::IntoIterator::into_iter(#block) + .collect::>(); + #ctx.extend_location_ranges(__value, &mut __location_ranges); + }, + )); } Field::LocationNone => {} Field::Named { name, value } => { @@ -544,12 +562,19 @@ impl Node { ); match value { Template::Splice(block) => { + let uses_ctx = block_uses_ctx(block); let block = rust_block(block); + let interpolation = isolated_interpolation( + uses_ctx, + quote! { + let __value = ::std::iter::IntoIterator::into_iter(#block) + .collect::>(); + #ctx.extend_field_ids(__value, &mut #temp); + }, + ); stmts.push(quote! { let mut #temp: Vec = Vec::new(); - let __value = ::std::iter::IntoIterator::into_iter(#block) - .collect::>(); - #ctx.extend_field_ids(__value, &mut #temp); + #interpolation }); field_args.push(quote! { if !#temp.is_empty() { @@ -612,3 +637,28 @@ fn rust_block(block: &syn::Block) -> TokenStream { let statements = &block.stmts; quote!({ #(#statements)* }) } + +fn isolated_interpolation(uses_ctx: bool, body: TokenStream) -> TokenStream { + if uses_ctx { + quote! {{ + let __saved_user_ctx = (*ctx).clone(); + let __result = { #body }; + *ctx = __saved_user_ctx; + __result + }} + } else { + quote! {{ #body }} + } +} + +fn block_uses_ctx(block: &syn::Block) -> bool { + fn uses_ctx(tokens: TokenStream) -> bool { + tokens.into_iter().any(|token| match token { + TokenTree::Ident(ident) => ident == "ctx", + TokenTree::Group(group) => uses_ctx(group.stream()), + TokenTree::Punct(_) | TokenTree::Literal(_) => false, + }) + } + + uses_ctx(block.to_token_stream()) +} From b672a33b338483cf8cd1c12111e4868384245cc9 Mon Sep 17 00:00:00 2001 From: Taus Date: Fri, 6 Nov 2026 13:28:25 +0000 Subject: [PATCH 2/5] yeast: Test interpolation context isolation --- shared/yeast/tests/test.rs | 120 +++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/shared/yeast/tests/test.rs b/shared/yeast/tests/test.rs index f0e705c0f139..c519bf00012b 100644 --- a/shared/yeast/tests/test.rs +++ b/shared/yeast/tests/test.rs @@ -943,6 +943,126 @@ fn test_choice_fallback_can_reuse_consumed_splice() { ); } +#[derive(Clone, Default)] +struct InterpolationContext { + counter: usize, +} + +#[test] +fn test_output_template_interpolations_clone_user_context() { + let input = "x = 1"; + let input_ast = parse_input(input); + let mut ast = empty_output_ast(input); + let mut user_ctx = InterpolationContext::default(); + let mut ctx = yeast::build::BuildCtx::new(&input_ast, &mut ast, &mut user_ctx); + let source = yeast::tree!((identifier "source")); + + let nodes = yeast::trees!( + (call + location: { + ctx.counter += 1; + source + } + method: { + ctx.counter += 1; + let value = ctx.counter.to_string(); + ctx.literal("identifier", &value) + } + receiver: (identifier #{ + ctx.counter += 1; + ctx.counter + }) + ) + { + ctx.counter += 1; + let value = ctx.counter.to_string(); + [ctx.literal("identifier", &value)] + } + ); + + assert_eq!(ctx.counter, 0); + assert_eq!(nodes.len(), 2); + assert_dump_eq( + &dump_ast(ctx.output, nodes[0], input), + r#" + call + method: identifier "1" + receiver: identifier "1" + "#, + ); + assert_dump_eq( + &dump_ast(ctx.output, nodes[1], input), + r#" + identifier "1" + "#, + ); +} + +#[test] +fn test_interpolation_preserves_build_ctx_method_borrowing() { + let input = "x = 1"; + let input_ast = parse_input(input); + let mut ast = empty_output_ast(input); + let mut user_ctx = InterpolationContext::default(); + let mut ctx = yeast::build::BuildCtx::new(&input_ast, &mut ast, &mut user_ctx); + let source = yeast::tree!((identifier "source")); + + let node = yeast::tree!((call + method: { + ctx.literal("identifier", &ctx.source_text(source)) + } + )); + + assert_dump_eq( + &dump_ast(ctx.output, node, input), + r#" + call + method: identifier "source" + "#, + ); +} + +#[test] +fn test_interpolation_preserves_user_context_assignment() { + let input = "x = 1"; + let input_ast = parse_input(input); + let mut ast = empty_output_ast(input); + let mut user_ctx = InterpolationContext::default(); + let mut ctx = yeast::build::BuildCtx::new(&input_ast, &mut ast, &mut user_ctx); + + let node = yeast::tree!((identifier #{ + *ctx = InterpolationContext { counter: 1 }; + ctx.counter + })); + + assert_eq!(ctx.counter, 0); + assert_dump_eq( + &dump_ast(ctx.output, node, input), + r#" + identifier "1" + "#, + ); +} + +#[derive(Clone, Default)] +struct BorrowedInterpolationContext { + locations: Vec, +} + +#[test] +fn test_interpolation_accepts_value_borrowed_from_context() { + let input = "x = 1"; + let input_ast = parse_input(input); + let mut ast = empty_output_ast(input); + let mut user_ctx = BorrowedInterpolationContext::default(); + let mut ctx = yeast::build::BuildCtx::new(&input_ast, &mut ast, &mut user_ctx); + let location = yeast::tree!((identifier "location")); + ctx.locations.push(location); + let locations = ctx.locations.iter(); + + yeast::tree!((call location: {locations})); +} + #[test] fn test_root_choice_returns_an_optional_node() { let input = "x = 1"; From 161ee6c8e76f3719c2451e19115acc65688bdcbe Mon Sep 17 00:00:00 2001 From: Taus Date: Fri, 6 Nov 2026 13:28:58 +0000 Subject: [PATCH 3/5] Unified: Use isolated interpolation contexts This allows us to get rid of two uses of `ctx.scoped`, cleaning up the code a bit in the process. --- .../extractor/src/languages/swift/swift.rs | 35 ++++++------------- 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/unified/extractor/src/languages/swift/swift.rs b/unified/extractor/src/languages/swift/swift.rs index 4599cc79f62f..db8f6490a265 100644 --- a/unified/extractor/src/languages/swift/swift.rs +++ b/unified/extractor/src/languages/swift/swift.rs @@ -1,5 +1,5 @@ use codeql_extractor::extractor::desugaring; -use yeast::{ConcreteDesugarer, DesugaringConfig, Rule, tree}; +use yeast::{tree, ConcreteDesugarer, DesugaringConfig, Rule}; /// User context propagated from outer rules down to the inner rules that /// emit the corresponding output declarations, so that each emitted node @@ -51,10 +51,9 @@ impl SwiftContext { /// Called before recursively translating a body / initializer /// slot. Most rules mutate `ctx` in place — the framework invokes /// each rule with a private clone of the user context, so - /// mutations are discarded on rule exit anyway. Rules that need - /// the outer context intact *after* the reset-and-translate (see - /// e.g. the `property_binding` willSet/didSet rule) wrap the - /// mutation in `ctx.scoped(...)` instead. + /// mutations are discarded on rule exit anyway. Output-template + /// interpolations additionally restore the context after evaluation, + /// preventing mutations from leaking into sibling fields. fn reset(&mut self) { *self = SwiftContext::default(); } @@ -382,17 +381,13 @@ fn translation_rules() -> Vec> { let mut result = Vec::new(); if let Some(val) = val { // Stored property with observers: the initializer is not part - // of the binding, so translate it in a reset scope. - let val = ctx.scoped(|ctx| { - ctx.reset(); - ctx.translate(val) - }); + // of the binding, so translate it in a reset interpolation. result.push(tree!( (variable_declaration modifier: {ctx.outer_modifiers.clone()} pattern: (identifier #{name}) type: {ty} - value: {val}) + value: {ctx.reset(); val}) )); } for acc in accessors.into_iter() { @@ -747,19 +742,11 @@ fn translation_rules() -> Vec> { trailingClosure: _? @trailing additionalTrailingClosures: _* @additional) => - expr { - // Always translate the callee in non-pattern context. - let callee = ctx.scoped(|ctx| { - ctx.in_pattern = false; - ctx.translate(rawCallee) - }); - tree!((call_expr - callee: {callee} - argument: {args} - argument: {trailing.map(|trailing| tree!((argument value: {trailing})))} - argument: {additional} - )) - } + (call_expr + callee: {ctx.in_pattern = false; rawCallee} + argument: {args} + argument: {trailing.map(|trailing| tree!((argument value: {trailing})))} + argument: {additional}) ), // A call or enum-case pattern argument. rule!( From ee41076fceabc1291cdb289159343b063f1f35fa Mon Sep 17 00:00:00 2001 From: Taus Date: Fri, 6 Nov 2026 16:54:06 +0000 Subject: [PATCH 4/5] yeast: Only clone the user context --- shared/yeast-macros/src/lower.rs | 4 ++-- shared/yeast/tests/test.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/shared/yeast-macros/src/lower.rs b/shared/yeast-macros/src/lower.rs index b26e4c569d1d..480f8f1a82e3 100644 --- a/shared/yeast-macros/src/lower.rs +++ b/shared/yeast-macros/src/lower.rs @@ -641,9 +641,9 @@ fn rust_block(block: &syn::Block) -> TokenStream { fn isolated_interpolation(uses_ctx: bool, body: TokenStream) -> TokenStream { if uses_ctx { quote! {{ - let __saved_user_ctx = (*ctx).clone(); + let __saved_user_ctx = (*ctx.user_ctx).clone(); let __result = { #body }; - *ctx = __saved_user_ctx; + *ctx.user_ctx = __saved_user_ctx; __result }} } else { diff --git a/shared/yeast/tests/test.rs b/shared/yeast/tests/test.rs index c519bf00012b..9b357f7db64e 100644 --- a/shared/yeast/tests/test.rs +++ b/shared/yeast/tests/test.rs @@ -1044,6 +1044,33 @@ fn test_interpolation_preserves_user_context_assignment() { ); } +#[test] +fn test_interpolation_accepts_borrowed_build_ctx() { + fn build_identifier( + ctx: &mut yeast::build::BuildCtx<'_, InterpolationContext>, + ) -> yeast::OutputId { + yeast::tree!((identifier #{ + ctx.counter += 1; + ctx.counter + })) + } + + let input = "x = 1"; + let input_ast = parse_input(input); + let mut ast = empty_output_ast(input); + let mut user_ctx = InterpolationContext::default(); + let mut ctx = yeast::build::BuildCtx::new(&input_ast, &mut ast, &mut user_ctx); + + let node = build_identifier(&mut ctx); + assert_eq!(ctx.counter, 0); + assert_dump_eq( + &dump_ast(ctx.output, node, input), + r#" + identifier "1" + "#, + ); +} + #[derive(Clone, Default)] struct BorrowedInterpolationContext { locations: Vec, From e6205e032aadfa834a403ad3229c5553e1f17a14 Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 10 Nov 2026 16:14:22 +0000 Subject: [PATCH 5/5] yeast: Document interpolation context detection Also changes the `InterpolationContext` counter to use `u64` instead of `usize`. --- shared/yeast-macros/src/lower.rs | 5 +++++ shared/yeast/tests/test.rs | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/shared/yeast-macros/src/lower.rs b/shared/yeast-macros/src/lower.rs index 480f8f1a82e3..0aaa4c38e95f 100644 --- a/shared/yeast-macros/src/lower.rs +++ b/shared/yeast-macros/src/lower.rs @@ -651,6 +651,11 @@ fn isolated_interpolation(uses_ctx: bool, body: TokenStream) -> TokenStream { } } +/// Returns whether `block` directly mentions `ctx`. +/// +/// This lexical check cannot see context access hidden behind a helper or +/// macro. Such an interpolation can opt into isolation by explicitly +/// mentioning `ctx`, for example with `let _ = &ctx;`. fn block_uses_ctx(block: &syn::Block) -> bool { fn uses_ctx(tokens: TokenStream) -> bool { tokens.into_iter().any(|token| match token { diff --git a/shared/yeast/tests/test.rs b/shared/yeast/tests/test.rs index 9b357f7db64e..d802e81e626b 100644 --- a/shared/yeast/tests/test.rs +++ b/shared/yeast/tests/test.rs @@ -945,7 +945,7 @@ fn test_choice_fallback_can_reuse_consumed_splice() { #[derive(Clone, Default)] struct InterpolationContext { - counter: usize, + counter: u64, } #[test]