From 0124b8574b798f9b3955fe1ddbcae1e3542b2ec6 Mon Sep 17 00:00:00 2001 From: Lionel Henry Date: Thu, 23 Jul 2026 14:03:20 +0200 Subject: [PATCH] Implement `substitute()` effect handler --- crates/oak_core/src/syntax_ext.rs | 21 +++ crates/oak_semantic/src/builder.rs | 71 ++++++++ .../oak_semantic/src/builder/builder_nse.rs | 11 +- crates/oak_semantic/src/effects.rs | 68 ++++++-- .../oak_semantic/src/effects/contrib/base.rs | 113 ++++++++++++- .../oak_semantic/tests/integration/builder.rs | 157 +++++++++++++++++- 6 files changed, 422 insertions(+), 19 deletions(-) diff --git a/crates/oak_core/src/syntax_ext.rs b/crates/oak_core/src/syntax_ext.rs index fd140631d6..9b7419d5bc 100644 --- a/crates/oak_core/src/syntax_ext.rs +++ b/crates/oak_core/src/syntax_ext.rs @@ -1,7 +1,10 @@ +use aether_syntax::AnyRExpression; use aether_syntax::AnyRSelector; +use aether_syntax::RCall; use aether_syntax::RIdentifier; use aether_syntax::RStringValue; use biome_rowan::AstNode; +use biome_rowan::AstSeparatedList; // Candidates for upstreaming into `aether_syntax`. @@ -61,6 +64,24 @@ impl AnyRSelectorExt for AnyRSelector { } } +pub trait RCallExt { + /// The value expression of the argument at `position`, counting in call + /// order. `None` when there's no argument there or it has no value. + fn argument_value(&self, position: usize) -> Option; +} + +impl RCallExt for RCall { + fn argument_value(&self, position: usize) -> Option { + self.arguments() + .ok()? + .items() + .iter() + .nth(position)? + .ok()? + .value() + } +} + #[cfg(test)] mod tests { use aether_parser::RParserOptions; diff --git a/crates/oak_semantic/src/builder.rs b/crates/oak_semantic/src/builder.rs index b329bec8cf..b172f4846a 100644 --- a/crates/oak_semantic/src/builder.rs +++ b/crates/oak_semantic/src/builder.rs @@ -58,6 +58,7 @@ use rustc_hash::FxHashSet; use crate::effects::AssignBinding; use crate::effects::ResolvedArgumentEffects; +use crate::effects::ScopeBindings; use crate::effects::TargetAccess; use crate::resolver::ImportsResolver; use crate::resolver::SourceResolution; @@ -491,6 +492,40 @@ impl SemanticIndexBuilder { self.walked_binding(scope, name).is_some() || self.bound_names[scope].binds(name) } + /// Whether the current evaluation frame binds `name` (see [`scan_scope`]). + /// For a scope, delegates to [`scope_binds_anywhere`]. For a `local()` + /// descent body, the names collected into it so far. + /// + /// [`scan_scope`]: Self::scan_scope + /// [`scope_binds_anywhere`]: Self::scope_binds_anywhere + fn scan_scope_binds(&self, name: &str) -> bool { + match self.scan_scope() { + Some(ScanScope::Descent(bound)) => bound.binds(name), + Some(ScanScope::Scope(scope)) => self.scope_binds_anywhere(scope, name), + None => false, + } + } + + fn scan_scope_is_global(&self) -> bool { + match self.scan_scope() { + Some(ScanScope::Scope(scope)) => matches!(self.scopes[scope].kind, ScopeKind::File), + Some(ScanScope::Descent(_)) => false, + None => true, + } + } + + fn scan_scope(&self) -> Option> { + if let Some(bound) = self.eager_descent.open.last() { + return Some(ScanScope::Descent(bound)); + } + + let scope = match self.scopes[self.current_scope].kind { + ScopeKind::Nse(EvalEnv::Current, EvalTiming::Lazy) => self.definition_owner()?, + _ => self.current_scope, + }; + Some(ScanScope::Scope(scope)) + } + /// The site where `scope` binds `name`, matching what /// [`scope_binds_anywhere`](Self::scope_binds_anywhere) counts as a binding /// (so it returns `Some` on exactly the same names). Prefers the @@ -1497,6 +1532,31 @@ struct SourcedFile { resolution: Option, } +/// Backs a [`CallContext`]'s [`ScopeBindings`] with the builder's live scope +/// state, so an effect handler (`substitute`) can query bindings during the +/// scan without reaching into the builder directly. +/// +/// [`CallContext`]: crate::effects::CallContext +struct ScanBindings<'a, R: ImportsResolver> { + builder: &'a SemanticIndexBuilder, +} + +impl ScopeBindings for ScanBindings<'_, R> { + fn is_bound(&self, name: &str, inherits: bool) -> bool { + if inherits { + // The scan's `flow_state` carries the current scope's bindings plus + // the inherited eager environment seeded at `begin_scan`, so it's + // the lexical answer. + return self.builder.flow_state.is_bound(name); + } + self.builder.scan_scope_binds(name) + } + + fn is_global_scope(&self) -> bool { + self.builder.scan_scope_is_global() + } +} + /// The scan's flow-precise binding state: which names are bound at the current /// point of the current scan unit, in flow order. /// @@ -1604,6 +1664,17 @@ impl BoundNames { } } +/// A scope as the scan sees it. A `local()` body scanned inline has no arena +/// scope yet and its bindings are stored in the staging [`EagerNestedDescent`]. +/// Every other scope is materialized in the arena. [`scan_scope`] resolves +/// which one is the current evaluation frame. +/// +/// [`scan_scope`]: SemanticIndexBuilder::scan_scope +enum ScanScope<'a> { + Descent(&'a BoundNames), + Scope(ScopeId), +} + fn is_assignment(bin: &RBinaryExpression) -> bool { let Ok(op) = bin.operator() else { return false; diff --git a/crates/oak_semantic/src/builder/builder_nse.rs b/crates/oak_semantic/src/builder/builder_nse.rs index 321bad5e7e..b2ef12dcfd 100644 --- a/crates/oak_semantic/src/builder/builder_nse.rs +++ b/crates/oak_semantic/src/builder/builder_nse.rs @@ -14,6 +14,7 @@ use super::is_assignment; use super::is_right_assignment; use super::is_super_assignment; use super::BoundNames; +use super::ScanBindings; use super::SemanticIndexBuilder; use super::SourcedFile; use crate::effects; @@ -211,7 +212,9 @@ impl SemanticIndexBuilder { } let handlers = self.resolve_symbol_effects(op_text, bin.syntax().text_trimmed_range())?; - let ctx = CallContext::new(); + + let bindings = ScanBindings { builder: &*self }; + let ctx = CallContext::with_bindings(&bindings); handlers.assign?.resolve(EffectSite::Operator(bin), &ctx) } @@ -314,7 +317,11 @@ impl SemanticIndexBuilder { /// Resolve a call's effects. fn resolve_effects(&mut self, call: &RCall) -> Option { let handlers = self.resolve_effects_handlers(call)?; - let ctx = CallContext::new(); + + // `resolve_effects_handlers()` returns owned handlers, so its `&mut + // self` borrow is finished. Reborrow immutably. + let bindings = ScanBindings { builder: &*self }; + let ctx = CallContext::with_bindings(&bindings); let arguments = handlers .arguments diff --git a/crates/oak_semantic/src/effects.rs b/crates/oak_semantic/src/effects.rs index 186239ce97..f2ef12bdf9 100644 --- a/crates/oak_semantic/src/effects.rs +++ b/crates/oak_semantic/src/effects.rs @@ -94,7 +94,7 @@ pub trait EffectHandler: std::fmt::Debug + Sync { /// /// `ctx` provides semantic resolution, e.g. resolve an argument to a /// statically known string or boolean. - fn resolve(&self, call: &RCall, ctx: &CallContext) -> Option; + fn resolve(&self, call: &RCall, ctx: &CallContext<'_>) -> Option; } /// Where an effect is invoked. Most effects are only ever calls but an Assign @@ -113,7 +113,26 @@ pub enum EffectSite<'a> { /// Contributed statically like [`EffectHandler`], so it's `Sync` for the /// registry `static`s. pub trait AssignHandler: std::fmt::Debug + Sync { - fn resolve(&self, site: EffectSite, ctx: &CallContext) -> Option>; + fn resolve(&self, site: EffectSite, ctx: &CallContext<'_>) -> Option>; +} + +/// Scope state a handler needs that the call syntax alone can't answer, backed +/// by the builder's flow-precise binding tables. +/// +/// `substitute` uses this to tell which symbols in its argument name a binding +/// in the current scope (so they resolve here, against substitute's env) from +/// those that stay quoted (so they resolve wherever the result is later +/// evaluated). +pub trait ScopeBindings { + /// Whether `name` is bound in the current scope. With `inherits`, also + /// counts bindings inherited from enclosing scopes, mirroring R's + /// `get(..., inherits=)`. + fn is_bound(&self, name: &str, inherits: bool) -> bool; + + /// Whether the current scope is the global (file) scope. R's `substitute` + /// substitutes nothing in the global environment, so a handler falls back to + /// a plain quote there. + fn is_global_scope(&self) -> bool; } /// Whether an assign effect reads its target before writing it. @@ -128,15 +147,38 @@ pub enum TargetAccess { /// Context for effect handlers. /// -/// Allows querying the properties or static values of arguments. Stateless -/// today, an extension point for information a call's syntax doesn't carry (e.g. -/// resolving a `character.only` variable to its string value) once that lands. +/// Allows querying the properties or static values of arguments, and the +/// binding state of the surrounding scope. #[derive(Default)] -pub struct CallContext; +pub struct CallContext<'a> { + bindings: Option<&'a dyn ScopeBindings>, +} + +impl<'a> CallContext<'a> { + /// A context backed by the builder's scope state, for handlers that query + /// bindings (`substitute`). + pub fn with_bindings(bindings: &'a dyn ScopeBindings) -> Self { + Self { + bindings: Some(bindings), + } + } + + /// Whether `name` is bound in the current scope (see + /// [`ScopeBindings::is_bound`]). Without a bindings backing (a [`Default`] + /// context) we can't tell, so we answer "unbound", the choice that leaves a + /// symbol quoted rather than treating it as a use. + pub fn is_bound(&self, name: &str, inherits: bool) -> bool { + self.bindings + .is_some_and(|bindings| bindings.is_bound(name, inherits)) + } -impl CallContext { - pub fn new() -> Self { - Self + /// Whether the current scope is the global (file) scope (see + /// [`ScopeBindings::is_global_scope`]). Without a bindings backing (a + /// [`Default`] context) we assume global, so `substitute` degrades to a + /// plain quote (its no-substitution behaviour). + pub fn current_scope_is_global(&self) -> bool { + self.bindings + .is_none_or(|bindings| bindings.is_global_scope()) } /// Match `call`'s arguments to `formals`, returning for each call argument @@ -287,7 +329,7 @@ impl ArgumentEffect { impl EffectHandler for ArgumentsAnnotation { type Output = ResolvedArgumentEffects; - fn resolve(&self, call: &RCall, ctx: &CallContext) -> Option { + fn resolve(&self, call: &RCall, ctx: &CallContext<'_>) -> Option { let arguments = self.arguments; let formals: Vec = arguments .iter() @@ -322,7 +364,7 @@ pub struct SourceAnnotation { impl EffectHandler for SourceAnnotation { type Output = Vec; - fn resolve(&self, call: &RCall, ctx: &CallContext) -> Option> { + fn resolve(&self, call: &RCall, ctx: &CallContext<'_>) -> Option> { let args = call.arguments().ok()?; // The path is matched positionally among unnamed arguments rather than @@ -387,7 +429,7 @@ pub struct AssignAnnotation { } impl AssignHandler for AssignAnnotation { - fn resolve(&self, site: EffectSite, ctx: &CallContext) -> Option> { + fn resolve(&self, site: EffectSite, ctx: &CallContext<'_>) -> Option> { let EffectSite::Call(call) = site else { return None; }; @@ -454,7 +496,7 @@ pub struct BindingOperatorHandler { } impl AssignHandler for BindingOperatorHandler { - fn resolve(&self, site: EffectSite, ctx: &CallContext) -> Option> { + fn resolve(&self, site: EffectSite, ctx: &CallContext<'_>) -> Option> { let EffectSite::Operator(bin) = site else { return None; }; diff --git a/crates/oak_semantic/src/effects/contrib/base.rs b/crates/oak_semantic/src/effects/contrib/base.rs index fa62ba15df..12e3e81870 100644 --- a/crates/oak_semantic/src/effects/contrib/base.rs +++ b/crates/oak_semantic/src/effects/contrib/base.rs @@ -1,8 +1,12 @@ use aether_syntax::AnyRExpression; +use aether_syntax::RArgumentNameClause; use aether_syntax::RCall; +use aether_syntax::RIdentifier; +use aether_syntax::RParameter; use biome_rowan::AstNode; use biome_rowan::AstSeparatedList; use biome_rowan::WalkEvent; +use oak_core::syntax_ext::RCallExt; use oak_core::syntax_ext::RIdentifierExt; use crate::effects::contrib::assign; @@ -42,6 +46,19 @@ pub(crate) static ENTRIES: &[Entry] = &[ assign: None, }, }, + // `substitute` quotes `expr` too, but replaces the symbols its environment + // binds, so it needs a handler that queries the scope rather than a static + // per-argument effect. + Entry { + package: "base", + function: "substitute", + effects: EffectsHandlers { + arguments: Some(&SubstituteHandler), + attach: None, + source: None, + assign: None, + }, + }, // base attach. `library`/`require` share `LibraryHandler` (below). attach_entry("library"), attach_entry("require"), @@ -62,7 +79,7 @@ pub(crate) struct BquoteHandler; impl EffectHandler for BquoteHandler { type Output = ResolvedArgumentEffects; - fn resolve(&self, call: &RCall, ctx: &CallContext) -> Option { + fn resolve(&self, call: &RCall, ctx: &CallContext<'_>) -> Option { // `bquote(expr, where, splice)`: only `expr` (the first positional) is // quoted. The other arguments are ordinary values. let formals = [ @@ -152,6 +169,98 @@ fn unquote_hole(call: &RCall, splice: bool) -> Option { call.arguments().ok()?.items().iter().next()?.ok()?.value() } +/// Handler for `substitute()`. It quotes `expr` like `quote()`, but replaces +/// each symbol bound in its environment (the current frame by default) with what +/// that binding holds. Those substituted symbols are live uses of the frame +/// binding. The remaining part of the expression stays quoted and resolves +/// wherever the result is later evaluated. +#[derive(Debug, Clone, Copy)] +pub(crate) struct SubstituteHandler; + +impl EffectHandler for SubstituteHandler { + type Output = ResolvedArgumentEffects; + + fn resolve(&self, call: &RCall, ctx: &CallContext<'_>) -> Option { + // `substitute(expr, env)`: only `expr` (formal 0) is quoted, everything + // else is a plain value. + let formals = [ + Formal { + name: "expr", + position: 0, + }, + Formal { + name: "env", + position: 1, + }, + ]; + let matched = ctx.match_arguments(call, &formals); + let expr_pos = matched.iter().position(|formal| *formal == Some(0))?; + + // Only the default `env`, the current frame, is one we can query. Any + // explicit `env` names a frame we can't see into, so we bail to a plain + // quote. + // + // TODO(nse, env): resolve an explicit env to its binding set (a + // `list(...)`, `new.env()`, `parent.frame()`, an env-typed variable) once + // environment captures and argument resolution land, and substitute + // against that instead of bailing. + let default_env = !matched.contains(&Some(1)); + + // Substitution is disabled in the global environment + let substitutes = default_env && !ctx.current_scope_is_global(); + + let holes = if substitutes { + call.argument_value(expr_pos) + .map(|expr| substituted_symbols(&expr, ctx)) + .unwrap_or_default() + } else { + Vec::new() + }; + + // Effects align 1:1 with the call's arguments. Only the `expr` slot is + // quoted, the rest is plain. + let mut effects = vec![None; matched.len()]; + effects[expr_pos] = Some(ResolvedArgumentEffect::Quote { holes }); + Some(effects) + } +} + +/// The symbols in a `substitute`d expression that name a binding in the current +/// frame. R walks the whole parse tree and replaces every symbol the frame +/// binds, `$`/`@` members and both sides of `::` included, but never the tags +/// that name an argument (`f(x = .)`) or a formal (`function(x) .`). So we +/// collect every frame-bound `RIdentifier` outside those two tag positions. Each +/// becomes a hole the builder records as a use of that binding; the rest stay +/// inert. +fn substituted_symbols(expr: &AnyRExpression, ctx: &CallContext<'_>) -> Vec { + let mut holes = Vec::new(); + for event in expr.syntax().preorder() { + let WalkEvent::Enter(node) = event else { + continue; + }; + let Some(ident) = RIdentifier::cast(node) else { + continue; + }; + if is_protected_name(&ident) { + continue; + } + if ctx.is_bound(&ident.name_text(), false) { + holes.push(AnyRExpression::RIdentifier(ident)); + } + } + holes +} + +/// Whether `ident` is a tag naming an argument (`f(x = .)`) or a formal +/// parameter (`function(x) .`), the two positions R's `substitute` leaves +/// untouched. Each is the sole identifier child of its clause node, so the +/// parent kind identifies it. +fn is_protected_name(ident: &RIdentifier) -> bool { + ident.syntax().parent().is_some_and(|parent| { + RArgumentNameClause::can_cast(parent.kind()) || RParameter::can_cast(parent.kind()) + }) +} + /// Build the attach [`Entry`] for a base function served by [`LibraryHandler`]. const fn attach_entry(function: &'static str) -> Entry { Entry { @@ -178,7 +287,7 @@ pub(crate) struct LibraryHandler; impl EffectHandler for LibraryHandler { type Output = String; - fn resolve(&self, call: &RCall, ctx: &CallContext) -> Option { + fn resolve(&self, call: &RCall, ctx: &CallContext<'_>) -> Option { // `character.only` sits at signature position 4 in both callees; in // practice it's passed by name. let formals = [ diff --git a/crates/oak_semantic/tests/integration/builder.rs b/crates/oak_semantic/tests/integration/builder.rs index 39ce387b8a..650f7a4170 100644 --- a/crates/oak_semantic/tests/integration/builder.rs +++ b/crates/oak_semantic/tests/integration/builder.rs @@ -1022,6 +1022,159 @@ fn test_bquote_multiple_holes() { ); } +#[test] +fn test_substitute_reports_parameter_use() { + // `substitute(x)` in a function frame substitutes the parameter `x`, so `x` + // is a use of that binding (the `deparse(substitute(x))` idiom). + let index = index_with_base("f <- function(x) substitute(x)"); + let fun = ScopeId::from(1); + + assert_eq!( + index.symbols(fun).get("x").unwrap().flags(), + SymbolFlags::IS_BOUND + .union(SymbolFlags::IS_USED) + .union(SymbolFlags::IS_PARAMETER) + ); +} + +#[test] +fn test_substitute_leaves_free_symbol_quoted() { + // A symbol the frame doesn't bind stays quoted, so `y` is not a use while the + // parameter `x` is. + let index = index_with_base("f <- function(x) substitute(x + y)"); + let fun = ScopeId::from(1); + + assert!(index.symbols(fun).get("y").is_none()); + assert_eq!( + index.symbols(fun).get("x").unwrap().flags(), + SymbolFlags::IS_BOUND + .union(SymbolFlags::IS_USED) + .union(SymbolFlags::IS_PARAMETER) + ); +} + +#[test] +fn test_substitute_global_frame_quotes() { + // R substitutes nothing in the global environment, so a top-level + // `substitute` is a plain quote: `a` stays bound-only and `b` is absent. The + // one use is the `substitute` callee itself. + let index = index_with_base("a <- 1\nsubstitute(a + b)"); + let file = ScopeId::from(0); + + assert_eq!( + index.symbols(file).get("a").unwrap().flags(), + SymbolFlags::IS_BOUND + ); + assert!(index.symbols(file).get("b").is_none()); + assert_eq!(index.uses(file).len(), 1); +} + +#[test] +fn test_substitute_protects_argument_tag() { + // The value `x` is substituted, but the `x =` tag is a name, not a symbol, so + // it stays quoted. The two uses are the `substitute` callee and the value + // `x`; without tag protection there would be a third for the tag. + let index = index_with_base("f <- function(x) substitute(list(x = x))"); + let fun = ScopeId::from(1); + + assert!(index.symbols(fun).get("list").is_none()); + assert_eq!( + index.symbols(fun).get("x").unwrap().flags(), + SymbolFlags::IS_BOUND + .union(SymbolFlags::IS_USED) + .union(SymbolFlags::IS_PARAMETER) + ); + assert_eq!(index.uses(fun).len(), 2); +} + +#[test] +fn test_substitute_replaces_extraction_member() { + // Unlike ordinary evaluation, `substitute` replaces the `$` member too, so + // the parameter `v` in `d$v` is a use while `d` stays quoted. + let index = index_with_base("f <- function(v) substitute(d$v)"); + let fun = ScopeId::from(1); + + assert!(index.symbols(fun).get("d").is_none()); + assert_eq!( + index.symbols(fun).get("v").unwrap().flags(), + SymbolFlags::IS_BOUND + .union(SymbolFlags::IS_USED) + .union(SymbolFlags::IS_PARAMETER) + ); +} + +#[test] +fn test_substitute_explicit_env_quotes_even_environment() { + // We bail on any explicit `env`, even `environment()` (which names the frame + // we'd otherwise query), until proper env resolution lands. So `x` stays + // quoted rather than being reported as a use. + let index = index_with_base("f <- function(x) substitute(x, environment())"); + let fun = ScopeId::from(1); + + assert_eq!( + index.symbols(fun).get("x").unwrap().flags(), + SymbolFlags::IS_BOUND.union(SymbolFlags::IS_PARAMETER) + ); +} + +#[test] +fn test_substitute_non_default_env_quotes() { + // An explicit `env` we can't see into falls back to a plain quote, so the + // parameter `x` is not reported as a use. + let index = index_with_base("f <- function(x) substitute(x, list())"); + let fun = ScopeId::from(1); + + assert_eq!( + index.symbols(fun).get("x").unwrap().flags(), + SymbolFlags::IS_BOUND.union(SymbolFlags::IS_PARAMETER) + ); +} + +#[test] +fn test_substitute_local_frame() { + // Inside `local()`, the frame is the local body, so a name the body binds is + // substituted and reported as a use. + let index = index_with_base("local({\n y <- 1\n substitute(y)\n})"); + let local = ScopeId::from(1); + + assert_eq!( + index.symbols(local).get("y").unwrap().flags(), + SymbolFlags::IS_BOUND.union(SymbolFlags::IS_USED) + ); +} + +#[test] +fn test_substitute_quotes_nested_function_inertly() { + // A nested function in the argument is inert language data. The frame binds + // nothing, so no symbol inside is a use, and the function is not itself a + // scope. It would only become a scope once the result is evaluated. + let index = index_with_base("f <- function() substitute(function(x) x + 1)"); + let fun = ScopeId::from(1); + + assert!(index.symbols(fun).get("x").is_none()); + assert_eq!(index.child_scope_ids(fun).count(), 0); +} + +#[test] +fn test_substitute_replaces_symbol_in_nested_function() { + // `substitute` replaces symbols syntactically, ignoring the nested function's + // own scope, so the body `x` (bound by the outer frame) is a use of the outer + // parameter. The inner formal `x` is a tag and stays quoted, and the nested + // function is not itself a scope. The two uses are the `substitute` callee + // and the body `x`. + let index = index_with_base("g <- function(x) substitute(function(x) x + 1)"); + let fun = ScopeId::from(1); + + assert_eq!( + index.symbols(fun).get("x").unwrap().flags(), + SymbolFlags::IS_BOUND + .union(SymbolFlags::IS_USED) + .union(SymbolFlags::IS_PARAMETER) + ); + assert_eq!(index.child_scope_ids(fun).count(), 0); + assert_eq!(index.uses(fun).len(), 2); +} + #[test] fn test_local_quote_definition_shadows() { // A local `quote` shadows base's, so `quote(y)` is an ordinary call and `y` @@ -2314,7 +2467,7 @@ static COLLATION_HANDLER: CollationHandler = CollationHandler; impl EffectHandler for CollationHandler { type Output = Vec; - fn resolve(&self, _call: &RCall, _ctx: &CallContext) -> Option> { + fn resolve(&self, _call: &RCall, _ctx: &CallContext<'_>) -> Option> { Some(vec!["a.R".into(), "b.R".into()]) } } @@ -2375,7 +2528,7 @@ struct MultiAssignHandler; static MULTI_ASSIGN_HANDLER: MultiAssignHandler = MultiAssignHandler; impl AssignHandler for MultiAssignHandler { - fn resolve(&self, site: EffectSite, _ctx: &CallContext) -> Option> { + fn resolve(&self, site: EffectSite, _ctx: &CallContext<'_>) -> Option> { let EffectSite::Call(call) = site else { return None; };