diff --git a/crates/oak_semantic/src/builder.rs b/crates/oak_semantic/src/builder.rs index b172f4846..14b559f54 100644 --- a/crates/oak_semantic/src/builder.rs +++ b/crates/oak_semantic/src/builder.rs @@ -28,54 +28,34 @@ use std::sync::Arc; use aether_syntax::AnyRExpression; -use aether_syntax::AnyRParameterName; use aether_syntax::AnyRValue; -use aether_syntax::RArgumentList; use aether_syntax::RBinaryExpression; -use aether_syntax::RCall; -use aether_syntax::RExpressionList; -use aether_syntax::RFunctionDefinition; -use aether_syntax::RNamespaceExpression; -use aether_syntax::RParameter; -use aether_syntax::RParameters; use aether_syntax::RRoot; use aether_syntax::RSyntaxKind; -use aether_syntax::RSyntaxNode; use biome_rowan::AstNode; -use biome_rowan::AstNodeList; -use biome_rowan::AstPtr; -use biome_rowan::AstSeparatedList; -use biome_rowan::SyntaxNodeCast; use biome_rowan::TextRange; -use biome_rowan::WalkEvent; -use oak_core::syntax_ext::AnyRSelectorExt; use oak_core::syntax_ext::RIdentifierExt; use oak_core::syntax_ext::RStringValueExt; use oak_index_vec::Idx; use oak_index_vec::IndexVec; use rustc_hash::FxHashMap; -use rustc_hash::FxHashSet; +use scan::BoundNames; +use scan::CallResolution; +use scan::EagerNestedDescent; +use scan::FlowState; -use crate::effects::AssignBinding; -use crate::effects::ResolvedArgumentEffects; -use crate::effects::ScopeBindings; -use crate::effects::TargetAccess; use crate::resolver::ImportsResolver; -use crate::resolver::SourceResolution; use crate::semantic_index::Definition; use crate::semantic_index::DefinitionId; -use crate::semantic_index::DefinitionKind; use crate::semantic_index::EnclosingSnapshotId; use crate::semantic_index::EnclosingSnapshotKey; use crate::semantic_index::EvalEnv; use crate::semantic_index::EvalTiming; use crate::semantic_index::NamespaceAccess; -use crate::semantic_index::NamespaceAccessKind; use crate::semantic_index::Scope; use crate::semantic_index::ScopeId; use crate::semantic_index::ScopeKind; use crate::semantic_index::SemanticCall; -use crate::semantic_index::SemanticCallKind; use crate::semantic_index::SemanticDiagnostic; use crate::semantic_index::SemanticIndex; use crate::semantic_index::SymbolFlags; @@ -85,7 +65,9 @@ use crate::semantic_index::Use; use crate::semantic_index::UseId; use crate::use_def_map::UseDefMapBuilder; -mod builder_nse; +mod effects; +mod scan; +mod walk; /// Build a [`SemanticIndex`] from a parsed R file with cross-file /// information supplied by `resolver`. See [`ImportsResolver`] for the @@ -93,17 +75,17 @@ mod builder_nse; /// /// See the module docs for the scan/walk split. The scan /// ([`scan_expression`]) runs first over each scope, then the walk -/// ([`collect_expression`]) reuses its decisions and pushes NSE scopes inline. +/// ([`walk_expression`]) reuses its decisions and pushes NSE scopes inline. /// /// [`scan_expression`]: SemanticIndexBuilder::scan_expression -/// [`collect_expression`]: SemanticIndexBuilder::collect_expression +/// [`walk_expression`]: SemanticIndexBuilder::walk_expression pub fn build_index(root: &RRoot, resolver: impl ImportsResolver) -> SemanticIndex { let range = root.syntax().text_trimmed_range(); let mut builder = SemanticIndexBuilder::new(range, resolver); builder.begin_scan(); builder.scan_expression_list(&root.expressions()); - builder.collect_expression_list(&root.expressions()); + builder.walk_expression_list(&root.expressions()); builder.finish() } @@ -113,24 +95,24 @@ pub fn build_index(root: &RRoot, resolver: impl ImportsResolver) -> SemanticInde struct SemanticIndexBuilder { resolver: R, scopes: IndexVec, - symbol_tables: IndexVec, - definitions: IndexVec>, - uses: IndexVec>, - use_def_maps: IndexVec, current_scope: ScopeId, + // Diagnostics collected during the build and logged on `finish()`. A minimal + // channel for now, no user-facing surface. + diagnostics: Vec, + scan: ScanState, + walk: WalkState, +} + +/// State owned by the scan pass: its working state plus the products the walk +/// reads back (`bound_names`, `call_resolutions`, `eager_descent.pending`). +/// The walk also writes `bound_names`, but only to install scan-produced data: +/// the lockstep push in `push_scope()` and the pending install in +/// `walk_nse_argument()`. +struct ScanState { bound_names: IndexVec, - enclosing_snapshots: FxHashMap, - // Snapshots shared across every use of a free variable in lazy contexts, - // keyed by (nested scope, nested symbol). - lazy_snapshots: FxHashMap<(ScopeId, SymbolId), (ScopeId, EnclosingSnapshotId)>, - semantic_calls: Vec, - namespace_accesses: Vec, // Per-call facts resolved by the scanner in flow order, keyed by the call's // range. See `CallResolution`. call_resolutions: FxHashMap, - // Diagnostics collected during the build and logged on `finish()`. A minimal - // channel for now, no user-facing surface. - diagnostics: Vec, // The scan's flow-precise binding state for the scope being scanned, reset // at each scope's `begin_scan()`. See [`FlowState`]. flow_state: FlowState, @@ -151,6 +133,22 @@ struct SemanticIndexBuilder { eager_descent: EagerNestedDescent, } +/// State written by the walk pass: the per-scope arenas and the flat outputs +/// carried into the final [`SemanticIndex`]. Note that the scan reads some of +/// this data mid-flight, which is why we keep both states in a single builder. +struct WalkState { + symbol_tables: IndexVec, + definitions: IndexVec>, + uses: IndexVec>, + use_def_maps: IndexVec, + enclosing_snapshots: FxHashMap, + // Snapshots shared across every use of a free variable in lazy contexts, + // keyed by (nested scope, nested symbol). + lazy_snapshots: FxHashMap<(ScopeId, SymbolId), (ScopeId, EnclosingSnapshotId)>, + semantic_calls: Vec, + namespace_accesses: Vec, +} + impl SemanticIndexBuilder { fn new(range: TextRange, resolver: R) -> Self { let mut scopes = IndexVec::new(); @@ -181,23 +179,27 @@ impl SemanticIndexBuilder { Self { scopes, - symbol_tables, - definitions, - uses, - use_def_maps, current_scope: file_scope, - bound_names, - enclosing_snapshots: FxHashMap::default(), - lazy_snapshots: FxHashMap::default(), - semantic_calls: Vec::new(), - namespace_accesses: Vec::new(), - call_resolutions: FxHashMap::default(), - flow_state: FlowState::default(), - enclosing_flow: FxHashMap::default(), - attached_flow: Vec::new(), - eager_descent: EagerNestedDescent::default(), diagnostics: Vec::new(), resolver, + scan: ScanState { + bound_names, + call_resolutions: FxHashMap::default(), + flow_state: FlowState::default(), + enclosing_flow: FxHashMap::default(), + attached_flow: Vec::new(), + eager_descent: EagerNestedDescent::default(), + }, + walk: WalkState { + symbol_tables, + definitions, + uses, + use_def_maps, + enclosing_snapshots: FxHashMap::default(), + lazy_snapshots: FxHashMap::default(), + semantic_calls: Vec::new(), + namespace_accesses: Vec::new(), + }, } } @@ -217,11 +219,11 @@ impl SemanticIndexBuilder { }); self.current_scope = id; - self.symbol_tables.push(SymbolTableBuilder::new()); - self.definitions.push(IndexVec::new()); - self.uses.push(IndexVec::new()); - self.use_def_maps.push(UseDefMapBuilder::new()); - self.bound_names.push(BoundNames::new()); + self.walk.symbol_tables.push(SymbolTableBuilder::new()); + self.walk.definitions.push(IndexVec::new()); + self.walk.uses.push(IndexVec::new()); + self.walk.use_def_maps.push(UseDefMapBuilder::new()); + self.scan.bound_names.push(BoundNames::new()); id } @@ -236,72 +238,6 @@ impl SemanticIndexBuilder { }; } - fn add_definition( - &mut self, - name: &str, - flags: SymbolFlags, - kind: DefinitionKind, - range: TextRange, - ) { - // `Nse(Current, Lazy)` scopes don't own any definitions. We add the - // definitions to the real enclosing owner scope. Note that `Current + - // Eager` never reaches here because it doesn't push a scope. - if matches!( - self.scopes[self.current_scope].kind, - ScopeKind::Nse(EvalEnv::Current, EvalTiming::Lazy) - ) { - self.add_definition_to_owner(name, flags, kind, range); - return; - } - - let symbol_id = self.symbol_tables[self.current_scope].intern(name, flags); - let def_id = self.definitions[self.current_scope].push(Definition { - symbol: symbol_id, - kind, - range, - }); - self.use_def_maps[self.current_scope].ensure_symbol(symbol_id); - self.use_def_maps[self.current_scope].record_definition(symbol_id, def_id); - } - - /// Route a definition from a `Current + Lazy` scope to the scope that - /// owns it. That's the nearest ancestor scope which holds its own - /// definitions. A chain of `Current + Lazy` scopes (e.g. `on_load()` nested - /// in `on_load()`) is skipped: each one routes to its own owner, so they - /// all land in the same outer scope. - pub(super) fn add_definition_to_owner( - &mut self, - name: &str, - flags: SymbolFlags, - kind: DefinitionKind, - range: TextRange, - ) { - let Some(target_scope) = self.definition_owner() else { - stdext::debug_panic!("Current + Lazy scope has no parent"); - return; - }; - - let symbol_id = self.symbol_tables[target_scope].intern(name, flags); - let def_id = self.definitions[target_scope].push(Definition { - symbol: symbol_id, - kind, - range, - }); - - self.use_def_maps[target_scope].ensure_symbol(symbol_id); - - // Deferred: the body executes at an unknown later time, so the - // definition shouldn't shadow what's already live. This is the same - // mechanism as `<<-`. - // - // Known imprecision: the deferred def is visible to ALL uses in - // the parent scope (with `may_be_unbound: true`), including - // file-level uses that run before the lazy body executes. Ideally - // these defs would only be reachable from lazy scopes (functions), - // not from eager/file-level code. - self.use_def_maps[target_scope].record_deferred_definition(symbol_id, def_id); - } - /// The scope that owns definitions of a `Current + Lazy` NSE scope. The /// climb is iterative to handle e.g. `on_load(on_load(...))`. Every other /// scope kind (`File`, `Function`, `Nse(Nested, _)`) owns its definitions @@ -317,213 +253,11 @@ impl SemanticIndexBuilder { Some(scope) } - // Super-assignment is lexically in the current scope but binds in an - // ancestor. We record the definition in the current scope and append - // it to the target scope's use-def map (without shadowing prior - // definitions). - // - // R's `<<-` walks up the environment chain from the parent, targeting - // the first scope where the symbol is already bound. If no binding is - // found, it assigns in the global (file) scope. - fn add_super_definition(&mut self, name: &str, kind: DefinitionKind, range: TextRange) { - let Some(parent) = self.scopes[self.current_scope].parent else { - // A top-level `<<-` has no enclosing frame to walk to, so it binds - // in the file scope it already sits in. The marker scope and the - // binding scope coincide, so record one definition carrying both - // flags rather than pushing two coinciding entries. - let symbol_id = self.symbol_tables[self.current_scope].intern( - name, - SymbolFlags::IS_SUPER_BOUND.union(SymbolFlags::IS_BOUND), - ); - let def_id = self.definitions[self.current_scope].push(Definition { - symbol: symbol_id, - kind, - range, - }); - self.use_def_maps[self.current_scope].ensure_symbol(symbol_id); - self.use_def_maps[self.current_scope].record_deferred_definition(symbol_id, def_id); - return; - }; - - let target_scope = self.resolve_super_target(name, parent); - - let symbol_id = - self.symbol_tables[self.current_scope].intern(name, SymbolFlags::IS_SUPER_BOUND); - self.definitions[self.current_scope].push(Definition { - symbol: symbol_id, - kind: kind.clone(), - range, - }); - - let target_symbol = self.symbol_tables[target_scope].intern(name, SymbolFlags::IS_BOUND); - let target_def_id = self.definitions[target_scope].push(Definition { - symbol: target_symbol, - kind, - range, - }); - self.use_def_maps[target_scope].ensure_symbol(target_symbol); - self.use_def_maps[target_scope].record_deferred_definition(target_symbol, target_def_id); - } - - // Walk up from `start` to the first scope where `name` already has - // `IS_BOUND`. Returns that scope, or the file scope if no binding is found - // (mirroring R's assignment to the global environment). Reaching the file - // scope unbound ends the walk there, so its `parent` of `None` is the - // natural terminator. - fn resolve_super_target(&self, name: &str, start: ScopeId) -> ScopeId { - let mut scope = start; - loop { - if let Some(id) = self.symbol_tables[scope].id(name) { - if self.symbol_tables[scope] - .symbol(id) - .flags() - .contains(SymbolFlags::IS_BOUND) - { - return scope; - } - } - let Some(parent) = self.scopes[scope].parent else { - return scope; - }; - scope = parent; - } - } - - fn add_use(&mut self, name: &str, range: TextRange) { - let symbol_id = self.symbol_tables[self.current_scope].intern(name, SymbolFlags::IS_USED); - let use_id = self.uses[self.current_scope].push(Use { - symbol: symbol_id, - range, - }); - self.use_def_maps[self.current_scope].ensure_symbol(symbol_id); - self.use_def_maps[self.current_scope].record_use(symbol_id, use_id); - - // Associate free variables with the enclosing snapshot where the - // variable is defined - if self.use_def_maps[self.current_scope].is_may_be_unbound(symbol_id) { - self.register_enclosing_snapshot(name, symbol_id, use_id); - } - } - - fn register_enclosing_snapshot(&mut self, name: &str, nested_symbol: SymbolId, use_id: UseId) { - // We're looking for a parent definition for this scope's free variable - // so start from parent - let Some(mut current_scope) = self.scopes[self.current_scope].parent else { - return; - }; - - // Eager vs lazy snapshot for this free variable. Eager snapshots are - // precise, lazy ones over-approximate. For instance in: - // - // ``` - // x <- 1 - // local({ x }) - // x <- 2 - // ``` - // - // The eager body in `local()` captures `x <- 1` but not `x <- 2`. If - // the body was inside a lazy context like `function()` instead, the use - // of `x` could run at any time and we'd fall back to the accumulated - // union `{1, 2}`, which is an over-approximation. - // - // A precise enclosing snapshot requires eagerness throughout, which we - // track with `all_eager`. - let mut all_eager = !self.scopes[self.current_scope].kind.is_lazy(); - - loop { - if self.scope_binds_anywhere(current_scope, name) { - // Intern with empty flags: we just need a stable `SymbolId` for - // the lookup key. If the symbol was found via its `IS_BOUND` - // flag, it already exists. If found via pre-scan only, the later - // `add_definition()` call during the full walk will set `IS_BOUND`. - let enclosing_symbol_id = - self.symbol_tables[current_scope].intern(name, SymbolFlags::empty()); - self.use_def_maps[current_scope].ensure_symbol(enclosing_symbol_id); - - let entry = if all_eager { - // Eager: a fresh point-in-time snapshot per use, no dedup and - // no watcher. Two uses at different points in the body can - // capture different enclosing states (e.g. either side of a - // `<<-`), so they can't share. - let snapshot_id = self.use_def_maps[current_scope] - .register_eager_snapshot(enclosing_symbol_id); - (current_scope, snapshot_id) - } else { - // Lazy: every use of this symbol resolves to the same - // growing snapshot, so dedup on (nested scope, nested symbol) - // and reuse it across uses. - let dedup_key = (self.current_scope, nested_symbol); - - if let Some(&entry) = self.lazy_snapshots.get(&dedup_key) { - entry - } else { - let snapshot_id = self.use_def_maps[current_scope] - .register_lazy_snapshot(enclosing_symbol_id); - let entry = (current_scope, snapshot_id); - self.lazy_snapshots.insert(dedup_key, entry); - entry - } - }; - - let use_key = EnclosingSnapshotKey { - nested_scope: self.current_scope, - nested_use: use_id, - }; - self.enclosing_snapshots.insert(use_key, entry); - - return; - } - - if self.scopes[current_scope].kind.is_lazy() { - all_eager = false; - } - - let Some(parent) = self.scopes[current_scope].parent else { - return; - }; - current_scope = parent; - } - } - /// Whether `scope` binds `name` anywhere, regardless of flow position: an /// already-recorded `IS_BOUND` definition or a pre-scanned assignment. The /// pre-scan covers definitions the walk hasn't reached yet in this scope. fn scope_binds_anywhere(&self, scope: ScopeId, name: &str) -> bool { - 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)) + self.walked_binding(scope, name).is_some() || self.scan.bound_names[scope].binds(name) } /// The site where `scope` binds `name`, matching what @@ -534,14 +268,14 @@ impl SemanticIndexBuilder { /// seeds straight into `flow_state` without a `bound_names` entry). Used to /// point the lazy-shadow diagnostic at the overwrite. fn scope_binding_range(&self, scope: ScopeId, name: &str) -> Option { - if let Some(range) = self.bound_names[scope].binding_range(name) { + if let Some(range) = self.scan.bound_names[scope].binding_range(name) { return Some(range); } // `IS_BOUND` always has a matching `Definition` row (see the invariant // in `resolve_symbol()`), so the find never misses when the flag is set. let sym_id = self.walked_binding(scope, name)?; - self.definitions[scope] + self.walk.definitions[scope] .iter() .find(|(_id, def)| def.symbol == sym_id) .map(|(_id, def)| def.range) @@ -550,911 +284,14 @@ impl SemanticIndexBuilder { /// The symbol `name` interns to in `scope`, if the walk has already recorded /// an `IS_BOUND` definition for it. fn walked_binding(&self, scope: ScopeId, name: &str) -> Option { - let sym_id = self.symbol_tables[scope].id(name)?; - self.symbol_tables[scope] + let sym_id = self.walk.symbol_tables[scope].id(name)?; + self.walk.symbol_tables[scope] .symbol(sym_id) .flags() .contains(SymbolFlags::IS_BOUND) .then_some(sym_id) } - /// Record the names a child scope (function body, NSE argument) about to be - /// created at `range` inherits from its ancestors, to seed the child's scan - /// in `begin_scan`. Called during the scan, where `flow_state` is the - /// parent's flow-precise state at the child's definition point (already - /// carrying the parent's own inherited ancestors, so the child inherits - /// transitively). - pub(super) fn record_enclosing_flow(&mut self, range: TextRange) { - self.enclosing_flow - .insert(range, self.flow_state.snapshot()); - } - - // --- Scan pass --- - - /// Reset the flow-precise binding state for a fresh scope's scan. - /// - /// Seeds it with two things: - /// - /// - The names inherited from enclosing scopes, captured when this scope was - /// entered (`enclosing_flow`). The parent's own scan was seeded the same - /// way, so this is transitively complete: it holds every eager binding - /// visible from an ancestor at this scope's definition point. - /// - The scope's own already-bound names. For a function scope that's the - /// parameters, recorded just before the scan runs. For file and NSE scopes - /// nothing local is bound yet. - /// - /// Parameter defaults are a special case: they are scanned before the params - /// are recorded, so `collect_function` seeds the full formal set by hand - /// (all formals bind at once in R, so a default sees every parameter name). - pub(super) fn begin_scan(&mut self) { - let range = self.scopes[self.current_scope].range; - - match self.enclosing_flow.get(&range).cloned() { - Some(entry) => self.flow_state.restore(entry), - None => self.flow_state.clear(), - } - - for (_id, symbol) in self.symbol_tables[self.current_scope].iter() { - if symbol.flags().contains(SymbolFlags::IS_BOUND) { - self.flow_state.bind(symbol.name().to_string()); - } - } - } - - pub(super) fn scan_expression_list(&mut self, list: &RExpressionList) { - for expr in list.iter() { - self.scan_expression(&expr); - } - } - - /// Scan for NSE calls and collect the scope's bound names, in flow order. - /// - /// Runs before the walk of a scope. It decides NSE-ness at each call the - /// same way the walk's [`is_locally_bound`](Self::is_locally_bound) would, - /// records the decision in `call_resolutions` for the walk to reuse, and adds - /// non-skipped definition names to `bound_names`. The bound names must be - /// complete before the walk descends into any child scope, because a lazy - /// child body can reference an ancestor def the ancestor's walk hasn't - /// reached yet. - /// - /// A scan unit is the file or a lazy body (function, `Nested + Lazy`, - /// `Current + Lazy`). Each unit is scanned once. Within a unit the scan - /// descends through every eager boundary it meets, in flow order: - /// - /// - A `Current + Eager` body pushes no scope, so it stays part of this - /// scope's direct level and is scanned through transparently. - /// - A `Nested + Eager` body is descended into with a save/restore of - /// `flow_state`, and the names it binds are left pending for the walk to - /// install without re-scanning. - /// - Function and lazy bodies (`Nested + Lazy`, `Current + Lazy`) are their - /// own scan units, scanned separately when the walk enters them, because - /// NSE resolution there needs the child's own flow context. - /// - /// Branch analysis is precise. In `if (c) local <- f else local({ y <- 1 - /// })` the else branch sees an NSE call because `local` is unbound on the - /// else path, which prevents `y` from leaking into the scope. - pub(super) fn scan_expression(&mut self, expr: &AnyRExpression) { - match expr { - AnyRExpression::RFunctionDefinition(func) => { - // A function body is a child scope, scanned when it's entered. - // Record the names it inherits now so that when we later resolve - // an NSE callee inside the body, we can check whether one of them - // shadows it (see `enclosing_flow`). - self.record_enclosing_flow(func.syntax().text_trimmed_range()); - }, - - AnyRExpression::RBracedExpressions(braced) => { - self.scan_expression_list(&braced.expressions()); - }, - - AnyRExpression::RBinaryExpression(bin) => { - if is_assignment(bin) { - let right = is_right_assignment(bin); - - // Value side first, mirroring `collect_assignment`: it may - // hold NSE calls or nested defs that flow before the binding. - let value = if right { bin.left() } else { bin.right() }; - if let Ok(value) = value { - self.scan_expression(&value); - } - - let target = if right { bin.right() } else { bin.left() }; - if let Ok(target) = target { - match assignment_name(&target) { - // `<<-` binds in an ancestor, not here, so it doesn't - // shadow a callee in this scope (matching the walk). - Some((name, range)) if !is_super_assignment(bin) => { - self.record_binding(name, range); - }, - Some(_) => {}, - // Complex target (`x$foo <- v`): no binding, but the - // target may hold NSE calls. - None => self.scan_expression(&target), - } - } - } else { - // A binding operator (`x %<>% f()`) binds its left operand. - // Scan the operands as uses first, then record the binding, - // so a later callee in this scope sees that name shadowed. - // Mirrors the value-then-target order of the `is_assignment` - // branch. - if let Ok(lhs) = bin.left() { - self.scan_expression(&lhs); - } - if let Ok(rhs) = bin.right() { - self.scan_expression(&rhs); - } - self.scan_operator_assign(bin); - } - }, - - AnyRExpression::RCall(call) => { - if let Ok(func) = call.function() { - self.scan_expression(&func); - } - self.scan_call(call); - }, - - AnyRExpression::RForStatement(stmt) => { - // The for-variable is always bound (R sets it to NULL for empty - // sequences), so it binds before the body regardless of flow. - if let Ok(variable) = stmt.variable() { - self.record_binding( - variable.name_text(), - variable.syntax().text_trimmed_range(), - ); - } - if let Ok(sequence) = stmt.sequence() { - self.scan_expression(&sequence); - } - // A loop body only adds bindings (a name bound inside still - // "reaches" on the ran path), so no restore is needed, unlike - // the two-branch `if`/`else` below. - if let Ok(body) = stmt.body() { - self.scan_expression(&body); - } - }, - - AnyRExpression::RIfStatement(stmt) => { - if let Ok(condition) = stmt.condition() { - self.scan_expression(&condition); - } - - let pre_if = self.flow_state.snapshot(); - - if let Ok(consequence) = stmt.consequence() { - self.scan_expression(&consequence); - } - - let post_if = self.flow_state.snapshot(); - self.flow_state.restore(pre_if); - - if let Some(else_clause) = stmt.else_clause() { - if let Ok(alternative) = else_clause.alternative() { - self.scan_expression(&alternative); - } - } - - // Both branches' bindings are live afterwards. - self.flow_state.merge(post_if); - }, - - // `while`/`repeat` loops, subsets, extractions, parentheses, unary - // ops, and literals: recurse into child expressions. Loops need no - // flow restore (see the `for` arm). Identifiers and dots are leaves - // with no bindings or calls, so they fall through to a no-op walk. - _ => { - self.scan_descendants(expr.syntax()); - }, - } - } - - /// Walk descendant nodes of `expr`, scanning the outermost - /// `AnyRExpression` children. The scan analog of - /// `collect_descendants`. - fn scan_descendants(&mut self, node: &RSyntaxNode) { - let mut preorder = node.preorder(); - preorder.next(); - - while let Some(event) = preorder.next() { - let WalkEvent::Enter(node) = event else { - continue; - }; - if let Some(expr) = node.cast::() { - self.scan_expression(&expr); - preorder.skip_subtree(); - } - } - } - - fn scan_parameter_defaults(&mut self, params: &RParameters) { - // Seed `flow_state` with every parameter names so a callee inside a - // default value sees the full formal set - for param in params.items().iter() { - let Ok(param) = param else { continue }; - let Ok(name) = param.name() else { continue }; - let text = match &name { - AnyRParameterName::RIdentifier(ident) => ident.name_text(), - AnyRParameterName::RDots(_) => String::from("..."), - AnyRParameterName::RDotDotI(ddi) => ddi.syntax().text_trimmed().to_string(), - }; - self.flow_state.bind(text); - } - - for param in params.items().iter() { - let Ok(param) = param else { continue }; - let Some(default) = param.default() else { - continue; - }; - if let Ok(value) = default.value() { - self.scan_expression(&value); - } - } - } - - /// Resolve one sourced `path`, bind the names it brings in, and return its - /// resolution for the caller to cache. - /// - /// The binding is eager: `source()` runs at its position, so the sourced - /// names are bound afterwards and can shadow a later NSE callee (e.g. a - /// sourced `local` masking base `local`). Returns `None` when the resolver - /// can't locate the target. - /// - /// [`scan_call`]: Self::scan_call - fn scan_source_call( - &mut self, - path: &str, - source_range: TextRange, - ) -> Option { - let resolution = self.resolver.resolve_source(path)?; - - // Sourced names originate in another file, so they have no binding site - // here. Anchor the overwrite range at the `source()` call instead. - for name in &resolution.names { - self.record_binding(name.clone(), source_range); - } - - // A `source()`-forwarded `library()` attaches at this call's flow - // position, the same as an attach written here directly. Only in eager - // context, matching `scan_attach_call`'s `!is_lazy()` gate. - if !self.scopes[self.current_scope].kind.is_lazy() { - for pkg in &resolution.packages { - self.attached_flow.push(pkg.clone()); - } - } - - Some(resolution) - } - - /// Record a binding in the scan's flow state. - /// - /// The flow-precise `flow_state` always learns the name, so a - /// later callee in this scope sees it shadowed. The bound names only get it - /// when the current scope owns it. A `Current + Lazy` scope routes its defs - /// to the owner, so the name is added to the owner's bound names instead, the - /// same routing `add_definition_to_owner` does during the walk. - fn record_binding(&mut self, name: String, range: TextRange) { - self.record_owner_name(name.clone(), range); - self.flow_state.bind(name); - } - - /// Route a binding NAME into its owner scope's bound names, matching - /// `add_definition`'s routing. When a descent is open the name goes to the - /// descent top, which is always an eager `Nested` body scanned inline and so - /// owns its bindings. Otherwise a `Current + Lazy` scope routes to - /// `definition_owner()` and every other scope owns its bindings. - /// - /// Split from `record_binding` so `scan_lazy_owner_bindings` can add - /// a deferred body's names to the owner's bound names without also marking them - /// bound in `flow_state` (see that helper for why). - fn record_owner_name(&mut self, name: String, range: TextRange) { - if let Some(bound) = self.eager_descent.open.last_mut() { - bound.add(name, range); - return; - } - - if let Some(target) = match self.scopes[self.current_scope].kind { - ScopeKind::Nse(EvalEnv::Current, EvalTiming::Lazy) => self.definition_owner(), - _ => Some(self.current_scope), - } { - self.bound_names[target].add(name, range); - } - } - - fn nse_effect(&self, call: &RCall) -> Option { - self.call_resolutions - .get(&call.syntax().text_trimmed_range()) - .and_then(|resolution| resolution.arguments.clone()) - } - - // --- Recursive descent --- - - fn collect_expression_list(&mut self, list: &RExpressionList) { - for expr in list.iter() { - self.collect_expression(&expr); - } - } - - fn collect_expression(&mut self, expr: &AnyRExpression) { - match expr { - AnyRExpression::RIdentifier(ident) => { - let name = ident.name_text(); - let range = ident.syntax().text_trimmed_range(); - self.add_use(&name, range); - }, - - AnyRExpression::RDots(dots) => { - self.add_use("...", dots.syntax().text_trimmed_range()); - }, - - AnyRExpression::RDotDotI(ddi) => { - let name = ddi.syntax().text_trimmed().to_string(); - self.add_use(&name, ddi.syntax().text_trimmed_range()); - }, - - AnyRExpression::RFunctionDefinition(func) => { - self.collect_function(func); - }, - - AnyRExpression::RBracedExpressions(braced) => { - self.collect_expression_list(&braced.expressions()); - }, - - AnyRExpression::RBinaryExpression(bin) => { - // `<-`, `=`, `->`, `<<-`, and `->>` are assignments when they appear as - // `RBinaryExpression`. In call arguments, `=` is consumed by - // the parser into `RArgumentNameClause` instead, so it never - // reaches here. - if is_assignment(bin) { - self.collect_assignment(bin); - } else { - let range = bin.syntax().text_trimmed_range(); - - let reads_lhs = match self.call_resolutions.get(&range) { - Some(resolution) if !resolution.assign.is_empty() => { - // Pure binding operators such as `x := expr` do not - // read their LHS. `%<>%` is compound and acts like - // `x <- x %>% f()`. - resolution - .assign - .iter() - .any(|binding| binding.target == TargetAccess::ReadWrite) - }, - _ => true, - }; - - if reads_lhs { - if let Ok(lhs) = bin.left() { - self.collect_expression(&lhs); - } - } - if let Ok(rhs) = bin.right() { - self.collect_expression(&rhs); - } - // A `%...%` operator the scan recognized as an assign effect - // emits its binding here, after the operand uses. - self.collect_assign_operator(bin); - } - }, - - // Calls and subsets need explicit handling because argument name - // clauses contain `RIdentifier` nodes that should not be recorded - // as uses. - AnyRExpression::RCall(call) => { - // Record the callee as a use (a no-op for `pkg::fn`) before - // handling NSE. - if let Ok(func) = call.function() { - self.collect_expression(&func); - } - - if let Some(scoping) = self.nse_effect(call) { - self.collect_nse_call(call, scoping) - } else if let Ok(args) = call.arguments() { - self.collect_arguments(&args.items()); - } - - self.collect_semantic_call(call); - }, - AnyRExpression::RSubset(subset) => { - if let Ok(object) = subset.function() { - self.collect_expression(&object); - } - if let Ok(args) = subset.arguments() { - self.collect_arguments(&args.items()); - } - }, - AnyRExpression::RSubset2(subset) => { - if let Ok(object) = subset.function() { - self.collect_expression(&object); - } - if let Ok(args) = subset.arguments() { - self.collect_arguments(&args.items()); - } - }, - - AnyRExpression::RExtractExpression(extract) => { - // For `x$name` or `x@slot`, collect the object and skip the member - if let Ok(object) = extract.left() { - self.collect_expression(&object); - } - }, - - AnyRExpression::RNamespaceExpression(expr) => { - self.collect_namespace_access(expr); - }, - - AnyRExpression::RForStatement(stmt) => { - // The for variable is always bound (R sets it to NULL for - // empty sequences), so its binding is recorded before the - // snapshot. Assignments inside the body are conditional - // (body may not execute for empty sequences). - if let Ok(variable) = stmt.variable() { - self.add_definition( - &variable.name_text(), - SymbolFlags::IS_BOUND, - DefinitionKind::ForVariable(AstPtr::new(stmt)), - variable.syntax().text_trimmed_range(), - ); - } - if let Ok(sequence) = stmt.sequence() { - self.collect_expression(&sequence); - } - - let pre_loop = self.use_def_maps[self.current_scope].snapshot(); - - if let Ok(body) = stmt.body() { - let first_use = self.uses[self.current_scope].next_id(); - self.collect_expression(&body); - self.use_def_maps[self.current_scope].finish_loop_defs( - &pre_loop, - first_use, - &self.uses[self.current_scope], - ); - } - - self.use_def_maps[self.current_scope].merge(pre_loop); - }, - - AnyRExpression::RIfStatement(stmt) => { - // Condition is always evaluated - if let Ok(condition) = stmt.condition() { - self.collect_expression(&condition); - } - - let pre_if = self.use_def_maps[self.current_scope].snapshot(); - - // If-body (consequence) - if let Ok(consequence) = stmt.consequence() { - self.collect_expression(&consequence); - } - - let post_if = self.use_def_maps[self.current_scope].snapshot(); - self.use_def_maps[self.current_scope].restore(pre_if); - - // Else-body (alternative), if present. If absent, the - // "else path" is just the pre-if state we restored to. - if let Some(else_clause) = stmt.else_clause() { - if let Ok(alternative) = else_clause.alternative() { - self.collect_expression(&alternative); - } - } - - // After: definitions from both branches are live - self.use_def_maps[self.current_scope].merge(post_if); - }, - - AnyRExpression::RWhileStatement(stmt) => { - if let Ok(condition) = stmt.condition() { - self.collect_expression(&condition); - } - - let pre_loop = self.use_def_maps[self.current_scope].snapshot(); - - if let Ok(body) = stmt.body() { - let first_use = self.uses[self.current_scope].next_id(); - self.collect_expression(&body); - self.use_def_maps[self.current_scope].finish_loop_defs( - &pre_loop, - first_use, - &self.uses[self.current_scope], - ); - } - - // Body may not execute - self.use_def_maps[self.current_scope].merge(pre_loop); - }, - - AnyRExpression::RRepeatStatement(stmt) => { - // Body always executes at least once, so no merge with pre-loop state. - if let Ok(body) = stmt.body() { - let pre_loop = self.use_def_maps[self.current_scope].snapshot(); - let first_use = self.uses[self.current_scope].next_id(); - self.collect_expression(&body); - self.use_def_maps[self.current_scope].finish_loop_defs( - &pre_loop, - first_use, - &self.uses[self.current_scope], - ); - } - }, - - AnyRExpression::RBogusExpression(_) => {}, - - // Generic fallback: walk over descendant nodes and collect their - // `AnyRExpression` children, letting `collect_expression` - // handle their contents. This covers `RUnaryExpression`, - // `RParenthesizedExpression`, `RReturnExpression`, literals, and - // any future expression types without needing explicit arms. - // - // NOTE: This also means that identifiers and assignments inside - // quoting constructs (`~`, `quote()`, `bquote()`) are recorded as - // uses and bindings. Refining this requires special-casing these - // forms, which we defer as future work. - // - // Once quoting is handled, `declare()` and `~declare()` will need - // explicit treatment: its arguments are quoted (not evaluated) but - // should still be inspected for directives like `source()`. - // Currently this works by accident because the generic traversal is - // transparent to both `declare()` and `~`. - _ => { - self.collect_descendants(expr.syntax()); - }, - } - } - - // Walk descendant nodes of `expr`, collecting the outermost - // `AnyRExpression` nodes and recursing into them via `collect_expression`. - // This skips intermediate wrapper nodes (e.g. `RElseClause`) while - // correctly stopping at expression boundaries. - fn collect_descendants(&mut self, node: &RSyntaxNode) { - let mut preorder = node.preorder(); - - // Skip the root node itself - preorder.next(); - - while let Some(event) = preorder.next() { - let WalkEvent::Enter(node) = event else { - continue; - }; - if let Some(expr) = node.cast::() { - self.collect_expression(&expr); - preorder.skip_subtree(); - } - } - } - - fn collect_function(&mut self, fun: &RFunctionDefinition) { - let scope = self.push_scope(ScopeKind::Function, fun.syntax().text_trimmed_range()); - - if let Ok(params) = fun.parameters() { - // Scan the default values before collecting them. R binds all - // formals into the frame at once, so a default sees every parameter - // name regardless of position: `function(local, b = local(...))` is - // not NSE. So we seed the whole formal set into `flow_state` - // up front rather than flow-ordered, then scan each default. - self.begin_scan(); - self.scan_parameter_defaults(¶ms); - - // `collect_parameters` adds the parameter definitions and walks - // each default in source order, finding the NSE decisions the scan - // above recorded. - self.collect_parameters(¶ms); - } - if let Ok(body) = fun.body() { - self.begin_scan(); - self.scan_expression(&body); - self.collect_expression(&body); - } - - self.pop_scope(scope); - } - - fn collect_parameters(&mut self, params: &RParameters) { - for param in params.items().iter() { - let Ok(param) = param else { continue }; - self.collect_parameter(¶m); - } - } - - fn collect_parameter(&mut self, param: &RParameter) { - let flags = SymbolFlags::IS_BOUND.union(SymbolFlags::IS_PARAMETER); - - if let Ok(name) = param.name() { - match &name { - AnyRParameterName::RIdentifier(ident) => { - self.add_definition( - &ident.name_text(), - flags, - DefinitionKind::Parameter(AstPtr::new(param)), - ident.syntax().text_trimmed_range(), - ); - }, - AnyRParameterName::RDots(dots) => { - self.add_definition( - "...", - flags, - DefinitionKind::Parameter(AstPtr::new(param)), - dots.syntax().text_trimmed_range(), - ); - }, - AnyRParameterName::RDotDotI(ddi) => { - self.add_definition( - &ddi.syntax().text_trimmed().to_string(), - flags, - DefinitionKind::Parameter(AstPtr::new(param)), - ddi.syntax().text_trimmed_range(), - ); - }, - } - } - - if let Some(default) = param.default() { - if let Ok(value) = default.value() { - self.collect_expression(&value); - } - } - } - - fn collect_assignment(&mut self, op: &RBinaryExpression) { - let right = is_right_assignment(op); - let super_assign = is_super_assignment(op); - - // Value side first to record uses before the binding. The uses - // might refer to the same symbol as the new binding, but refer - // to a different place (previous binding). - let value = if right { op.left() } else { op.right() }; - if let Ok(value) = value { - self.collect_expression(&value); - } - - let target = if right { op.right() } else { op.left() }; - let Ok(target) = target else { return }; - - let Some((name, range)) = assignment_name(&target) else { - // Complex target (`x$foo <- rhs`, `x[1] <- rhs`, etc.) does - // not represent a binding. We recurse for uses. - self.collect_expression(&target); - return; - }; - - if super_assign { - self.add_super_definition( - &name, - DefinitionKind::SuperAssignment(AstPtr::new(op)), - range, - ); - } else { - self.add_definition( - &name, - SymbolFlags::IS_BOUND, - DefinitionKind::Assignment(AstPtr::new(op)), - range, - ); - } - } - - fn collect_arguments(&mut self, args: &RArgumentList) { - for item in args.iter() { - let Ok(arg) = item else { continue }; - if let Some(value) = arg.value() { - self.collect_expression(&value); - } - } - } - - fn collect_namespace_access(&mut self, expr: &RNamespaceExpression) { - let Ok(operator) = expr.operator() else { - return; - }; - let kind = match operator.kind() { - RSyntaxKind::COLON2 => NamespaceAccessKind::Export, - RSyntaxKind::COLON3 => NamespaceAccessKind::Internal, - _ => return, - }; - let Some(package) = expr - .left() - .ok() - .and_then(|selector| selector.identifier_text()) - else { - return; - }; - let Some(symbol) = expr - .right() - .ok() - .and_then(|selector| selector.identifier_text()) - else { - return; - }; - let offset = expr.syntax().text_trimmed_range().start(); - self.namespace_accesses - .push(NamespaceAccess::new(package, symbol, kind, offset)); - } - - fn collect_semantic_call(&mut self, call: &aether_syntax::RCall) { - // Attach: the scan recognized it (shadow- and mask-aware) and recorded - // the package by range. We emit the `SemanticCall::Attach` here so it - // carries the walk-time scope, e.g. the pushed NSE scope for a - // `library()` inside `local({...})`. - let range = call.syntax().text_trimmed_range(); - if let Some(package) = self - .call_resolutions - .get(&range) - .and_then(|resolution| resolution.attach.clone()) - { - self.record_attach(call, package); - } - - // Source: the scan recognized it (shadow- and mask-aware) on the resolve - // path and cached the sourced files by range. Their presence is the - // recognition marker, so we dispatch on it rather than the callee name. - if self - .call_resolutions - .get(&range) - .is_some_and(|resolution| !resolution.source.is_empty()) - { - self.collect_source_call(call); - } - - // Assign: same recognition path. The scan cached the bound names and we - // emit the corresponding definitions so they feed the use-def map, - // `exports()`, and goto. - if self - .call_resolutions - .get(&range) - .is_some_and(|resolution| !resolution.assign.is_empty()) - { - self.collect_assign_call(call); - } - } - - // ## `library()` / `require()` scoping - // - // In R, `library()` always modifies the global search path regardless - // of where it's called. Statically, we scope the call to - // `self.current_scope`: at file scope it's visible everywhere (sequential - // execution is guaranteed), but inside a function it's only visible - // within that function and its children, since the function might never - // be called. Same reasoning as `source()` calls. - fn record_attach(&mut self, call: &RCall, package: String) { - let call_offset = call.syntax().text_trimmed_range().start(); - self.semantic_calls.push(SemanticCall { - kind: SemanticCallKind::Attach { package }, - offset: call_offset, - scope: self.current_scope, - }); - } - - // ## `source()` resolution - // - // `source("file.R")` creates `DefinitionKind::Import` forwarding - // bindings in the current scope for each top-level name exported by - // the target file. These participate in the use-def map like normal - // definitions (shadowing, ordering), but goto-definition chases - // through them via `resolve_definition` to reach the actual origin. - // - // The `local` argument is inspected only to bail: if it's set to - // something other than TRUE/FALSE (e.g., an environment), the call - // isn't statically analyzable and we skip it. - // - // TODO: In nested scopes, `local = FALSE` technically targets the - // global environment. We currently inject into the calling scope - // regardless to keep the sourcing mechanism simple. A future diagnostic - // should suggest `local = TRUE` in nested contexts. - fn collect_source_call(&mut self, call: &aether_syntax::RCall) { - let range = call.syntax().text_trimmed_range(); - let call_offset = range.start(); - - // Read back what the scan cached: the sourced files, each with its - // resolution. The scan is the single point that extracts the paths and - // consults `resolve_source`, so the walk never re-parses or re-resolves. - let sourced = match self.call_resolutions.get(&range) { - Some(resolution) => resolution.source.clone(), - None => return, - }; - - for SourcedFile { path, resolution } in sourced { - // Record every sourced file, independent of whether it resolved. - // `resolved` pins the canonical URL when resolution succeeded so - // reflective queries (diagnostics for unresolved `source()`, - // file-dependency views) read the outcome without re-resolving. - let resolved = resolution.as_ref().map(|r| r.url.clone()); - self.semantic_calls.push(SemanticCall { - kind: SemanticCallKind::Source { path, resolved }, - offset: call_offset, - scope: self.current_scope, - }); - - let Some(resolution) = resolution else { - continue; - }; - - let file = resolution.url; - - for name in resolution.names { - // Empty range: R's `source()` imports names implicitly (unlike - // Python's `from x import y` where `y` appears in the text). - // There's no text span to assign to these definitions. - let name_range = TextRange::empty(call_offset); - - self.add_definition( - &name, - SymbolFlags::IS_BOUND, - DefinitionKind::Import { - call: AstPtr::new(call), - file: file.clone(), - name: name.clone(), - }, - name_range, - ); - } - - // `library()` calls inside the sourced file attach packages to R's - // global search path at runtime, the same as a `library()` written - // here directly would. Emit them as `Attach` semantic calls scoped - // to this `source()`'s offset so scope-layer composition treats - // them identically to local `library()` calls. - for pkg in resolution.packages { - self.semantic_calls.push(SemanticCall { - kind: SemanticCallKind::Attach { package: pkg }, - offset: call_offset, - scope: self.current_scope, - }); - } - } - } - - // ## `assign()` binding - // - // `assign("x", value)` binds `x` in the current scope, the same as `x <- - // value` would. We record a `DefinitionKind::Assign` def so it feeds the - // use-def map, `exports()`, and goto exactly like a syntactic assignment. - // The name is not chased to its value, so an `assign("f", local)` def - // carries no NSE, just like `f <- local`. - fn collect_assign_call(&mut self, call: &aether_syntax::RCall) { - let range = call.syntax().text_trimmed_range(); - - // Read back the bindings the scan extracted (their presence is what the - // caller checked before dispatching here). - let bindings = match self.call_resolutions.get(&range) { - Some(resolution) => resolution.assign.clone(), - None => return, - }; - - self.add_assign_definitions(&AnyRExpression::RCall(call.clone()), bindings); - } - - fn add_assign_definitions(&mut self, node: &AnyRExpression, bindings: Vec) { - for binding in bindings { - // The def's own range is the name token, captured at scan time, so a - // cursor on the name at the definition site hit-tests to it, the same - // as a syntactic `<-` binding. - let name_range = binding.name_expr.text_trimmed_range(); - let name = binding.name_expr.as_ptr().clone(); - self.add_definition( - &binding.name, - SymbolFlags::IS_BOUND, - DefinitionKind::Assign { - node: AstPtr::new(node), - name, - value: binding.value_expr, - }, - name_range, - ); - } - } - - /// Emit the `Assign` definition for a binding operator (e.g. `x %<>% f()`) the - /// scan recognized, after its operands were collected as uses. - fn collect_assign_operator(&mut self, bin: &RBinaryExpression) { - let range = bin.syntax().text_trimmed_range(); - let bindings = match self.call_resolutions.get(&range) { - Some(resolution) if !resolution.assign.is_empty() => resolution.assign.clone(), - _ => return, - }; - - self.add_assign_definitions(&AnyRExpression::RBinaryExpression(bin.clone()), bindings); - } - fn finish(mut self) -> SemanticIndex { self.scopes[ScopeId::from(0)].descendants.end = self.scopes.next_id(); @@ -1474,6 +311,7 @@ impl SemanticIndexBuilder { } let symbol_tables = self + .walk .symbol_tables .into_iter() .map(|b| Arc::new(b.build())) @@ -1481,200 +319,33 @@ impl SemanticIndexBuilder { // The file scope's exit flow state is the file's exports. Capture it // before the builders are consumed below. - let file_final_bindings = self.use_def_maps[ScopeId::from(0)].final_bindings().clone(); + let file_final_bindings = self.walk.use_def_maps[ScopeId::from(0)] + .final_bindings() + .clone(); let use_def_maps: IndexVec = self + .walk .use_def_maps .into_iter() - .zip(self.uses.iter()) + .zip(self.walk.uses.iter()) .map(|(b, (_, uses))| Arc::new(b.finish(uses))) .collect(); SemanticIndex::new( self.scopes, symbol_tables, - self.definitions, - self.uses, + self.walk.definitions, + self.walk.uses, use_def_maps, - self.enclosing_snapshots, - self.semantic_calls, - self.namespace_accesses, + self.walk.enclosing_snapshots, + self.walk.semantic_calls, + self.walk.namespace_accesses, self.diagnostics, file_final_bindings, ) } } -/// What the scan resolved a single call to, for the walk to reuse. A call can -/// carry several of these at once. -/// -/// - `arguments`: the per-argument evaluation effects the call resolved to, -/// filled in flow order. `None` means no annotated arguments (not NSE today). -/// - `attach`: the package a `library()`/`require()` call attaches, recognized -/// shadow-aware on the resolve path. The walk reads it back to emit a scoped -/// `SemanticCall::Attach`. -/// - `source`: the files a recognized `source()` call brings in, each with its -/// resolution. -/// - `assign`: the bindings `assign()`-like calls create in the current scope. -#[derive(Default)] -struct CallResolution { - arguments: Option, - attach: Option, - source: Vec, - assign: Vec, -} - -/// A single file a `source()` call brings in: its statically-extracted path and -/// the resolution the scan computed for it (`None` when it didn't resolve). -#[derive(Clone)] -struct SourcedFile { - path: String, - 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. -/// -/// It's the scan's own flow state, a coarse variant of the walk's use-def map, -/// which isn't built yet. It answers one question, "is this name bound here?", -/// so the scan can tell whether a callee is shadowed at each call and decide -/// whether a call is NSE. It tracks only eager bindings, and it is allowed to -/// stay coarse: `merge()` unions the two sides of an `if`, so that a single -/// branch marks a name as bound. -#[derive(Clone, Default)] -struct FlowState { - bound: FxHashSet, -} - -impl FlowState { - /// Save the current state, to rewind to or to seed a child scan unit from. - fn snapshot(&self) -> FlowState { - self.clone() - } - - /// Rewind to `snapshot`, dropping any bindings recorded since it was taken. - fn restore(&mut self, snapshot: FlowState) { - *self = snapshot; - } - - /// Union `snapshot` in, so a name reads as bound here if it was bound on - /// either path. This is the `if`/`else` join. - fn merge(&mut self, snapshot: FlowState) { - self.bound.extend(snapshot.bound); - } - - /// Record `name` as bound from here on. - fn bind(&mut self, name: String) { - self.bound.insert(name); - } - - /// Whether `name` is bound at the current point. - fn is_bound(&self, name: &str) -> bool { - self.bound.contains(name) - } - - /// Drop all bindings, to start a fresh scan unit (see `begin_scan()`). - fn clear(&mut self) { - self.bound.clear(); - } -} - -/// Tracks eager `Nested` NSE bodies scanned inline during the scan. -/// -/// An eager `Nested` body like `local()` runs immediately at its call site, so -/// we scan it inline instead of deferring it to the walk. `open` is the stack -/// of bodies being scanned right now, with the innermost on top. -/// `record_owner_name()` routes a binding to the top so names land on the body -/// that owns them. When a descent finishes, its names move to `pending`, keyed -/// by the body's range. -/// -/// `pending` is keyed by range rather than written straight into -/// `bound_names[scope]` because the body's arena scope doesn't exist yet: the -/// walk allocates scopes in preorder, and allocating one mid-scan would break -/// the `Scope::descendants` invariant. The range is the body's pre-arena -/// identity until the walk pushes its scope. -/// -/// Once the walk pushes that scope, it installs the pending names into it -/// instead of re-scanning. It does this before collecting the body, because a -/// lazy child inside (a function or lazy NSE body) runs later than the walk -/// reaches it, so it can reference a binding defined further down this scope. -/// Resolving that name checks whether an enclosing scope binds it -/// (`scope_binds_anywhere()`), and the walk hasn't recorded that binding yet, so -/// the scan-populated bound set has to be complete up front. That's the reason -/// the scan collects bound names ahead of the walk at all. -#[derive(Default)] -struct EagerNestedDescent { - open: Vec, - pending: FxHashMap, -} - -/// All definitions in a scope, collected by the scan pass before the -/// walk. Skips child-scope bodies (nested functions and `Nested` NSE bodies). -/// -/// Keeps each name's earliest binding site in scan order, which is source -/// order within the scope. A name bound several times reads as bound -/// throughout, and this earliest site is what the lazy-shadow diagnostic points -/// at, once `is_lazily_shadowed` has picked the nearest binding ancestor. -struct BoundNames { - by_name: FxHashMap, -} - -impl BoundNames { - fn new() -> Self { - Self { - by_name: FxHashMap::default(), - } - } - - fn add(&mut self, name: String, range: TextRange) { - self.by_name.entry(name).or_insert(range); - } - - fn binds(&self, name: &str) -> bool { - self.by_name.contains_key(name) - } - - fn binding_range(&self, name: &str) -> Option { - self.by_name.get(name).copied() - } -} - -/// 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 deleted file mode 100644 index b2ef12dcf..000000000 --- a/crates/oak_semantic/src/builder/builder_nse.rs +++ /dev/null @@ -1,587 +0,0 @@ -use aether_syntax::AnyRExpression; -use aether_syntax::RBinaryExpression; -use aether_syntax::RCall; -use aether_syntax::RSyntaxKind; -use biome_rowan::AstNode; -use biome_rowan::AstNodeList; -use biome_rowan::AstSeparatedList; -use biome_rowan::TextRange; -use oak_core::syntax_ext::AnyRSelectorExt; -use oak_core::syntax_ext::RIdentifierExt; - -use super::assignment_name; -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; -use crate::effects::AssignBinding; -use crate::effects::CallContext; -use crate::effects::EffectSite; -use crate::effects::Effects; -use crate::effects::EffectsHandlers; -use crate::effects::ResolvedArgumentEffect; -use crate::effects::ResolvedArgumentEffects; -use crate::resolver::ImportsResolver; -use crate::semantic_index::EvalEnv; -use crate::semantic_index::EvalTiming; -use crate::semantic_index::ScopeKind; -use crate::semantic_index::SemanticDiagnostic; - -impl SemanticIndexBuilder { - /// Scan a call for effects (NSE scopes, attaches, sources, assigns) and - /// record its decisions for the walk to reuse. The callee is resolved once - /// through [`resolve_effects`]. - /// - /// `Current + Eager` and `Nested + Eager` arguments are scanned here: - /// `Current + Eager` transparently, `Nested + Eager` by descending into the - /// body and holding the names it binds as pending. `Nested + Lazy` and - /// `Current + Lazy` bodies are their own scan units and deferred to the walk - /// because resolution of effects in these lazy scopes needs the child's own - /// flow context. - pub(super) fn scan_call(&mut self, call: &RCall) { - let (arg_effects, attach, source, assign) = match self.resolve_effects(call) { - Some(effects) => ( - effects.arguments, - effects.attach, - effects.source, - effects.assign, - ), - None => (None, None, None, None), - }; - - if let Some(package) = attach { - self.call_resolutions - .entry(call.syntax().text_trimmed_range()) - .or_default() - .attach = Some(package.clone()); - if !self.scopes[self.current_scope].kind.is_lazy() { - self.attached_flow.push(package); - } - } - - // Cache each recognized path with its resolution. The walk reads them - // back to emit one `Source` semantic call per file. `scan_source_call()` - // binds the sourced names as it goes so a later callee in this scope - // can see them. - if let Some(paths) = source { - let range = call.syntax().text_trimmed_range(); - for path in paths { - let resolution = self.scan_source_call(&path, range); - self.call_resolutions - .entry(range) - .or_default() - .source - .push(SourcedFile { path, resolution }); - } - } - - // Record each assigned name as a binding so a later callee in this scope - // sees it shadowed (e.g. `assign("local", identity)` masks base - // `local`). - if let Some(bindings) = assign { - let range = call.syntax().text_trimmed_range(); - for binding in bindings { - self.record_binding(binding.name.clone(), range); - self.call_resolutions - .entry(range) - .or_default() - .assign - .push(binding); - } - } - - let Some(arg_effects) = arg_effects else { - if let Ok(args) = call.arguments() { - for item in args.items().iter() { - let Ok(arg) = item else { continue }; - if let Some(value) = arg.value() { - self.scan_expression(&value); - } - } - } - return; - }; - - let Ok(args) = call.arguments() else { - return; - }; - let items = args.items(); - - for (i, item) in items.iter().enumerate() { - let Ok(arg) = item else { continue }; - let Some(value) = arg.value() else { continue }; - - match &arg_effects[i] { - None => self.scan_expression(&value), - // Quoted argument: only the unquoted holes are live. Scan these, - // suppress the rest. - Some(ResolvedArgumentEffect::Quote { holes }) => { - for hole in holes { - self.scan_expression(hole); - } - }, - Some(ResolvedArgumentEffect::EvalQ { env, timing }) => match (env, timing) { - // Calls like `evalq()` - (EvalEnv::Current, EvalTiming::Eager) => self.scan_expression(&value), - - // Calls like `on_load()`. Its body runs later, so its defs - // land in the enclosing scope. We don't resolve the body's - // calls here. The walk does that once it enters the child - // scope. But we do grab the names it defines now, so the - // owner's bound names are complete before the walk reaches a sibling. - (EvalEnv::Current, EvalTiming::Lazy) => { - self.record_enclosing_flow(value.syntax().text_trimmed_range()); - self.scan_lazy_owner_bindings(&value); - }, - - // Calls like `local()`. Its body runs eagerly at the call - // site, so its environment IS the current `flow_state`. - // Descend now, holding the names bound in this scope as - // pending so the walk has access to them. No `flow_state` - // reset: the child sees exactly what `begin_scan()` would - // have seeded. - // No `record_enclosing_flow()`: eager `Nested` bodies are - // never scanned at walk time, so nothing would read it. - (EvalEnv::Nested, EvalTiming::Eager) => { - let old = self.flow_state.snapshot(); - - let range = value.syntax().text_trimmed_range(); - self.eager_descent.open.push(BoundNames::new()); - self.scan_expression(&value); - if let Some(bound) = self.eager_descent.open.pop() { - self.eager_descent.pending.insert(range, bound); - } - - self.flow_state.restore(old); - }, - - // Calls like `reactive()`. Its body runs at an unknown - // later time, so it's a child scope scanned when the walk - // enters it. Record the names it inherits for its callee - // resolution, same as a function body. - (EvalEnv::Nested, EvalTiming::Lazy) => { - self.record_enclosing_flow(value.syntax().text_trimmed_range()); - }, - }, - } - } - - // Hand the resolved argument effects to the walk (at the end to avoid a clone) - self.call_resolutions - .entry(call.syntax().text_trimmed_range()) - .or_default() - .arguments = Some(arg_effects); - } - - /// Scan a binary operator for an assign effect (e.g. magrittr's `x %<>% f()`) - pub(super) fn scan_operator_assign(&mut self, bin: &RBinaryExpression) { - let Some(bindings) = self.resolve_operator_assign(bin) else { - return; - }; - let range = bin.syntax().text_trimmed_range(); - for binding in bindings { - self.record_binding(binding.name.clone(), range); - self.call_resolutions - .entry(range) - .or_default() - .assign - .push(binding); - } - } - - /// Resolve a binding operator's definitions. - fn resolve_operator_assign(&mut self, bin: &RBinaryExpression) -> Option> { - let op = bin.operator().ok()?; - - // A binding operator is either a `%...%` (`SPECIAL`, e.g. `%<>%`, where - // the operator text distinguishes it from `%>%`) or the walrus `:=` - // (`WALRUS`). Gate on the token kind before consulting the registry so we - // skip the resolver for ordinary operators like `+`. - if !matches!(op.kind(), RSyntaxKind::SPECIAL | RSyntaxKind::WALRUS) { - return None; - } - let op_text = op.text_trimmed(); - - // Bail early if this operator is not known to have effects annotations - if !effects::annotates(op_text) { - return None; - } - - let handlers = self.resolve_symbol_effects(op_text, bin.syntax().text_trimmed_range())?; - - let bindings = ScanBindings { builder: &*self }; - let ctx = CallContext::with_bindings(&bindings); - handlers.assign?.resolve(EffectSite::Operator(bin), &ctx) - } - - /// Copy the names a `Current + Lazy` body defines into the owner's - /// bound names, without marking them bound in the scan's flow state. - /// - /// This feeds enclosing snapshots only. A free variable elsewhere can - /// resolve to a name an `on_load({ ... })` defines in the owner, and - /// `register_enclosing_snapshot()` reads `bound_names` to find that ancestor. - /// The scan doesn't descend into these bodies otherwise, so their names - /// would only reach the owner when the walk later gets to the call, too late - /// for a sibling scanned before then. Collecting them now keeps the owner's - /// bound names complete before the walk touches any sibling. - /// - /// NSE shadow resolution does not read `bound_names`, so an incomplete - /// collection here can't flip an NSE decision. `is_locally_bound` reads the - /// captured eager bindings, which exclude deferred names by construction. - /// - /// We cover the realistic shapes, direct assignments and control flow, e.g. - /// `on_load({ x <- 1 })`. We stop at nested calls and function bodies - /// however, so we only add names the walk will also route, never a phantom. - /// `register_enclosing_snapshot` reads `binds()` as "a real definition - /// exists", so a phantom would send it chasing a binding that isn't there. - /// The price is a binding buried in a nested transparent call, e.g. - /// `on_load({ evalq(helper <- ...) })`, which we miss here, so a free - /// variable can't resolve to it. TODO(nse): We could potentially walk - /// transparent (Current) nested calls to collect those too. - /// - /// The names go to `bound_names` only, never to `flow_state`. The body - /// runs at some later time, so at an eager position after the call these - /// names aren't bound yet, and an eager callee there must still treat them - /// as unbound. - fn scan_lazy_owner_bindings(&mut self, expr: &AnyRExpression) { - match expr { - AnyRExpression::RBracedExpressions(braced) => { - for expr in braced.expressions().iter() { - self.scan_lazy_owner_bindings(&expr); - } - }, - - AnyRExpression::RBinaryExpression(bin) => { - // `<<-` binds in an ancestor, not the owner, so it's not routed - // here (matching `add_definition`). - if !is_assignment(bin) || is_super_assignment(bin) { - return; - } - let target = if is_right_assignment(bin) { - bin.right() - } else { - bin.left() - }; - if let Ok(target) = target { - if let Some((name, range)) = assignment_name(&target) { - self.record_owner_name(name, range); - } - } - }, - - AnyRExpression::RIfStatement(stmt) => { - if let Ok(consequence) = stmt.consequence() { - self.scan_lazy_owner_bindings(&consequence); - } - if let Some(else_clause) = stmt.else_clause() { - if let Ok(alternative) = else_clause.alternative() { - self.scan_lazy_owner_bindings(&alternative); - } - } - }, - - AnyRExpression::RForStatement(stmt) => { - if let Ok(variable) = stmt.variable() { - self.record_owner_name( - variable.name_text(), - variable.syntax().text_trimmed_range(), - ); - } - if let Ok(body) = stmt.body() { - self.scan_lazy_owner_bindings(&body); - } - }, - - AnyRExpression::RWhileStatement(stmt) => { - if let Ok(body) = stmt.body() { - self.scan_lazy_owner_bindings(&body); - } - }, - - AnyRExpression::RRepeatStatement(stmt) => { - if let Ok(body) = stmt.body() { - self.scan_lazy_owner_bindings(&body); - } - }, - - // Stop everywhere else: function bodies are child scopes, and a - // call's arguments aren't part of this scope's direct level. - _ => {}, - } - } - - /// Resolve a call's effects. - fn resolve_effects(&mut self, call: &RCall) -> Option { - let handlers = self.resolve_effects_handlers(call)?; - - // `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 - .and_then(|handler| handler.resolve(call, &ctx)); - let attach = handlers - .attach - .and_then(|handler| handler.resolve(call, &ctx)); - let source = handlers - .source - .and_then(|handler| handler.resolve(call, &ctx)); - let assign = handlers - .assign - .and_then(|handler| handler.resolve(EffectSite::Call(call), &ctx)); - - Some(Effects { - arguments, - attach, - source, - assign, - }) - } - - /// Resolve a call's callee to its [`EffectsHandlers`] (NSE, attach, ...). - /// - /// The shared core for both NSE recognition ([`scan_call`] reads `.arguments`) and - /// attach recognition ([`scan_call`] reads `.attach`). Two cases resolve: - /// - A bare identifier. If bound locally it goes through the local - /// [`resolve_local_effects`](Self::resolve_local_effects). Otherwise the - /// cross-file `ImportsResolver::resolve_effects()` resolves it across the - /// search path, against the attach set in `attached_flow`. - /// - A `pkg::fn` namespace expression, resolved through - /// `ImportsResolver::resolve_qualified_effects()`. `::` names the package, - /// so there's no search-path disambiguation; the resolver answers from - /// per-package knowledge (the static registry, plus cross-file knowledge - /// like the re-export chase once that lands). - /// - /// The bound check reads the scan pass's flow-precise binding state - /// for the current scope, so this must run during the scan, not the walk. - /// - /// [`EffectsHandlers`]: crate::effects::EffectsHandlers - /// [`scan_call`]: Self::scan_call - fn resolve_effects_handlers(&mut self, call: &RCall) -> Option { - let func = call.function().ok()?; - - match &func { - AnyRExpression::RIdentifier(ident) => { - let name = ident.name_text(); - self.resolve_symbol_effects(&name, call.syntax().text_trimmed_range()) - }, - - AnyRExpression::RNamespaceExpression(ns_expr) => { - let left = ns_expr.left().ok()?; - let right = ns_expr.right().ok()?; - let pkg = left.identifier_text()?; - let func_name = right.identifier_text()?; - - if !effects::annotates(&func_name) { - return None; - } - - self.resolver.resolve_qualified_effects(&pkg, &func_name) - }, - - _ => None, - } - } - - /// Resolve a callee `sym` to its [`EffectsHandlers`]. - /// - /// `range` is the invocation's range, used to anchor a lazy-shadow - /// diagnostic. - fn resolve_symbol_effects(&mut self, sym: &str, range: TextRange) -> Option { - // First check for a local definition (which in the future may - // carry declared effects that we resolve here) - // - // Looked up from `flow_state` which already carries every - // eager binding visible here: the scope's own flow-precise - // bindings so far, plus the enclosing eager environment seeded - // at `begin_scan()`. Forward and deferred (lazy-routed) - // bindings are excluded. A forward one isn't in `flow_state` - // yet, and a deferred one (`on_load`, `<<-`) never enters it. - if self.flow_state.is_bound(sym) { - return self.resolve_local_effects(sym); - } - - // Bail early if it is known that no package annotates this name - // with effects. This speeds up the common case of no known annotations. - if !effects::annotates(sym) { - return None; - } - - // Now check imports since the symbol is locally unbound. The - // arena's `current_scope` is the scan unit's scope (the descent - // pushes no arena scopes), so its laziness is the "am I in a lazy - // context" test the resolver needs. `attached_flow` is the - // flow-precise attach prefix during the file scan and the - // complete end-of-file set during the walk. - let lazy = self.scopes[self.current_scope].kind.is_lazy(); - let effects = self - .resolver - .resolve_effects(sym, &self.attached_flow, lazy)?; - - // The callee is unbound by any eager binding, so its effect - // holds. If a lazy-crossed ancestor binds it whole-scope, that - // binding's timing relative to this deferred body is - // undetermined, so the decision is a guess. Flag it. - // - // TODO(diagnostics): a symmetric attach ambiguity is out of - // scope here. A callee resolved not-effectful could be flipped - // by an attach from a sibling lazy body (`g <- function() - // library(shiny); f <- function() reactive({...}`). Detecting it - // needs the complete set of lazy-context attaches, a post-pass - // rather than this local ancestor check, so it belongs in the - // future salsa diagnostics query where this lint should move too. - if let Some(overwrite_range) = self.is_lazily_shadowed(sym) { - self.record_lazy_shadow_ambiguity(sym.to_string(), range, overwrite_range); - } - - Some(effects) - } - - /// Local resolver for declared effects, mirroring the imports resolver's - /// `resolve_effects()` method on the cross-file side. - /// TODO(nse, annotations): always `None` until `declare()` parsing lands. - fn resolve_local_effects(&self, _name: &str) -> Option { - None - } - - /// Detect ambiguities caused by laziness. - /// - /// We've recognized an effect for `name` (NSE scope or attach) because it - /// was locally unbound at the current flow cursor and eager-flow resolution - /// found one. If we're in a lazy context, that decision could be wrong: an - /// enclosing scope may bind `name` with a timing we can't pin down, either a - /// later assignment, or one from another deferred body that could run before - /// or after us. We detect this ambiguity here so it can be linted. - /// - /// Returns the site of the shadowing binding. - fn is_lazily_shadowed(&self, name: &str) -> Option { - let mut scope = self.current_scope; - let mut crossed_lazy = self.scopes[scope].kind.is_lazy(); - - while let Some(parent) = self.scopes[scope].parent { - if crossed_lazy { - if let Some(range) = self.scope_binding_range(parent, name) { - return Some(range); - } - } - - if self.scopes[parent].kind.is_lazy() { - crossed_lazy = true; - } - scope = parent; - } - - None - } - - fn record_lazy_shadow_ambiguity( - &mut self, - name: String, - call_range: TextRange, - overwrite_range: TextRange, - ) { - self.diagnostics - .push(SemanticDiagnostic::LazyShadowAmbiguity { - name, - call_range, - overwrite_range, - }); - } - - /// Process a call the scan pass decided is NSE, using the resolved argument - /// scoping the scan cached. Handle each scoped argument, pushing NSE scopes - /// inline. - pub(super) fn collect_nse_call(&mut self, call: &RCall, arg_effects: ResolvedArgumentEffects) { - let Ok(args) = call.arguments() else { - return; - }; - let items = args.items(); - - for (i, item) in items.iter().enumerate() { - let Ok(arg) = item else { continue }; - let Some(value) = arg.value() else { continue }; - - let Some(argument) = &arg_effects[i] else { - self.collect_expression(&value); - continue; - }; - match argument { - ResolvedArgumentEffect::EvalQ { env, timing } => { - self.collect_nse_argument(*env, *timing, &value) - }, - // Quoted argument: only the unquote holes are live. - ResolvedArgumentEffect::Quote { holes } => { - for hole in holes { - self.collect_expression(hole); - } - }, - } - } - } - - /// Walk a single NSE argument body, pushing a scope when appropriate. - /// - /// `Current + Eager` stays in the current scope. `Nested + Eager` was - /// already scanned by the descent, so we install its pending names and only - /// walk. The remaining lazy bodies are their own scan units that we scan - /// here on entry. - fn collect_nse_argument(&mut self, env: EvalEnv, timing: EvalTiming, value: &AnyRExpression) { - match (env, timing) { - // Calls like `evalq()` - (EvalEnv::Current, EvalTiming::Eager) => { - self.collect_expression(value); - }, - - // Calls like `local()` - (EvalEnv::Nested, EvalTiming::Eager) => { - let range = value.syntax().text_trimmed_range(); - let kind = ScopeKind::Nse(EvalEnv::Nested, EvalTiming::Eager); - let scope = self.push_scope(kind, range); - - // Install the pending names the descent recorded for this body, - // before collecting so lazy children inside can see them via - // `scope_binds_anywhere()`. - match self.eager_descent.pending.remove(&range) { - Some(bound) => self.bound_names[scope] = bound, - None => { - // An eager NSE scope is reachable only through the scan - // unit that descended into it, so the pending set must - // exist. If not this is a builder bug. In release - // builds we still scan the body here so the walk can - // proceed. This fallback runs with an empty eager - // environment and its shadow decisions are more - // degraded than a real lazy unit's. - stdext::debug_panic!( - "Missing pending bound names for eager NSE body at {range:?}" - ); - self.begin_scan(); - self.scan_expression(value); - }, - } - - self.collect_expression(value); - self.pop_scope(scope); - }, - - (env, timing) => { - let kind = ScopeKind::Nse(env, timing); - let scope = self.push_scope(kind, value.syntax().text_trimmed_range()); - - // Scan the child body before walking it. A `Current + Lazy` - // scope routes its defs to the owner and holds no bound names of its - // own, which `record_binding` handles; the scan still runs to - // record the body's NSE decisions in the child's flow context. - self.begin_scan(); - self.scan_expression(value); - self.collect_expression(value); - self.pop_scope(scope); - }, - } - } -} diff --git a/crates/oak_semantic/src/builder/effects.rs b/crates/oak_semantic/src/builder/effects.rs new file mode 100644 index 000000000..e2835a779 --- /dev/null +++ b/crates/oak_semantic/src/builder/effects.rs @@ -0,0 +1,228 @@ +use aether_syntax::AnyRExpression; +use aether_syntax::RBinaryExpression; +use aether_syntax::RCall; +use aether_syntax::RSyntaxKind; +use biome_rowan::AstNode; +use biome_rowan::TextRange; +use oak_core::syntax_ext::AnyRSelectorExt; +use oak_core::syntax_ext::RIdentifierExt; + +use super::scan::ScanBindings; +use super::SemanticIndexBuilder; +use crate::effects; +use crate::effects::AssignBinding; +use crate::effects::CallContext; +use crate::effects::EffectSite; +use crate::effects::Effects; +use crate::effects::EffectsHandlers; +use crate::resolver::ImportsResolver; +use crate::semantic_index::SemanticDiagnostic; + +impl SemanticIndexBuilder { + pub(super) fn resolve_effects(&mut self, call: &RCall) -> Option { + let handlers = self.resolve_effects_handlers(call)?; + + // `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 + .and_then(|handler| handler.resolve(call, &ctx)); + let attach = handlers + .attach + .and_then(|handler| handler.resolve(call, &ctx)); + let source = handlers + .source + .and_then(|handler| handler.resolve(call, &ctx)); + let assign = handlers + .assign + .and_then(|handler| handler.resolve(EffectSite::Call(call), &ctx)); + + Some(Effects { + arguments, + attach, + source, + assign, + }) + } + + /// Resolve a call's callee to its [`EffectsHandlers`] (NSE, attach, ...). + /// + /// The shared core for both NSE recognition ([`scan_call`] reads `.arguments`) and + /// attach recognition ([`scan_call`] reads `.attach`). Two cases resolve: + /// - A bare identifier. If bound locally it goes through the local + /// [`resolve_local_effects`](Self::resolve_local_effects). Otherwise the + /// cross-file `ImportsResolver::resolve_effects()` resolves it across the + /// search path, against the attach set in `attached_flow`. + /// - A `pkg::fn` namespace expression, resolved through + /// `ImportsResolver::resolve_qualified_effects()`. `::` names the package, + /// so there's no search-path disambiguation; the resolver answers from + /// per-package knowledge (the static registry, plus cross-file knowledge + /// like the re-export chase once that lands). + /// + /// The bound check reads the scan pass's flow-precise binding state + /// for the current scope, so this must run during the scan, not the walk. + /// + /// [`EffectsHandlers`]: crate::effects::EffectsHandlers + /// [`scan_call`]: Self::scan_call + fn resolve_effects_handlers(&mut self, call: &RCall) -> Option { + let func = call.function().ok()?; + + match &func { + AnyRExpression::RIdentifier(ident) => { + let name = ident.name_text(); + self.resolve_symbol_effects(&name, call.syntax().text_trimmed_range()) + }, + + AnyRExpression::RNamespaceExpression(ns_expr) => { + let left = ns_expr.left().ok()?; + let right = ns_expr.right().ok()?; + let pkg = left.identifier_text()?; + let func_name = right.identifier_text()?; + + if !effects::annotates(&func_name) { + return None; + } + + self.resolver.resolve_qualified_effects(&pkg, &func_name) + }, + + _ => None, + } + } + + /// Resolve a callee `sym` to its [`EffectsHandlers`]. + /// + /// `range` is the invocation's range, used to anchor a lazy-shadow + /// diagnostic. + fn resolve_symbol_effects(&mut self, sym: &str, range: TextRange) -> Option { + // First check for a local definition (which in the future may + // carry declared effects that we resolve here) + // + // Looked up from `flow_state` which already carries every + // eager binding visible here: the scope's own flow-precise + // bindings so far, plus the enclosing eager environment seeded + // at `begin_scan()`. Forward and deferred (lazy-routed) + // bindings are excluded. A forward one isn't in `flow_state` + // yet, and a deferred one (`on_load`, `<<-`) never enters it. + if self.scan.flow_state.is_bound(sym) { + return self.resolve_local_effects(sym); + } + + // Bail early if it is known that no package annotates this name + // with effects. This speeds up the common case of no known annotations. + if !effects::annotates(sym) { + return None; + } + + // Now check imports since the symbol is locally unbound. The + // arena's `current_scope` is the scan unit's scope (the descent + // pushes no arena scopes), so its laziness is the "am I in a lazy + // context" test the resolver needs. `attached_flow` is the + // flow-precise attach prefix during the file scan and the + // complete end-of-file set during the walk. + let lazy = self.scopes[self.current_scope].kind.is_lazy(); + let effects = self + .resolver + .resolve_effects(sym, &self.scan.attached_flow, lazy)?; + + // The callee is unbound by any eager binding, so its effect + // holds. If a lazy-crossed ancestor binds it whole-scope, that + // binding's timing relative to this deferred body is + // undetermined, so the decision is a guess. Flag it. + // + // TODO(diagnostics): a symmetric attach ambiguity is out of + // scope here. A callee resolved not-effectful could be flipped + // by an attach from a sibling lazy body (`g <- function() + // library(shiny); f <- function() reactive({...}`). Detecting it + // needs the complete set of lazy-context attaches, a post-pass + // rather than this local ancestor check, so it belongs in the + // future salsa diagnostics query where this lint should move too. + if let Some(overwrite_range) = self.is_lazily_shadowed(sym) { + self.record_lazy_shadow_ambiguity(sym.to_string(), range, overwrite_range); + } + + Some(effects) + } + + /// Local resolver for declared effects, mirroring the imports resolver's + /// `resolve_effects()` method on the cross-file side. + /// TODO(nse, annotations): always `None` until `declare()` parsing lands. + fn resolve_local_effects(&self, _name: &str) -> Option { + None + } + + /// Resolve a binding operator's definitions. + pub(super) fn resolve_operator_assign( + &mut self, + bin: &RBinaryExpression, + ) -> Option> { + let op = bin.operator().ok()?; + + // A binding operator is either a `%...%` (`SPECIAL`, e.g. `%<>%`, where + // the operator text distinguishes it from `%>%`) or the walrus `:=` + // (`WALRUS`). Gate on the token kind before consulting the registry so we + // skip the resolver for ordinary operators like `+`. + if !matches!(op.kind(), RSyntaxKind::SPECIAL | RSyntaxKind::WALRUS) { + return None; + } + let op_text = op.text_trimmed(); + + // Bail early if this operator is not known to have effects annotations + if !effects::annotates(op_text) { + return None; + } + + let handlers = self.resolve_symbol_effects(op_text, bin.syntax().text_trimmed_range())?; + + let bindings = ScanBindings { builder: &*self }; + let ctx = CallContext::with_bindings(&bindings); + handlers.assign?.resolve(EffectSite::Operator(bin), &ctx) + } + + /// Detect ambiguities caused by laziness. + /// + /// We've recognized an effect for `name` (NSE scope or attach) because it + /// was locally unbound at the current flow cursor and eager-flow resolution + /// found one. If we're in a lazy context, that decision could be wrong: an + /// enclosing scope may bind `name` with a timing we can't pin down, either a + /// later assignment, or one from another deferred body that could run before + /// or after us. We detect this ambiguity here so it can be linted. + /// + /// Returns the site of the shadowing binding. + fn is_lazily_shadowed(&self, name: &str) -> Option { + let mut scope = self.current_scope; + let mut crossed_lazy = self.scopes[scope].kind.is_lazy(); + + while let Some(parent) = self.scopes[scope].parent { + if crossed_lazy { + if let Some(range) = self.scope_binding_range(parent, name) { + return Some(range); + } + } + + if self.scopes[parent].kind.is_lazy() { + crossed_lazy = true; + } + scope = parent; + } + + None + } + + fn record_lazy_shadow_ambiguity( + &mut self, + name: String, + call_range: TextRange, + overwrite_range: TextRange, + ) { + self.diagnostics + .push(SemanticDiagnostic::LazyShadowAmbiguity { + name, + call_range, + overwrite_range, + }); + } +} diff --git a/crates/oak_semantic/src/builder/scan.rs b/crates/oak_semantic/src/builder/scan.rs new file mode 100644 index 000000000..dd2972918 --- /dev/null +++ b/crates/oak_semantic/src/builder/scan.rs @@ -0,0 +1,813 @@ +//! The scan pass: NSE decisions and bound-name collection in flow order, +//! ahead of the walk. See the module docs on [`super`] for the scan/walk +//! split. + +use aether_syntax::AnyRExpression; +use aether_syntax::AnyRParameterName; +use aether_syntax::RBinaryExpression; +use aether_syntax::RCall; +use aether_syntax::RExpressionList; +use aether_syntax::RParameters; +use aether_syntax::RSyntaxNode; +use biome_rowan::AstNode; +use biome_rowan::AstNodeList; +use biome_rowan::AstSeparatedList; +use biome_rowan::SyntaxNodeCast; +use biome_rowan::TextRange; +use biome_rowan::WalkEvent; +use oak_core::syntax_ext::RIdentifierExt; +use rustc_hash::FxHashMap; +use rustc_hash::FxHashSet; + +use super::assignment_name; +use super::is_assignment; +use super::is_right_assignment; +use super::is_super_assignment; +use super::SemanticIndexBuilder; +use crate::effects::AssignBinding; +use crate::effects::ResolvedArgumentEffect; +use crate::effects::ResolvedArgumentEffects; +use crate::effects::ScopeBindings; +use crate::resolver::ImportsResolver; +use crate::resolver::SourceResolution; +use crate::semantic_index::EvalEnv; +use crate::semantic_index::EvalTiming; +use crate::semantic_index::ScopeId; +use crate::semantic_index::ScopeKind; +use crate::semantic_index::SymbolFlags; + +// Traversal + +impl SemanticIndexBuilder { + /// Reset the flow-precise binding state for a fresh scope's scan. + /// + /// Seeds it with two things: + /// + /// - The names inherited from enclosing scopes, captured when this scope was + /// entered (`enclosing_flow`). The parent's own scan was seeded the same + /// way, so this is transitively complete: it holds every eager binding + /// visible from an ancestor at this scope's definition point. + /// - The scope's own already-bound names. For a function scope that's the + /// parameters, recorded just before the scan runs. For file and NSE scopes + /// nothing local is bound yet. + /// + /// Parameter defaults are a special case: they are scanned before the params + /// are recorded, so `walk_function` seeds the full formal set by hand + /// (all formals bind at once in R, so a default sees every parameter name). + pub(super) fn begin_scan(&mut self) { + let range = self.scopes[self.current_scope].range; + + match self.scan.enclosing_flow.get(&range).cloned() { + Some(entry) => self.scan.flow_state.restore(entry), + None => self.scan.flow_state.clear(), + } + + for (_id, symbol) in self.walk.symbol_tables[self.current_scope].iter() { + if symbol.flags().contains(SymbolFlags::IS_BOUND) { + self.scan.flow_state.bind(symbol.name().to_string()); + } + } + } + + pub(super) fn scan_expression_list(&mut self, list: &RExpressionList) { + for expr in list.iter() { + self.scan_expression(&expr); + } + } + + /// Scan for NSE calls and collect the scope's bound names, in flow order. + /// + /// Runs before the walk of a scope. It decides NSE-ness at each call the + /// same way the walk's [`is_locally_bound`](Self::is_locally_bound) would, + /// records the decision in `call_resolutions` for the walk to reuse, and adds + /// non-skipped definition names to `bound_names`. The bound names must be + /// complete before the walk descends into any child scope, because a lazy + /// child body can reference an ancestor def the ancestor's walk hasn't + /// reached yet. + /// + /// A scan unit is the file or a lazy body (function, `Nested + Lazy`, + /// `Current + Lazy`). Each unit is scanned once. Within a unit the scan + /// descends through every eager boundary it meets, in flow order: + /// + /// - A `Current + Eager` body pushes no scope, so it stays part of this + /// scope's direct level and is scanned through transparently. + /// - A `Nested + Eager` body is descended into with a save/restore of + /// `flow_state`, and the names it binds are left pending for the walk to + /// install without re-scanning. + /// - Function and lazy bodies (`Nested + Lazy`, `Current + Lazy`) are their + /// own scan units, scanned separately when the walk enters them, because + /// NSE resolution there needs the child's own flow context. + /// + /// Branch analysis is precise. In `if (c) local <- f else local({ y <- 1 + /// })` the else branch sees an NSE call because `local` is unbound on the + /// else path, which prevents `y` from leaking into the scope. + pub(super) fn scan_expression(&mut self, expr: &AnyRExpression) { + match expr { + AnyRExpression::RFunctionDefinition(func) => { + // A function body is a child scope, scanned when it's entered. + // Record the names it inherits now so that when we later resolve + // an NSE callee inside the body, we can check whether one of them + // shadows it (see `enclosing_flow`). + self.record_enclosing_flow(func.syntax().text_trimmed_range()); + }, + + AnyRExpression::RBracedExpressions(braced) => { + self.scan_expression_list(&braced.expressions()); + }, + + AnyRExpression::RBinaryExpression(bin) => { + if is_assignment(bin) { + let right = is_right_assignment(bin); + + // Value side first, mirroring `walk_assignment`: it may + // hold NSE calls or nested defs that flow before the binding. + let value = if right { bin.left() } else { bin.right() }; + if let Ok(value) = value { + self.scan_expression(&value); + } + + let target = if right { bin.right() } else { bin.left() }; + if let Ok(target) = target { + match assignment_name(&target) { + // `<<-` binds in an ancestor, not here, so it doesn't + // shadow a callee in this scope (matching the walk). + Some((name, range)) if !is_super_assignment(bin) => { + self.record_binding(name, range); + }, + Some(_) => {}, + // Complex target (`x$foo <- v`): no binding, but the + // target may hold NSE calls. + None => self.scan_expression(&target), + } + } + } else { + // A binding operator (`x %<>% f()`) binds its left operand. + // Scan the operands as uses first, then record the binding, + // so a later callee in this scope sees that name shadowed. + // Mirrors the value-then-target order of the `is_assignment` + // branch. + if let Ok(lhs) = bin.left() { + self.scan_expression(&lhs); + } + if let Ok(rhs) = bin.right() { + self.scan_expression(&rhs); + } + self.scan_operator_assign(bin); + } + }, + + AnyRExpression::RCall(call) => { + if let Ok(func) = call.function() { + self.scan_expression(&func); + } + self.scan_call(call); + }, + + AnyRExpression::RForStatement(stmt) => { + // The for-variable is always bound (R sets it to NULL for empty + // sequences), so it binds before the body regardless of flow. + if let Ok(variable) = stmt.variable() { + self.record_binding( + variable.name_text(), + variable.syntax().text_trimmed_range(), + ); + } + if let Ok(sequence) = stmt.sequence() { + self.scan_expression(&sequence); + } + // A loop body only adds bindings (a name bound inside still + // "reaches" on the ran path), so no restore is needed, unlike + // the two-branch `if`/`else` below. + if let Ok(body) = stmt.body() { + self.scan_expression(&body); + } + }, + + AnyRExpression::RIfStatement(stmt) => { + if let Ok(condition) = stmt.condition() { + self.scan_expression(&condition); + } + + let pre_if = self.scan.flow_state.snapshot(); + + if let Ok(consequence) = stmt.consequence() { + self.scan_expression(&consequence); + } + + let post_if = self.scan.flow_state.snapshot(); + self.scan.flow_state.restore(pre_if); + + if let Some(else_clause) = stmt.else_clause() { + if let Ok(alternative) = else_clause.alternative() { + self.scan_expression(&alternative); + } + } + + // Both branches' bindings are live afterwards. + self.scan.flow_state.merge(post_if); + }, + + // `while`/`repeat` loops, subsets, extractions, parentheses, unary + // ops, and literals: recurse into child expressions. Loops need no + // flow restore (see the `for` arm). Identifiers and dots are leaves + // with no bindings or calls, so they fall through to a no-op walk. + _ => { + self.scan_descendants(expr.syntax()); + }, + } + } + + /// Walk descendant nodes of `expr`, scanning the outermost + /// `AnyRExpression` children. The scan analog of + /// `walk_descendants`. + fn scan_descendants(&mut self, node: &RSyntaxNode) { + let mut preorder = node.preorder(); + preorder.next(); + + while let Some(event) = preorder.next() { + let WalkEvent::Enter(node) = event else { + continue; + }; + if let Some(expr) = node.cast::() { + self.scan_expression(&expr); + preorder.skip_subtree(); + } + } + } + + /// Scan a call for effects (NSE scopes, attaches, sources, assigns) and + /// record its decisions for the walk to reuse. The callee is resolved once + /// through [`resolve_effects`]. + /// + /// `Current + Eager` and `Nested + Eager` arguments are scanned here: + /// `Current + Eager` transparently, `Nested + Eager` by descending into the + /// body and holding the names it binds as pending. `Nested + Lazy` and + /// `Current + Lazy` bodies are their own scan units and deferred to the walk + /// because resolution of effects in these lazy scopes needs the child's own + /// flow context. + fn scan_call(&mut self, call: &RCall) { + let (arg_effects, attach, source, assign) = match self.resolve_effects(call) { + Some(effects) => ( + effects.arguments, + effects.attach, + effects.source, + effects.assign, + ), + None => (None, None, None, None), + }; + + if let Some(package) = attach { + self.scan + .call_resolutions + .entry(call.syntax().text_trimmed_range()) + .or_default() + .attach = Some(package.clone()); + if !self.scopes[self.current_scope].kind.is_lazy() { + self.scan.attached_flow.push(package); + } + } + + // Cache each recognized path with its resolution. The walk reads them + // back to emit one `Source` semantic call per file. `scan_source_call()` + // binds the sourced names as it goes so a later callee in this scope + // can see them. + if let Some(paths) = source { + let range = call.syntax().text_trimmed_range(); + for path in paths { + let resolution = self.scan_source_call(&path, range); + self.scan + .call_resolutions + .entry(range) + .or_default() + .source + .push(SourcedFile { path, resolution }); + } + } + + // Record each assigned name as a binding so a later callee in this scope + // sees it shadowed (e.g. `assign("local", identity)` masks base + // `local`). + if let Some(bindings) = assign { + let range = call.syntax().text_trimmed_range(); + for binding in bindings { + self.record_binding(binding.name.clone(), range); + self.scan + .call_resolutions + .entry(range) + .or_default() + .assign + .push(binding); + } + } + + let Some(arg_effects) = arg_effects else { + if let Ok(args) = call.arguments() { + for item in args.items().iter() { + let Ok(arg) = item else { continue }; + if let Some(value) = arg.value() { + self.scan_expression(&value); + } + } + } + return; + }; + + let Ok(args) = call.arguments() else { + return; + }; + let items = args.items(); + + for (i, item) in items.iter().enumerate() { + let Ok(arg) = item else { continue }; + let Some(value) = arg.value() else { continue }; + + match &arg_effects[i] { + None => self.scan_expression(&value), + // Quoted argument: only the unquoted holes are live. Scan these, + // suppress the rest. + Some(ResolvedArgumentEffect::Quote { holes }) => { + for hole in holes { + self.scan_expression(hole); + } + }, + Some(ResolvedArgumentEffect::EvalQ { env, timing }) => match (env, timing) { + // Calls like `evalq()` + (EvalEnv::Current, EvalTiming::Eager) => self.scan_expression(&value), + + // Calls like `on_load()`. Its body runs later, so its defs + // land in the enclosing scope. We don't resolve the body's + // calls here. The walk does that once it enters the child + // scope. But we do grab the names it defines now, so the + // owner's bound names are complete before the walk reaches a sibling. + (EvalEnv::Current, EvalTiming::Lazy) => { + self.record_enclosing_flow(value.syntax().text_trimmed_range()); + self.scan_lazy_owner_bindings(&value); + }, + + // Calls like `local()`. Its body runs eagerly at the call + // site, so its environment IS the current `flow_state`. + // Descend now, holding the names bound in this scope as + // pending so the walk has access to them. No `flow_state` + // reset: the child sees exactly what `begin_scan()` would + // have seeded. + // No `record_enclosing_flow()`: eager `Nested` bodies are + // never scanned at walk time, so nothing would read it. + (EvalEnv::Nested, EvalTiming::Eager) => { + let old = self.scan.flow_state.snapshot(); + + let range = value.syntax().text_trimmed_range(); + self.scan.eager_descent.open.push(BoundNames::new()); + self.scan_expression(&value); + if let Some(bound) = self.scan.eager_descent.open.pop() { + self.scan.eager_descent.pending.insert(range, bound); + } + + self.scan.flow_state.restore(old); + }, + + // Calls like `reactive()`. Its body runs at an unknown + // later time, so it's a child scope scanned when the walk + // enters it. Record the names it inherits for its callee + // resolution, same as a function body. + (EvalEnv::Nested, EvalTiming::Lazy) => { + self.record_enclosing_flow(value.syntax().text_trimmed_range()); + }, + }, + } + } + + // Hand the resolved argument effects to the walk (at the end to avoid a clone) + self.scan + .call_resolutions + .entry(call.syntax().text_trimmed_range()) + .or_default() + .arguments = Some(arg_effects); + } + + /// Copy the names a `Current + Lazy` body defines into the owner's + /// bound names, without marking them bound in the scan's flow state. + /// + /// This feeds enclosing snapshots only. A free variable elsewhere can + /// resolve to a name an `on_load({ ... })` defines in the owner, and + /// `register_enclosing_snapshot()` reads `bound_names` to find that ancestor. + /// The scan doesn't descend into these bodies otherwise, so their names + /// would only reach the owner when the walk later gets to the call, too late + /// for a sibling scanned before then. Collecting them now keeps the owner's + /// bound names complete before the walk touches any sibling. + /// + /// NSE shadow resolution does not read `bound_names`, so an incomplete + /// collection here can't flip an NSE decision. `is_locally_bound` reads the + /// captured eager bindings, which exclude deferred names by construction. + /// + /// We cover the realistic shapes, direct assignments and control flow, e.g. + /// `on_load({ x <- 1 })`. We stop at nested calls and function bodies + /// however, so we only add names the walk will also route, never a phantom. + /// `register_enclosing_snapshot` reads `binds()` as "a real definition + /// exists", so a phantom would send it chasing a binding that isn't there. + /// The price is a binding buried in a nested transparent call, e.g. + /// `on_load({ evalq(helper <- ...) })`, which we miss here, so a free + /// variable can't resolve to it. TODO(nse): We could potentially walk + /// transparent (Current) nested calls to collect those too. + /// + /// The names go to `bound_names` only, never to `flow_state`. The body + /// runs at some later time, so at an eager position after the call these + /// names aren't bound yet, and an eager callee there must still treat them + /// as unbound. + fn scan_lazy_owner_bindings(&mut self, expr: &AnyRExpression) { + match expr { + AnyRExpression::RBracedExpressions(braced) => { + for expr in braced.expressions().iter() { + self.scan_lazy_owner_bindings(&expr); + } + }, + + AnyRExpression::RBinaryExpression(bin) => { + // `<<-` binds in an ancestor, not the owner, so it's not routed + // here (matching `add_definition`). + if !is_assignment(bin) || is_super_assignment(bin) { + return; + } + let target = if is_right_assignment(bin) { + bin.right() + } else { + bin.left() + }; + if let Ok(target) = target { + if let Some((name, range)) = assignment_name(&target) { + self.record_owner_name(name, range); + } + } + }, + + AnyRExpression::RIfStatement(stmt) => { + if let Ok(consequence) = stmt.consequence() { + self.scan_lazy_owner_bindings(&consequence); + } + if let Some(else_clause) = stmt.else_clause() { + if let Ok(alternative) = else_clause.alternative() { + self.scan_lazy_owner_bindings(&alternative); + } + } + }, + + AnyRExpression::RForStatement(stmt) => { + if let Ok(variable) = stmt.variable() { + self.record_owner_name( + variable.name_text(), + variable.syntax().text_trimmed_range(), + ); + } + if let Ok(body) = stmt.body() { + self.scan_lazy_owner_bindings(&body); + } + }, + + AnyRExpression::RWhileStatement(stmt) => { + if let Ok(body) = stmt.body() { + self.scan_lazy_owner_bindings(&body); + } + }, + + AnyRExpression::RRepeatStatement(stmt) => { + if let Ok(body) = stmt.body() { + self.scan_lazy_owner_bindings(&body); + } + }, + + // Stop everywhere else: function bodies are child scopes, and a + // call's arguments aren't part of this scope's direct level. + _ => {}, + } + } + + /// Scan a binary operator for an assign effect (e.g. magrittr's `x %<>% f()`) + fn scan_operator_assign(&mut self, bin: &RBinaryExpression) { + let Some(bindings) = self.resolve_operator_assign(bin) else { + return; + }; + let range = bin.syntax().text_trimmed_range(); + for binding in bindings { + self.record_binding(binding.name.clone(), range); + self.scan + .call_resolutions + .entry(range) + .or_default() + .assign + .push(binding); + } + } + + pub(super) fn scan_parameter_defaults(&mut self, params: &RParameters) { + // Seed `flow_state` with every parameter names so a callee inside a + // default value sees the full formal set + for param in params.items().iter() { + let Ok(param) = param else { continue }; + let Ok(name) = param.name() else { continue }; + let text = match &name { + AnyRParameterName::RIdentifier(ident) => ident.name_text(), + AnyRParameterName::RDots(_) => String::from("..."), + AnyRParameterName::RDotDotI(ddi) => ddi.syntax().text_trimmed().to_string(), + }; + self.scan.flow_state.bind(text); + } + + for param in params.items().iter() { + let Ok(param) = param else { continue }; + let Some(default) = param.default() else { + continue; + }; + if let Ok(value) = default.value() { + self.scan_expression(&value); + } + } + } + + /// Resolve one sourced `path`, bind the names it brings in, and return its + /// resolution for the caller to cache. + /// + /// The binding is eager: `source()` runs at its position, so the sourced + /// names are bound afterwards and can shadow a later NSE callee (e.g. a + /// sourced `local` masking base `local`). Returns `None` when the resolver + /// can't locate the target. + /// + /// [`scan_call`]: Self::scan_call + fn scan_source_call( + &mut self, + path: &str, + source_range: TextRange, + ) -> Option { + let resolution = self.resolver.resolve_source(path)?; + + // Sourced names originate in another file, so they have no binding site + // here. Anchor the overwrite range at the `source()` call instead. + for name in &resolution.names { + self.record_binding(name.clone(), source_range); + } + + // A `source()`-forwarded `library()` attaches at this call's flow + // position, the same as an attach written here directly. Only in eager + // context, matching `scan_attach_call`'s `!is_lazy()` gate. + if !self.scopes[self.current_scope].kind.is_lazy() { + for pkg in &resolution.packages { + self.scan.attached_flow.push(pkg.clone()); + } + } + + Some(resolution) + } + + /// 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.scan.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)) + } +} + +// State management + +impl SemanticIndexBuilder { + /// Record the names a child scope (function body, NSE argument) about to be + /// created at `range` inherits from its ancestors, to seed the child's scan + /// in `begin_scan`. Called during the scan, where `flow_state` is the + /// parent's flow-precise state at the child's definition point (already + /// carrying the parent's own inherited ancestors, so the child inherits + /// transitively). + fn record_enclosing_flow(&mut self, range: TextRange) { + self.scan + .enclosing_flow + .insert(range, self.scan.flow_state.snapshot()); + } + + /// Record a binding in the scan's flow state. + /// + /// The flow-precise `flow_state` always learns the name, so a + /// later callee in this scope sees it shadowed. The bound names only get it + /// when the current scope owns it. A `Current + Lazy` scope routes its defs + /// to the owner, so the name is added to the owner's bound names instead, the + /// same routing `add_definition_to_owner` does during the walk. + fn record_binding(&mut self, name: String, range: TextRange) { + self.record_owner_name(name.clone(), range); + self.scan.flow_state.bind(name); + } + + /// Route a binding NAME into its owner scope's bound names, matching + /// `add_definition`'s routing. When a descent is open the name goes to the + /// descent top, which is always an eager `Nested` body scanned inline and so + /// owns its bindings. Otherwise a `Current + Lazy` scope routes to + /// `definition_owner()` and every other scope owns its bindings. + /// + /// Split from `record_binding` so `scan_lazy_owner_bindings` can add + /// a deferred body's names to the owner's bound names without also marking them + /// bound in `flow_state` (see that helper for why). + fn record_owner_name(&mut self, name: String, range: TextRange) { + if let Some(bound) = self.scan.eager_descent.open.last_mut() { + bound.add(name, range); + return; + } + + if let Some(target) = match self.scopes[self.current_scope].kind { + ScopeKind::Nse(EvalEnv::Current, EvalTiming::Lazy) => self.definition_owner(), + _ => Some(self.current_scope), + } { + self.scan.bound_names[target].add(name, range); + } + } +} + +/// What the scan resolved a single call to, for the walk to reuse. A call can +/// carry several of these at once. +/// +/// - `arguments`: the per-argument evaluation effects the call resolved to, +/// filled in flow order. `None` means no annotated arguments (not NSE today). +/// - `attach`: the package a `library()`/`require()` call attaches, recognized +/// shadow-aware on the resolve path. The walk reads it back to emit a scoped +/// `SemanticCall::Attach`. +/// - `source`: the files a recognized `source()` call brings in, each with its +/// resolution. +/// - `assign`: the bindings `assign()`-like calls create in the current scope. +#[derive(Default)] +pub(super) struct CallResolution { + pub(super) arguments: Option, + pub(super) attach: Option, + pub(super) source: Vec, + pub(super) assign: Vec, +} + +/// A single file a `source()` call brings in: its statically-extracted path and +/// the resolution the scan computed for it (`None` when it didn't resolve). +#[derive(Clone)] +pub(super) struct SourcedFile { + pub(super) path: String, + pub(super) 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 +pub(super) struct ScanBindings<'a, R: ImportsResolver> { + pub(super) 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.scan.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. +/// +/// It's the scan's own flow state, a coarse variant of the walk's use-def map, +/// which isn't built yet. It answers one question, "is this name bound here?", +/// so the scan can tell whether a callee is shadowed at each call and decide +/// whether a call is NSE. It tracks only eager bindings, and it is allowed to +/// stay coarse: `merge()` unions the two sides of an `if`, so that a single +/// branch marks a name as bound. +#[derive(Clone, Default)] +pub(super) struct FlowState { + bound: FxHashSet, +} + +impl FlowState { + /// Whether `name` is bound at the current point. + pub(super) fn is_bound(&self, name: &str) -> bool { + self.bound.contains(name) + } + + /// Save the current state, to rewind to or to seed a child scan unit from. + fn snapshot(&self) -> FlowState { + self.clone() + } + + /// Rewind to `snapshot`, dropping any bindings recorded since it was taken. + fn restore(&mut self, snapshot: FlowState) { + *self = snapshot; + } + + /// Union `snapshot` in, so a name reads as bound here if it was bound on + /// either path. This is the `if`/`else` join. + fn merge(&mut self, snapshot: FlowState) { + self.bound.extend(snapshot.bound); + } + + /// Record `name` as bound from here on. + fn bind(&mut self, name: String) { + self.bound.insert(name); + } + + /// Drop all bindings, to start a fresh scan unit (see `begin_scan()`). + fn clear(&mut self) { + self.bound.clear(); + } +} + +/// Tracks eager `Nested` NSE bodies scanned inline during the scan. +/// +/// An eager `Nested` body like `local()` runs immediately at its call site, so +/// we scan it inline instead of deferring it to the walk. `open` is the stack +/// of bodies being scanned right now, with the innermost on top. +/// `record_owner_name()` routes a binding to the top so names land on the body +/// that owns them. When a descent finishes, its names move to `pending`, keyed +/// by the body's range. +/// +/// `pending` is keyed by range rather than written straight into +/// `bound_names[scope]` because the body's arena scope doesn't exist yet: the +/// walk allocates scopes in preorder, and allocating one mid-scan would break +/// the `Scope::descendants` invariant. The range is the body's pre-arena +/// identity until the walk pushes its scope. +/// +/// Once the walk pushes that scope, it installs the pending names into it +/// instead of re-scanning. It does this before collecting the body, because a +/// lazy child inside (a function or lazy NSE body) runs later than the walk +/// reaches it, so it can reference a binding defined further down this scope. +/// Resolving that name checks whether an enclosing scope binds it +/// (`scope_binds_anywhere()`), and the walk hasn't recorded that binding yet, so +/// the scan-populated bound set has to be complete up front. That's the reason +/// the scan collects bound names ahead of the walk at all. +#[derive(Default)] +pub(super) struct EagerNestedDescent { + pub(super) open: Vec, + pub(super) pending: FxHashMap, +} + +/// All definitions in a scope, collected by the scan pass before the +/// walk. Skips child-scope bodies (nested functions and `Nested` NSE bodies). +/// +/// Keeps each name's earliest binding site in scan order, which is source +/// order within the scope. A name bound several times reads as bound +/// throughout, and this earliest site is what the lazy-shadow diagnostic points +/// at, once `is_lazily_shadowed` has picked the nearest binding ancestor. +pub(super) struct BoundNames { + by_name: FxHashMap, +} + +impl BoundNames { + pub(super) fn new() -> Self { + Self { + by_name: FxHashMap::default(), + } + } + + pub(super) fn binds(&self, name: &str) -> bool { + self.by_name.contains_key(name) + } + + pub(super) fn binding_range(&self, name: &str) -> Option { + self.by_name.get(name).copied() + } + + fn add(&mut self, name: String, range: TextRange) { + self.by_name.entry(name).or_insert(range); + } +} + +/// 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), +} diff --git a/crates/oak_semantic/src/builder/walk.rs b/crates/oak_semantic/src/builder/walk.rs new file mode 100644 index 000000000..26f561a59 --- /dev/null +++ b/crates/oak_semantic/src/builder/walk.rs @@ -0,0 +1,970 @@ +//! The walk pass: the recursive descent that writes the arenas (scopes, +//! symbols, definitions, uses, use-def maps), reusing the scan's decisions. +//! See the module docs on [`super`] for the scan/walk split. + +use aether_syntax::AnyRExpression; +use aether_syntax::AnyRParameterName; +use aether_syntax::RArgumentList; +use aether_syntax::RBinaryExpression; +use aether_syntax::RCall; +use aether_syntax::RExpressionList; +use aether_syntax::RFunctionDefinition; +use aether_syntax::RNamespaceExpression; +use aether_syntax::RParameter; +use aether_syntax::RParameters; +use aether_syntax::RSyntaxKind; +use aether_syntax::RSyntaxNode; +use biome_rowan::AstNode; +use biome_rowan::AstNodeList; +use biome_rowan::AstPtr; +use biome_rowan::AstSeparatedList; +use biome_rowan::SyntaxNodeCast; +use biome_rowan::TextRange; +use biome_rowan::WalkEvent; +use oak_core::syntax_ext::AnyRSelectorExt; +use oak_core::syntax_ext::RIdentifierExt; + +use super::assignment_name; +use super::is_assignment; +use super::is_right_assignment; +use super::is_super_assignment; +use super::scan::SourcedFile; +use super::SemanticIndexBuilder; +use crate::effects::AssignBinding; +use crate::effects::ResolvedArgumentEffect; +use crate::effects::ResolvedArgumentEffects; +use crate::effects::TargetAccess; +use crate::resolver::ImportsResolver; +use crate::semantic_index::Definition; +use crate::semantic_index::DefinitionKind; +use crate::semantic_index::EnclosingSnapshotKey; +use crate::semantic_index::EvalEnv; +use crate::semantic_index::EvalTiming; +use crate::semantic_index::NamespaceAccess; +use crate::semantic_index::NamespaceAccessKind; +use crate::semantic_index::ScopeId; +use crate::semantic_index::ScopeKind; +use crate::semantic_index::SemanticCall; +use crate::semantic_index::SemanticCallKind; +use crate::semantic_index::SymbolFlags; +use crate::semantic_index::SymbolId; +use crate::semantic_index::Use; +use crate::semantic_index::UseId; + +// Traversal + +impl SemanticIndexBuilder { + pub(super) fn walk_expression_list(&mut self, list: &RExpressionList) { + for expr in list.iter() { + self.walk_expression(&expr); + } + } + + fn walk_expression(&mut self, expr: &AnyRExpression) { + match expr { + AnyRExpression::RIdentifier(ident) => { + let name = ident.name_text(); + let range = ident.syntax().text_trimmed_range(); + self.add_use(&name, range); + }, + + AnyRExpression::RDots(dots) => { + self.add_use("...", dots.syntax().text_trimmed_range()); + }, + + AnyRExpression::RDotDotI(ddi) => { + let name = ddi.syntax().text_trimmed().to_string(); + self.add_use(&name, ddi.syntax().text_trimmed_range()); + }, + + AnyRExpression::RFunctionDefinition(func) => { + self.walk_function(func); + }, + + AnyRExpression::RBracedExpressions(braced) => { + self.walk_expression_list(&braced.expressions()); + }, + + AnyRExpression::RBinaryExpression(bin) => { + // `<-`, `=`, `->`, `<<-`, and `->>` are assignments when they appear as + // `RBinaryExpression`. In call arguments, `=` is consumed by + // the parser into `RArgumentNameClause` instead, so it never + // reaches here. + if is_assignment(bin) { + self.walk_assignment(bin); + } else { + let range = bin.syntax().text_trimmed_range(); + let reads_lhs = match self.scan.call_resolutions.get(&range) { + Some(resolution) if !resolution.assign.is_empty() => { + // Pure binding operators such as `x := expr` do not + // read their LHS. `%<>%` is compound and acts like + // `x <- x %>% f()`. + resolution + .assign + .iter() + .any(|binding| binding.target == TargetAccess::ReadWrite) + }, + _ => true, + }; + + if reads_lhs { + if let Ok(lhs) = bin.left() { + self.walk_expression(&lhs); + } + } + if let Ok(rhs) = bin.right() { + self.walk_expression(&rhs); + } + // A `%...%` operator the scan recognized as an assign effect + // emits its binding here, after the operand uses. + self.walk_assign_operator(bin); + } + }, + + // Calls and subsets need explicit handling because argument name + // clauses contain `RIdentifier` nodes that should not be recorded + // as uses. + AnyRExpression::RCall(call) => { + // Record the callee as a use (a no-op for `pkg::fn`) before + // handling NSE. + if let Ok(func) = call.function() { + self.walk_expression(&func); + } + + if let Some(effects) = self.argument_effects(call) { + self.walk_nse_call(call, effects) + } else if let Ok(args) = call.arguments() { + self.walk_arguments(&args.items()); + } + + self.walk_semantic_call(call); + }, + AnyRExpression::RSubset(subset) => { + if let Ok(object) = subset.function() { + self.walk_expression(&object); + } + if let Ok(args) = subset.arguments() { + self.walk_arguments(&args.items()); + } + }, + AnyRExpression::RSubset2(subset) => { + if let Ok(object) = subset.function() { + self.walk_expression(&object); + } + if let Ok(args) = subset.arguments() { + self.walk_arguments(&args.items()); + } + }, + + AnyRExpression::RExtractExpression(extract) => { + // For `x$name` or `x@slot`, collect the object and skip the member + if let Ok(object) = extract.left() { + self.walk_expression(&object); + } + }, + + AnyRExpression::RNamespaceExpression(expr) => { + self.walk_namespace_access(expr); + }, + + AnyRExpression::RForStatement(stmt) => { + // The for variable is always bound (R sets it to NULL for + // empty sequences), so its binding is recorded before the + // snapshot. Assignments inside the body are conditional + // (body may not execute for empty sequences). + if let Ok(variable) = stmt.variable() { + self.add_definition( + &variable.name_text(), + SymbolFlags::IS_BOUND, + DefinitionKind::ForVariable(AstPtr::new(stmt)), + variable.syntax().text_trimmed_range(), + ); + } + if let Ok(sequence) = stmt.sequence() { + self.walk_expression(&sequence); + } + + let pre_loop = self.walk.use_def_maps[self.current_scope].snapshot(); + + if let Ok(body) = stmt.body() { + let first_use = self.walk.uses[self.current_scope].next_id(); + self.walk_expression(&body); + self.walk.use_def_maps[self.current_scope].finish_loop_defs( + &pre_loop, + first_use, + &self.walk.uses[self.current_scope], + ); + } + + self.walk.use_def_maps[self.current_scope].merge(pre_loop); + }, + + AnyRExpression::RIfStatement(stmt) => { + // Condition is always evaluated + if let Ok(condition) = stmt.condition() { + self.walk_expression(&condition); + } + + let pre_if = self.walk.use_def_maps[self.current_scope].snapshot(); + + // If-body (consequence) + if let Ok(consequence) = stmt.consequence() { + self.walk_expression(&consequence); + } + + let post_if = self.walk.use_def_maps[self.current_scope].snapshot(); + self.walk.use_def_maps[self.current_scope].restore(pre_if); + + // Else-body (alternative), if present. If absent, the + // "else path" is just the pre-if state we restored to. + if let Some(else_clause) = stmt.else_clause() { + if let Ok(alternative) = else_clause.alternative() { + self.walk_expression(&alternative); + } + } + + // After: definitions from both branches are live + self.walk.use_def_maps[self.current_scope].merge(post_if); + }, + + AnyRExpression::RWhileStatement(stmt) => { + if let Ok(condition) = stmt.condition() { + self.walk_expression(&condition); + } + + let pre_loop = self.walk.use_def_maps[self.current_scope].snapshot(); + + if let Ok(body) = stmt.body() { + let first_use = self.walk.uses[self.current_scope].next_id(); + self.walk_expression(&body); + self.walk.use_def_maps[self.current_scope].finish_loop_defs( + &pre_loop, + first_use, + &self.walk.uses[self.current_scope], + ); + } + + // Body may not execute + self.walk.use_def_maps[self.current_scope].merge(pre_loop); + }, + + AnyRExpression::RRepeatStatement(stmt) => { + // Body always executes at least once, so no merge with pre-loop state. + if let Ok(body) = stmt.body() { + let pre_loop = self.walk.use_def_maps[self.current_scope].snapshot(); + let first_use = self.walk.uses[self.current_scope].next_id(); + self.walk_expression(&body); + self.walk.use_def_maps[self.current_scope].finish_loop_defs( + &pre_loop, + first_use, + &self.walk.uses[self.current_scope], + ); + } + }, + + AnyRExpression::RBogusExpression(_) => {}, + + // Generic fallback: walk over descendant nodes and collect their + // `AnyRExpression` children, letting `walk_expression` + // handle their contents. This covers `RUnaryExpression`, + // `RParenthesizedExpression`, `RReturnExpression`, literals, and + // any future expression types without needing explicit arms. + // + // NOTE: This also means that identifiers and assignments inside + // quoting constructs (`~`, `quote()`, `bquote()`) are recorded as + // uses and bindings. Refining this requires special-casing these + // forms, which we defer as future work. + // + // Once quoting is handled, `declare()` and `~declare()` will need + // explicit treatment: its arguments are quoted (not evaluated) but + // should still be inspected for directives like `source()`. + // Currently this works by accident because the generic traversal is + // transparent to both `declare()` and `~`. + _ => { + self.walk_descendants(expr.syntax()); + }, + } + } + + // Walk descendant nodes of `expr`, collecting the outermost + // `AnyRExpression` nodes and recursing into them via `walk_expression`. + // This skips intermediate wrapper nodes (e.g. `RElseClause`) while + // correctly stopping at expression boundaries. + fn walk_descendants(&mut self, node: &RSyntaxNode) { + let mut preorder = node.preorder(); + + // Skip the root node itself + preorder.next(); + + while let Some(event) = preorder.next() { + let WalkEvent::Enter(node) = event else { + continue; + }; + if let Some(expr) = node.cast::() { + self.walk_expression(&expr); + preorder.skip_subtree(); + } + } + } + + fn walk_function(&mut self, fun: &RFunctionDefinition) { + let scope = self.push_scope(ScopeKind::Function, fun.syntax().text_trimmed_range()); + + if let Ok(params) = fun.parameters() { + // Scan the default values before collecting them. R binds all + // formals into the frame at once, so a default sees every parameter + // name regardless of position: `function(local, b = local(...))` is + // not NSE. So we seed the whole formal set into `flow_state` + // up front rather than flow-ordered, then scan each default. + self.begin_scan(); + self.scan_parameter_defaults(¶ms); + + // `walk_parameters` adds the parameter definitions and walks + // each default in source order, finding the NSE decisions the scan + // above recorded. + self.walk_parameters(¶ms); + } + if let Ok(body) = fun.body() { + self.begin_scan(); + self.scan_expression(&body); + self.walk_expression(&body); + } + + self.pop_scope(scope); + } + + fn walk_parameters(&mut self, params: &RParameters) { + for param in params.items().iter() { + let Ok(param) = param else { continue }; + self.walk_parameter(¶m); + } + } + + fn walk_parameter(&mut self, param: &RParameter) { + let flags = SymbolFlags::IS_BOUND.union(SymbolFlags::IS_PARAMETER); + + if let Ok(name) = param.name() { + match &name { + AnyRParameterName::RIdentifier(ident) => { + self.add_definition( + &ident.name_text(), + flags, + DefinitionKind::Parameter(AstPtr::new(param)), + ident.syntax().text_trimmed_range(), + ); + }, + AnyRParameterName::RDots(dots) => { + self.add_definition( + "...", + flags, + DefinitionKind::Parameter(AstPtr::new(param)), + dots.syntax().text_trimmed_range(), + ); + }, + AnyRParameterName::RDotDotI(ddi) => { + self.add_definition( + &ddi.syntax().text_trimmed().to_string(), + flags, + DefinitionKind::Parameter(AstPtr::new(param)), + ddi.syntax().text_trimmed_range(), + ); + }, + } + } + + if let Some(default) = param.default() { + if let Ok(value) = default.value() { + self.walk_expression(&value); + } + } + } + + fn walk_assignment(&mut self, op: &RBinaryExpression) { + let right = is_right_assignment(op); + let super_assign = is_super_assignment(op); + + // Value side first to record uses before the binding. The uses + // might refer to the same symbol as the new binding, but refer + // to a different place (previous binding). + let value = if right { op.left() } else { op.right() }; + if let Ok(value) = value { + self.walk_expression(&value); + } + + let target = if right { op.right() } else { op.left() }; + let Ok(target) = target else { return }; + + let Some((name, range)) = assignment_name(&target) else { + // Complex target (`x$foo <- rhs`, `x[1] <- rhs`, etc.) does + // not represent a binding. We recurse for uses. + self.walk_expression(&target); + return; + }; + + if super_assign { + self.add_super_definition( + &name, + DefinitionKind::SuperAssignment(AstPtr::new(op)), + range, + ); + } else { + self.add_definition( + &name, + SymbolFlags::IS_BOUND, + DefinitionKind::Assignment(AstPtr::new(op)), + range, + ); + } + } + + fn walk_arguments(&mut self, args: &RArgumentList) { + for item in args.iter() { + let Ok(arg) = item else { continue }; + if let Some(value) = arg.value() { + self.walk_expression(&value); + } + } + } + + fn walk_namespace_access(&mut self, expr: &RNamespaceExpression) { + let Ok(operator) = expr.operator() else { + return; + }; + let kind = match operator.kind() { + RSyntaxKind::COLON2 => NamespaceAccessKind::Export, + RSyntaxKind::COLON3 => NamespaceAccessKind::Internal, + _ => return, + }; + let Some(package) = expr + .left() + .ok() + .and_then(|selector| selector.identifier_text()) + else { + return; + }; + let Some(symbol) = expr + .right() + .ok() + .and_then(|selector| selector.identifier_text()) + else { + return; + }; + let offset = expr.syntax().text_trimmed_range().start(); + self.walk + .namespace_accesses + .push(NamespaceAccess::new(package, symbol, kind, offset)); + } + + // Handle effects recognised by the scan and emit semantic calls + fn walk_semantic_call(&mut self, call: &aether_syntax::RCall) { + let range = call.syntax().text_trimmed_range(); + if let Some(package) = self + .scan + .call_resolutions + .get(&range) + .and_then(|resolution| resolution.attach.clone()) + { + self.walk_attach_call(call, package); + } + + if self + .scan + .call_resolutions + .get(&range) + .is_some_and(|resolution| !resolution.source.is_empty()) + { + self.walk_source_call(call); + } + + if self + .scan + .call_resolutions + .get(&range) + .is_some_and(|resolution| !resolution.assign.is_empty()) + { + self.walk_assign_call(call); + } + } + + fn walk_attach_call(&mut self, call: &aether_syntax::RCall, package: String) { + // At runtime, `library()` always modifies the global search path + // regardless of where it's called. Statically, we scope the call to + // `self.current_scope`: at file scope it's visible everywhere + // (sequential execution is guaranteed), but inside a function it's + // only visible within that function and its children, since the + // function might never be called. Same reasoning as `source()` calls. + let call_offset = call.syntax().text_trimmed_range().start(); + self.walk.semantic_calls.push(SemanticCall { + kind: SemanticCallKind::Attach { package }, + offset: call_offset, + scope: self.current_scope, + }); + } + + // `source("file.R")` creates `DefinitionKind::Import` forwarding + // bindings in the current scope for each top-level name exported by + // the target file. These participate in the use-def map like normal + // definitions (shadowing, ordering), but goto-definition chases + // through them via `resolve_definition` to reach the actual origin. + // + // The `local` argument is inspected only to bail: if it's set to + // something other than TRUE/FALSE (e.g., an environment), the call + // isn't statically analyzable and we skip it. + // + // TODO: In nested scopes, `local = FALSE` technically targets the + // global environment. We currently inject into the calling scope + // regardless to keep the sourcing mechanism simple. A future diagnostic + // should suggest `local = TRUE` in nested contexts. + fn walk_source_call(&mut self, call: &aether_syntax::RCall) { + let range = call.syntax().text_trimmed_range(); + let call_offset = range.start(); + + // Read back what the scan cached: the sourced files, each with its + // resolution. The scan is the single point that extracts the paths and + // consults `resolve_source`, so the walk never re-parses or re-resolves. + let sourced = match self.scan.call_resolutions.get(&range) { + Some(resolution) => resolution.source.clone(), + None => return, + }; + + for SourcedFile { path, resolution } in sourced { + // Record every sourced file, independent of whether it resolved. + // `resolved` pins the canonical URL when resolution succeeded so + // reflective queries (diagnostics for unresolved `source()`, + // file-dependency views) read the outcome without re-resolving. + let resolved = resolution.as_ref().map(|r| r.url.clone()); + self.walk.semantic_calls.push(SemanticCall { + kind: SemanticCallKind::Source { path, resolved }, + offset: call_offset, + scope: self.current_scope, + }); + + let Some(resolution) = resolution else { + continue; + }; + + let file = resolution.url; + + for name in resolution.names { + // Empty range: R's `source()` imports names implicitly (unlike + // Python's `from x import y` where `y` appears in the text). + // There's no text span to assign to these definitions. + let name_range = TextRange::empty(call_offset); + + self.add_definition( + &name, + SymbolFlags::IS_BOUND, + DefinitionKind::Import { + call: AstPtr::new(call), + file: file.clone(), + name: name.clone(), + }, + name_range, + ); + } + + // `library()` calls inside the sourced file attach packages to R's + // global search path at runtime, the same as a `library()` written + // here directly would. Emit them as `Attach` semantic calls scoped + // to this `source()`'s offset so scope-layer composition treats + // them identically to local `library()` calls. + for pkg in resolution.packages { + self.walk.semantic_calls.push(SemanticCall { + kind: SemanticCallKind::Attach { package: pkg }, + offset: call_offset, + scope: self.current_scope, + }); + } + } + } + + // `assign("x", value)` binds `x` in the current scope, the same as `x <- + // value` would. We record a `DefinitionKind::Assign` def so it feeds the + // use-def map, `exports()`, and goto exactly like a syntactic assignment. + // The name is not chased to its value, so an `assign("f", local)` def + // carries no NSE, just like `f <- local`. + fn walk_assign_call(&mut self, call: &aether_syntax::RCall) { + let range = call.syntax().text_trimmed_range(); + + // Read back the bindings the scan extracted (their presence is what the + // caller checked before dispatching here). + let bindings = match self.scan.call_resolutions.get(&range) { + Some(resolution) => resolution.assign.clone(), + None => return, + }; + + self.add_assign_definitions(&AnyRExpression::RCall(call.clone()), bindings); + } + + /// Emit the `Assign` definition for a binding operator (e.g. `x %<>% f()`) the + /// scan recognized, after its operands were collected as uses. + fn walk_assign_operator(&mut self, bin: &RBinaryExpression) { + let range = bin.syntax().text_trimmed_range(); + let bindings = match self.scan.call_resolutions.get(&range) { + Some(resolution) if !resolution.assign.is_empty() => resolution.assign.clone(), + _ => return, + }; + + self.add_assign_definitions(&AnyRExpression::RBinaryExpression(bin.clone()), bindings); + } + + /// Process a call the scan pass decided is NSE, using the resolved argument + /// scoping the scan cached. Handle each scoped argument, pushing NSE scopes + /// inline. + fn walk_nse_call(&mut self, call: &RCall, effects: ResolvedArgumentEffects) { + let Ok(args) = call.arguments() else { + return; + }; + let items = args.items(); + + for (i, item) in items.iter().enumerate() { + let Ok(arg) = item else { continue }; + let Some(value) = arg.value() else { continue }; + + let Some(effect) = &effects[i] else { + self.walk_expression(&value); + continue; + }; + match effect { + ResolvedArgumentEffect::EvalQ { env, timing } => { + self.walk_nse_argument(*env, *timing, &value) + }, + // Quoted argument: only the unquote holes are live. + ResolvedArgumentEffect::Quote { holes } => { + for hole in holes { + self.walk_expression(hole); + } + }, + } + } + } + + /// Walk a single NSE argument body, pushing a scope when appropriate. + /// + /// `Current + Eager` stays in the current scope. `Nested + Eager` was + /// already scanned by the descent, so we install its pending names and only + /// walk. The remaining lazy bodies are their own scan units that we scan + /// here on entry. + fn walk_nse_argument(&mut self, env: EvalEnv, timing: EvalTiming, value: &AnyRExpression) { + match (env, timing) { + // Calls like `evalq()` + (EvalEnv::Current, EvalTiming::Eager) => { + self.walk_expression(value); + }, + + // Calls like `local()` + (EvalEnv::Nested, EvalTiming::Eager) => { + let range = value.syntax().text_trimmed_range(); + let kind = ScopeKind::Nse(EvalEnv::Nested, EvalTiming::Eager); + let scope = self.push_scope(kind, range); + + // Install the pending names the descent recorded for this body, + // before collecting so lazy children inside can see them via + // `scope_binds_anywhere()`. + match self.scan.eager_descent.pending.remove(&range) { + Some(bound) => self.scan.bound_names[scope] = bound, + None => { + // An eager NSE scope is reachable only through the scan + // unit that descended into it, so the pending set must + // exist. If not this is a builder bug. In release + // builds we still scan the body here so the walk can + // proceed. This fallback runs with an empty eager + // environment and its shadow decisions are more + // degraded than a real lazy unit's. + stdext::debug_panic!( + "Missing pending bound names for eager NSE body at {range:?}" + ); + self.begin_scan(); + self.scan_expression(value); + }, + } + + self.walk_expression(value); + self.pop_scope(scope); + }, + + (env, timing) => { + let kind = ScopeKind::Nse(env, timing); + let scope = self.push_scope(kind, value.syntax().text_trimmed_range()); + + // Scan the child body before walking it. A `Current + Lazy` + // scope routes its defs to the owner and holds no bound names of its + // own, which `record_binding` handles; the scan still runs to + // record the body's NSE decisions in the child's flow context. + self.begin_scan(); + self.scan_expression(value); + self.walk_expression(value); + self.pop_scope(scope); + }, + } + } + + fn argument_effects(&self, call: &RCall) -> Option { + self.scan + .call_resolutions + .get(&call.syntax().text_trimmed_range()) + .and_then(|resolution| resolution.arguments.clone()) + } +} + +// State management + +impl SemanticIndexBuilder { + fn add_definition( + &mut self, + name: &str, + flags: SymbolFlags, + kind: DefinitionKind, + range: TextRange, + ) { + // `Nse(Current, Lazy)` scopes don't own any definitions. We add the + // definitions to the real enclosing owner scope. Note that `Current + + // Eager` never reaches here because it doesn't push a scope. + if matches!( + self.scopes[self.current_scope].kind, + ScopeKind::Nse(EvalEnv::Current, EvalTiming::Lazy) + ) { + self.add_definition_to_owner(name, flags, kind, range); + return; + } + + let symbol_id = self.walk.symbol_tables[self.current_scope].intern(name, flags); + let def_id = self.walk.definitions[self.current_scope].push(Definition { + symbol: symbol_id, + kind, + range, + }); + self.walk.use_def_maps[self.current_scope].ensure_symbol(symbol_id); + self.walk.use_def_maps[self.current_scope].record_definition(symbol_id, def_id); + } + + /// Route a definition from a `Current + Lazy` scope to the scope that + /// owns it. That's the nearest ancestor scope which holds its own + /// definitions. A chain of `Current + Lazy` scopes (e.g. `on_load()` nested + /// in `on_load()`) is skipped: each one routes to its own owner, so they + /// all land in the same outer scope. + fn add_definition_to_owner( + &mut self, + name: &str, + flags: SymbolFlags, + kind: DefinitionKind, + range: TextRange, + ) { + let Some(target_scope) = self.definition_owner() else { + stdext::debug_panic!("Current + Lazy scope has no parent"); + return; + }; + + let symbol_id = self.walk.symbol_tables[target_scope].intern(name, flags); + let def_id = self.walk.definitions[target_scope].push(Definition { + symbol: symbol_id, + kind, + range, + }); + + self.walk.use_def_maps[target_scope].ensure_symbol(symbol_id); + + // Deferred: the body executes at an unknown later time, so the + // definition shouldn't shadow what's already live. This is the same + // mechanism as `<<-`. + // + // Known imprecision: the deferred def is visible to ALL uses in + // the parent scope (with `may_be_unbound: true`), including + // file-level uses that run before the lazy body executes. Ideally + // these defs would only be reachable from lazy scopes (functions), + // not from eager/file-level code. + self.walk.use_def_maps[target_scope].record_deferred_definition(symbol_id, def_id); + } + + // Super-assignment is lexically in the current scope but binds in an + // ancestor. We record the definition in the current scope and append + // it to the target scope's use-def map (without shadowing prior + // definitions). + // + // R's `<<-` walks up the environment chain from the parent, targeting + // the first scope where the symbol is already bound. If no binding is + // found, it assigns in the global (file) scope. + fn add_super_definition(&mut self, name: &str, kind: DefinitionKind, range: TextRange) { + let Some(parent) = self.scopes[self.current_scope].parent else { + // A top-level `<<-` has no enclosing frame to walk to, so it binds + // in the file scope it already sits in. The marker scope and the + // binding scope coincide, so record one definition carrying both + // flags rather than pushing two coinciding entries. + let symbol_id = self.walk.symbol_tables[self.current_scope].intern( + name, + SymbolFlags::IS_SUPER_BOUND.union(SymbolFlags::IS_BOUND), + ); + let def_id = self.walk.definitions[self.current_scope].push(Definition { + symbol: symbol_id, + kind, + range, + }); + self.walk.use_def_maps[self.current_scope].ensure_symbol(symbol_id); + self.walk.use_def_maps[self.current_scope] + .record_deferred_definition(symbol_id, def_id); + return; + }; + + let target_scope = self.resolve_super_target(name, parent); + + let symbol_id = + self.walk.symbol_tables[self.current_scope].intern(name, SymbolFlags::IS_SUPER_BOUND); + self.walk.definitions[self.current_scope].push(Definition { + symbol: symbol_id, + kind: kind.clone(), + range, + }); + + let target_symbol = + self.walk.symbol_tables[target_scope].intern(name, SymbolFlags::IS_BOUND); + let target_def_id = self.walk.definitions[target_scope].push(Definition { + symbol: target_symbol, + kind, + range, + }); + self.walk.use_def_maps[target_scope].ensure_symbol(target_symbol); + self.walk.use_def_maps[target_scope] + .record_deferred_definition(target_symbol, target_def_id); + } + + fn add_assign_definitions(&mut self, node: &AnyRExpression, bindings: Vec) { + for binding in bindings { + // The def's own range is the name token, captured at scan time, so a + // cursor on the name at the definition site hit-tests to it, the same + // as a syntactic `<-` binding. + let name_range = binding.name_expr.text_trimmed_range(); + let name = binding.name_expr.as_ptr().clone(); + self.add_definition( + &binding.name, + SymbolFlags::IS_BOUND, + DefinitionKind::Assign { + node: AstPtr::new(node), + name, + value: binding.value_expr, + }, + name_range, + ); + } + } + + // Walk up from `start` to the first scope where `name` already has + // `IS_BOUND`. Returns that scope, or the file scope if no binding is found + // (mirroring R's assignment to the global environment). Reaching the file + // scope unbound ends the walk there, so its `parent` of `None` is the + // natural terminator. + fn resolve_super_target(&self, name: &str, start: ScopeId) -> ScopeId { + let mut scope = start; + loop { + if let Some(id) = self.walk.symbol_tables[scope].id(name) { + if self.walk.symbol_tables[scope] + .symbol(id) + .flags() + .contains(SymbolFlags::IS_BOUND) + { + return scope; + } + } + let Some(parent) = self.scopes[scope].parent else { + return scope; + }; + scope = parent; + } + } + + fn add_use(&mut self, name: &str, range: TextRange) { + let symbol_id = + self.walk.symbol_tables[self.current_scope].intern(name, SymbolFlags::IS_USED); + let use_id = self.walk.uses[self.current_scope].push(Use { + symbol: symbol_id, + range, + }); + self.walk.use_def_maps[self.current_scope].ensure_symbol(symbol_id); + self.walk.use_def_maps[self.current_scope].record_use(symbol_id, use_id); + + // Associate free variables with the enclosing snapshot where the + // variable is defined + if self.walk.use_def_maps[self.current_scope].is_may_be_unbound(symbol_id) { + self.register_enclosing_snapshot(name, symbol_id, use_id); + } + } + + fn register_enclosing_snapshot(&mut self, name: &str, nested_symbol: SymbolId, use_id: UseId) { + // We're looking for a parent definition for this scope's free variable + // so start from parent + let Some(mut current_scope) = self.scopes[self.current_scope].parent else { + return; + }; + + // Eager vs lazy snapshot for this free variable. Eager snapshots are + // precise, lazy ones over-approximate. For instance in: + // + // ``` + // x <- 1 + // local({ x }) + // x <- 2 + // ``` + // + // The eager body in `local()` captures `x <- 1` but not `x <- 2`. If + // the body was inside a lazy context like `function()` instead, the use + // of `x` could run at any time and we'd fall back to the accumulated + // union `{1, 2}`, which is an over-approximation. + // + // A precise enclosing snapshot requires eagerness throughout, which we + // track with `all_eager`. + let mut all_eager = !self.scopes[self.current_scope].kind.is_lazy(); + + loop { + if self.scope_binds_anywhere(current_scope, name) { + // Intern with empty flags: we just need a stable `SymbolId` for + // the lookup key. If the symbol was found via its `IS_BOUND` + // flag, it already exists. If found via pre-scan only, the later + // `add_definition()` call during the full walk will set `IS_BOUND`. + let enclosing_symbol_id = + self.walk.symbol_tables[current_scope].intern(name, SymbolFlags::empty()); + self.walk.use_def_maps[current_scope].ensure_symbol(enclosing_symbol_id); + + let entry = if all_eager { + // Eager: a fresh point-in-time snapshot per use, no dedup and + // no watcher. Two uses at different points in the body can + // capture different enclosing states (e.g. either side of a + // `<<-`), so they can't share. + let snapshot_id = self.walk.use_def_maps[current_scope] + .register_eager_snapshot(enclosing_symbol_id); + (current_scope, snapshot_id) + } else { + // Lazy: every use of this symbol resolves to the same + // growing snapshot, so dedup on (nested scope, nested symbol) + // and reuse it across uses. + let dedup_key = (self.current_scope, nested_symbol); + + if let Some(&entry) = self.walk.lazy_snapshots.get(&dedup_key) { + entry + } else { + let snapshot_id = self.walk.use_def_maps[current_scope] + .register_lazy_snapshot(enclosing_symbol_id); + let entry = (current_scope, snapshot_id); + self.walk.lazy_snapshots.insert(dedup_key, entry); + entry + } + }; + + let use_key = EnclosingSnapshotKey { + nested_scope: self.current_scope, + nested_use: use_id, + }; + self.walk.enclosing_snapshots.insert(use_key, entry); + + return; + } + + if self.scopes[current_scope].kind.is_lazy() { + all_eager = false; + } + + let Some(parent) = self.scopes[current_scope].parent else { + return; + }; + current_scope = parent; + } + } +}