From f0e8fa949bf6f54434f59704ebec519836a3d8bb Mon Sep 17 00:00:00 2001 From: Lionel Henry Date: Fri, 10 Jul 2026 14:16:37 +0200 Subject: [PATCH] Detect `Source` effects --- crates/oak_semantic/src/builder.rs | 219 +++++++----------- .../oak_semantic/src/builder/builder_nse.rs | 37 ++- crates/oak_semantic/src/effects.rs | 83 ++++++- crates/oak_semantic/src/effects_registry.rs | 21 ++ .../oak_semantic/tests/integration/builder.rs | 199 ++++++++++++++-- 5 files changed, 390 insertions(+), 169 deletions(-) diff --git a/crates/oak_semantic/src/builder.rs b/crates/oak_semantic/src/builder.rs index dc1b1d26c..9858833a6 100644 --- a/crates/oak_semantic/src/builder.rs +++ b/crates/oak_semantic/src/builder.rs @@ -27,7 +27,6 @@ use std::sync::Arc; -use aether_syntax::AnyRArgumentName; use aether_syntax::AnyRExpression; use aether_syntax::AnyRParameterName; use aether_syntax::AnyRValue; @@ -651,7 +650,6 @@ impl SemanticIndexBuilder { self.scan_expression(&func); } self.scan_call(call); - self.scan_semantic_call(call); }, AnyRExpression::RForStatement(stmt) => { @@ -751,44 +749,26 @@ impl SemanticIndexBuilder { } } - /// Scan-time analog of [`collect_semantic_call`]. + /// Resolve one sourced `path`, bind the names it brings in, and return its + /// resolution for the caller to cache. /// - /// Only `source()` needs handling here. Its injected bindings shadow NSE - /// callees, and the walk injects them too late for a later call in the same - /// scope to see. `library()`/`require()` attaches are recognized on the - /// resolve path in [`scan_call`], not here. + /// 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 - /// - /// [`collect_semantic_call`]: Self::collect_semantic_call - fn scan_semantic_call(&mut self, call: &aether_syntax::RCall) { - let Ok(AnyRExpression::RIdentifier(ident)) = call.function() else { - return; - }; - if ident.name_text() == "source" { - self.scan_source_call(call); - } - } - - /// Resolve a `source()` call once, cache it, and bind the sourced names. - /// - /// 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`). The resolution is cached by call - /// range so the walk reuses it instead of consulting the resolver again. - fn scan_source_call(&mut self, call: &aether_syntax::RCall) { - let Some(path) = self.parse_source_path(call) else { - return; - }; - let Some(resolution) = self.resolver.resolve_source(&path) else { - return; - }; + 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. - let range = call.syntax().text_trimmed_range(); for name in &resolution.names { - self.record_binding(name.clone(), range); + self.record_binding(name.clone(), source_range); } // A `source()`-forwarded `library()` attaches at this call's flow @@ -800,7 +780,7 @@ impl SemanticIndexBuilder { } } - self.call_resolutions.entry(range).or_default().source = Some(resolution); + Some(resolution) } /// Record a binding in the scan's flow state. @@ -1241,12 +1221,15 @@ impl SemanticIndexBuilder { self.record_attach(call, package); } - // Source is still recognized by callee name here, reading the resolution - // the scan cached. - if let Ok(AnyRExpression::RIdentifier(ident)) = call.function() { - if ident.name_text() == "source" { - self.collect_source_call(call); - } + // 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); } } @@ -1284,115 +1267,66 @@ impl SemanticIndexBuilder { // 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 Some(path) = self.parse_source_path(call) else { - return; - }; - let range = call.syntax().text_trimmed_range(); let call_offset = range.start(); - // Read the resolution the scan already computed. The scan is the - // single point that consults `resolve_source`, so the walk never - // re-resolves. A cache miss means the scan bailed or the resolver - // returned `None`, both of which record the call with `resolved: None`. - let resolution = self - .call_resolutions - .get(&range) - .and_then(|resolution| resolution.source.clone()); - - // Record every `source()` call site, independent of whether the - // resolution was successful. `resolved` pins the canonical URL when - // resolution succeeded so reflective queries (diagnostics for - // unresolved `source()`, file-dependency views) read the outcome - // without re-resolving. - self.semantic_calls.push(SemanticCall { - kind: SemanticCallKind::Source { - path: path.clone(), - resolved: resolution.as_ref().map(|r| r.url.clone()), - }, - offset: call_offset, - scope: self.current_scope, - }); - - let Some(resolution) = resolution else { - return; + // 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, }; - 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 range = TextRange::empty(call_offset); - - self.add_definition( - &name, - SymbolFlags::IS_BOUND, - DefinitionKind::Import { - call: AstPtr::new(call), - file: file.clone(), - name: name.clone(), - }, - 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 { + 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::Attach { package: pkg }, + kind: SemanticCallKind::Source { path, resolved }, offset: call_offset, scope: self.current_scope, }); - } - } - /// Parse the file path out of a `source("path")` call. - /// - /// Shared by the scan and the walk so they agree on which calls are - /// statically analyzable. Returns `None` when there's no positional path, - /// or when `local =` is set to something other than TRUE/FALSE (an - /// environment or a non-literal expression we can't follow). - fn parse_source_path(&self, call: &aether_syntax::RCall) -> Option { - let args = call.arguments().ok()?; - - let mut path: Option = None; + let Some(resolution) = resolution else { + continue; + }; - for item in args.items().iter() { - let Ok(arg) = item 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, + ); + } - if let Some(name_clause) = arg.name_clause() { - let Ok(AnyRArgumentName::RIdentifier(name_ident)) = name_clause.name() else { - continue; - }; - if name_ident.name_text() == "local" { - if let Some(value) = arg.value() { - match value { - // TRUE/FALSE are fine, we resolve uniformly. For - // the FALSE in nested context case, we'll emit a - // diagnostic. - AnyRExpression::RTrueExpression(_) | - AnyRExpression::RFalseExpression(_) => {}, - // Anything else (environment, non-statically - // resolvable expression) means we bail. - _ => return None, - } - } - } - } else if path.is_none() { - // First positional argument: the file path - if let Some(AnyRExpression::AnyRValue(AnyRValue::RStringValue(s))) = arg.value() { - path = s.string_text(); - } + // `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, + }); } } - - path } fn finish(mut self) -> SemanticIndex { @@ -1450,17 +1384,24 @@ impl SemanticIndexBuilder { /// /// - `arguments`: the NSE effect the call resolved to, filled in flow order. `None` /// means "not NSE". -/// - `source`: the resolution of a `source()` call. The scan fills it once -/// (consulting `resolve_source`), the walk reads it back, so the resolver is -/// queried exactly once per `source()` call site. /// - `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. #[derive(Default)] struct CallResolution { arguments: Option, - source: Option, attach: Option, + source: 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, } /// The scan's flow-precise binding state: which names are bound at the current diff --git a/crates/oak_semantic/src/builder/builder_nse.rs b/crates/oak_semantic/src/builder/builder_nse.rs index 43ad2392b..221d6ddba 100644 --- a/crates/oak_semantic/src/builder/builder_nse.rs +++ b/crates/oak_semantic/src/builder/builder_nse.rs @@ -13,6 +13,7 @@ use super::is_right_assignment; use super::is_super_assignment; use super::BoundNames; use super::SemanticIndexBuilder; +use super::SourcedFile; use crate::effects::Argument; use crate::effects::CallContext; use crate::effects::Effects; @@ -26,8 +27,9 @@ use crate::semantic_index::ScopeKind; use crate::semantic_index::SemanticDiagnostic; impl SemanticIndexBuilder { - /// Scan a call for effects (NSE scopes, attaches) and record its decisions - /// for the walk to reuse. The callee is resolved once through [`resolve_effects`]. + /// Scan a call for effects (NSE scopes, attaches, sources) 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 @@ -36,9 +38,9 @@ impl SemanticIndexBuilder { /// 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 (nse_args, attach) = match self.resolve_effects(call) { - Some(effects) => (effects.arguments, effects.attach), - None => (None, None), + let (nse_args, attach, source) = match self.resolve_effects(call) { + Some(effects) => (effects.arguments, effects.attach, effects.source), + None => (None, None, None), }; if let Some(package) = attach { @@ -51,6 +53,22 @@ impl SemanticIndexBuilder { } } + // 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 }); + } + } + let Some(nse_args) = nse_args else { if let Ok(args) = call.arguments() { for item in args.items().iter() { @@ -235,8 +253,15 @@ impl SemanticIndexBuilder { let attach = handlers .attach .and_then(|handler| handler.resolve(call, &ctx)); + let source = handlers + .source + .and_then(|handler| handler.resolve(call, &ctx)); - Some(Effects { arguments, attach }) + Some(Effects { + arguments, + attach, + source, + }) } /// Resolve a call's callee to its [`EffectsHandlers`] (NSE, attach, ...). diff --git a/crates/oak_semantic/src/effects.rs b/crates/oak_semantic/src/effects.rs index a06c90287..5a1e0e603 100644 --- a/crates/oak_semantic/src/effects.rs +++ b/crates/oak_semantic/src/effects.rs @@ -13,8 +13,13 @@ use crate::semantic_index::NseTiming; /// Effects of a call, resolved against the call site. #[derive(Debug, Clone, Default)] pub struct Effects { + /// Evaluate arguments in non-standard fashion pub arguments: Option, + /// Attach a package pub attach: Option, + /// Source one or more files. A vector so a collation-style callee can name + /// several; base `source` resolves to one. + pub source: Option>, } /// The handlers that compute a function's effects. @@ -22,12 +27,12 @@ pub struct Effects { pub struct EffectsHandlers { pub arguments: Option<&'static dyn EffectHandler>, pub attach: Option<&'static dyn EffectHandler>, + pub source: Option<&'static dyn EffectHandler>>, } /// Resolver for an effect of a call. /// -/// The single interface behind every effect kind (NSE, attach, and `source` -/// later). +/// The single interface behind every effect kind (NSE, attach, source). /// /// Handlers are contributed statically for now (a `&'static dyn` in the /// registry), so the trait is `Sync`, which every registry `static` needs. @@ -99,6 +104,16 @@ impl CallContext { matched } + + /// Statically evaluate an argument's value expression to a string. `None` + /// when it's dynamic. + pub fn resolve_static_string(&self, value: &AnyRExpression) -> Option { + match value { + AnyRExpression::AnyRValue(AnyRValue::RStringValue(s)) => s.string_text(), + // Static resolution of expressions is not implemented yet + _ => None, + } + } } /// A formal a handler wants to locate in a call, by name and by its position in @@ -190,6 +205,70 @@ impl EffectHandler for AttachAnnotation { } } +/// Declares how a source function (`source()`) names the file it reads, and +/// serves as the default [`EffectHandler`] for it by pulling that path out of a +/// call. +#[derive(Debug, Clone, Copy)] +pub struct SourceAnnotation { + /// Which positional argument holds the path, counting only unnamed + /// arguments (0 for base `source`). Other source-like functions may put the + /// path elsewhere, so it's configured per entry rather than assumed. + pub position: usize, +} + +impl EffectHandler for SourceAnnotation { + type Output = Vec; + + fn resolve(&self, call: &RCall, ctx: &CallContext) -> Option> { + let args = call.arguments().ok()?; + + // The path is matched positionally among unnamed arguments rather than + // through [`CallContext::match_arguments`], for two reasons. We need to + // inspect the `local =` value to bail on non-static calls, which + // argument matching doesn't do. And counting unnamed arguments is robust + // to a named argument coming first (e.g. `source(echo = TRUE, "x.R")`), + // which the call-position matching isn't yet. A named `file =` therefore + // isn't recognized today. + let mut path: Option = None; + let mut positional = 0; + + for item in args.items().iter() { + let Ok(arg) = item else { continue }; + + if let Some(name_clause) = arg.name_clause() { + let Ok(AnyRArgumentName::RIdentifier(name_ident)) = name_clause.name() else { + continue; + }; + if name_ident.name_text() == "local" { + if let Some(value) = arg.value() { + match value { + // TRUE/FALSE are fine, we resolve uniformly. For + // the FALSE in nested context case, we'll emit a + // diagnostic. + AnyRExpression::RTrueExpression(_) | + AnyRExpression::RFalseExpression(_) => {}, + // Anything else (environment, non-statically + // resolvable expression) means the call isn't + // statically analyzable, so it's not recognized. + _ => return None, + } + } + } + continue; + } + + if positional == self.position { + path = arg + .value() + .and_then(|value| ctx.resolve_static_string(&value)); + } + positional += 1; + } + + path.map(|resolved| vec![resolved]) + } +} + /// Match a named argument against `formals`. Returns the index of the matched /// formal. /// diff --git a/crates/oak_semantic/src/effects_registry.rs b/crates/oak_semantic/src/effects_registry.rs index d56791726..e3dd8ad18 100644 --- a/crates/oak_semantic/src/effects_registry.rs +++ b/crates/oak_semantic/src/effects_registry.rs @@ -2,6 +2,7 @@ use crate::effects::Argument; use crate::effects::ArgumentsAnnotation; use crate::effects::AttachAnnotation; use crate::effects::EffectsHandlers; +use crate::effects::SourceAnnotation; use crate::semantic_index::NseScope::Current; use crate::semantic_index::NseScope::Nested; use crate::semantic_index::NseTiming::Eager; @@ -48,6 +49,7 @@ macro_rules! nse { }),+], }), attach: None, + source: None, }, } }; @@ -64,6 +66,23 @@ macro_rules! attach { attach: Some(&AttachAnnotation { character_only: $character_only, }), + source: None, + }, + } + }; +} + +/// A source entry: `(path-argument position)`. The function reads and evaluates +/// another file, injecting its top-level names into the caller. +macro_rules! source { + ($pkg:literal, $func:literal, $pos:literal) => { + Entry { + package: $pkg, + function: $func, + effects: EffectsHandlers { + arguments: None, + attach: None, + source: Some(&SourceAnnotation { position: $pos }), }, } }; @@ -80,6 +99,8 @@ static REGISTRY: &[Entry] = &[ // base attach attach!("base", "library", 0, true), attach!("base", "require", 0, true), + // base source + source!("base", "source", 0), // rlang nse!("rlang", "on_load", ("expr", 0, Current, Lazy)), // shiny diff --git a/crates/oak_semantic/tests/integration/builder.rs b/crates/oak_semantic/tests/integration/builder.rs index 8326483a2..c3da85090 100644 --- a/crates/oak_semantic/tests/integration/builder.rs +++ b/crates/oak_semantic/tests/integration/builder.rs @@ -1,7 +1,12 @@ use aether_parser::parse; use aether_parser::RParserOptions; +use aether_syntax::RCall; use aether_syntax::RSyntaxKind; use oak_semantic::build_index; +use oak_semantic::effects::CallContext; +use oak_semantic::effects::EffectHandler; +use oak_semantic::effects::SourceAnnotation; +use oak_semantic::effects_registry; use oak_semantic::semantic_index::DefinitionId; use oak_semantic::semantic_index::DefinitionKind; use oak_semantic::semantic_index::NamespaceAccessKind; @@ -11,6 +16,7 @@ use oak_semantic::semantic_index::SemanticCallKind; use oak_semantic::semantic_index::SemanticIndex; use oak_semantic::semantic_index::SymbolFlags; use oak_semantic::semantic_index::UseId; +use oak_semantic::EffectsHandlers; use oak_semantic::ImportsResolver; use oak_semantic::NoopImportsResolver; use oak_semantic::SourceResolution; @@ -1433,7 +1439,7 @@ fn test_directive_preserves_offset() { #[test] fn test_source_call_records_path() { - let index = index("source(\"helpers.R\")"); + let index = index_with_base("source(\"helpers.R\")"); assert_eq!(semantic_call_kinds(&index), [&SemanticCallKind::Source { path: "helpers.R".into(), resolved: None, @@ -1442,7 +1448,7 @@ fn test_source_call_records_path() { #[test] fn test_source_call_single_quoted_string() { - let index = index("source('helpers.R')"); + let index = index_with_base("source('helpers.R')"); assert_eq!(semantic_call_kinds(&index), [&SemanticCallKind::Source { path: "helpers.R".into(), resolved: None, @@ -1451,7 +1457,7 @@ fn test_source_call_single_quoted_string() { #[test] fn test_source_call_preserves_offset() { - let index = index("x <- 1\nsource(\"helpers.R\")"); + let index = index_with_base("x <- 1\nsource(\"helpers.R\")"); let semantic_calls = index.semantic_calls(); assert_eq!(semantic_calls.len(), 1); assert_eq!(semantic_calls[0].offset(), biome_rowan::TextSize::from(7)); @@ -1459,7 +1465,7 @@ fn test_source_call_preserves_offset() { #[test] fn test_source_call_records_file_scope() { - let index = index("source(\"helpers.R\")"); + let index = index_with_base("source(\"helpers.R\")"); let semantic_calls = index.semantic_calls(); assert_eq!(semantic_calls.len(), 1); assert_eq!(semantic_calls[0].scope(), ScopeId::from(0)); @@ -1467,7 +1473,7 @@ fn test_source_call_records_file_scope() { #[test] fn test_source_call_in_function_body_records_inner_scope() { - let index = index("f <- function() { source(\"helpers.R\") }"); + let index = index_with_base("f <- function() { source(\"helpers.R\") }"); let semantic_calls = index.semantic_calls(); assert_eq!(semantic_calls.len(), 1); assert_eq!(semantic_calls[0].kind(), &SemanticCallKind::Source { @@ -1479,7 +1485,7 @@ fn test_source_call_in_function_body_records_inner_scope() { #[test] fn test_source_call_non_static_path_ignored() { - let index = index("source(get_path())"); + let index = index_with_base("source(get_path())"); assert_eq!(semantic_call_kinds(&index), Vec::<&SemanticCallKind>::new()); } @@ -1487,19 +1493,28 @@ fn test_source_call_non_static_path_ignored() { fn test_source_call_non_static_local_ignored() { // `local = some_env()` isn't statically resolvable; we bail rather // than record the call. - let index = index("source(\"helpers.R\", local = some_env())"); + let index = index_with_base("source(\"helpers.R\", local = some_env())"); assert_eq!(semantic_call_kinds(&index), Vec::<&SemanticCallKind>::new()); } #[test] fn test_source_call_local_true_recorded() { - let index = index("source(\"helpers.R\", local = TRUE)"); + let index = index_with_base("source(\"helpers.R\", local = TRUE)"); assert_eq!(semantic_call_kinds(&index), [&SemanticCallKind::Source { path: "helpers.R".into(), resolved: None, }]); } +#[test] +fn test_source_call_shadowed_by_local_binding_not_recognized() { + // A user-defined `source` shadows base `source`, so the call isn't a source + // directive and injects nothing. Recognition runs on the resolve path, which + // sees the local binding first. + let index = index_with_base("source <- function(...) {}\nsource(\"helpers.R\")"); + assert_eq!(semantic_call_kinds(&index), Vec::<&SemanticCallKind>::new()); +} + #[test] fn test_source_and_library_calls_coexist() { let index = index_with_base("library(dplyr)\nsource(\"helpers.R\")\nrequire(tidyr)"); @@ -1518,13 +1533,13 @@ fn test_source_and_library_calls_coexist() { } #[test] -fn test_source_call_emitted_without_resolver() { - // The pure `semantic_index` (no resolver) doesn't produce - // `DefinitionKind::Import` for sourced names — those come from - // the legacy `_with_source_resolver` path. But the `Source` - // semantic call IS recorded, so downstream queries in `oak_db` - // can still chase the forwarding chain. - let index = index("source(\"helpers.R\")"); +fn test_source_call_recognized_under_base_resolver() { + // Recognition runs on the resolve path now, so `source()` needs a resolver + // that resolves base. With base attached but no registered source, the + // resolver's `resolve_source` returns `None`: no `DefinitionKind::Import` is + // injected for sourced names, but the `Source` semantic call IS recorded, so + // downstream queries in `oak_db` can still chase the forwarding chain. + let index = index_with_base("source(\"helpers.R\")"); let file_scope = ScopeId::from(0); assert_eq!(index.definitions(file_scope).iter().count(), 0); assert_eq!(index.semantic_calls().len(), 1); @@ -1671,7 +1686,7 @@ fn test_source_call_no_arguments_ignored() { #[test] fn test_directive_declare_source_no_resolver() { - let index = index("declare(source(\"helpers.R\"))"); + let index = index_with_base("declare(source(\"helpers.R\"))"); assert_eq!(semantic_call_kinds(&index), [&SemanticCallKind::Source { path: "helpers.R".into(), resolved: None, @@ -1680,7 +1695,7 @@ fn test_directive_declare_source_no_resolver() { #[test] fn test_directive_declare_source_single_quotes_no_resolver() { - let index = index("declare(source('utils.R'))"); + let index = index_with_base("declare(source('utils.R'))"); assert_eq!(semantic_call_kinds(&index), [&SemanticCallKind::Source { path: "utils.R".into(), resolved: None, @@ -1689,7 +1704,7 @@ fn test_directive_declare_source_single_quotes_no_resolver() { #[test] fn test_directive_tilde_declare_source_no_resolver() { - let index = index("~declare(source(\"helpers.R\"))"); + let index = index_with_base("~declare(source(\"helpers.R\"))"); assert_eq!(semantic_call_kinds(&index), [&SemanticCallKind::Source { path: "helpers.R".into(), resolved: None, @@ -1711,7 +1726,7 @@ fn test_fixme_directive_declare_library_transparent() { fn test_directive_declare_not_at_file_scope() { // declare()'s argument is walked into regardless of position, so a // nested source() inside a function body is still recorded. - let index = index("f <- function() { declare(source(\"helpers.R\")) }"); + let index = index_with_base("f <- function() { declare(source(\"helpers.R\")) }"); assert_eq!(semantic_call_kinds(&index), [&SemanticCallKind::Source { path: "helpers.R".into(), resolved: None, @@ -1720,7 +1735,7 @@ fn test_directive_declare_not_at_file_scope() { #[test] fn test_directive_tilde_declare_not_at_file_scope() { - let index = index("f <- function() { ~declare(source(\"helpers.R\")) }"); + let index = index_with_base("f <- function() { ~declare(source(\"helpers.R\")) }"); assert_eq!(semantic_call_kinds(&index), [&SemanticCallKind::Source { path: "helpers.R".into(), resolved: None, @@ -1748,7 +1763,7 @@ fn test_directive_declare_mixed_with_bare() { #[test] fn test_directive_declare_source_no_resolver_records_call() { - let index = index("x <- 1\ndeclare(source(\"helpers.R\"))"); + let index = index_with_base("x <- 1\ndeclare(source(\"helpers.R\"))"); let semantic_calls = index.semantic_calls(); assert_eq!(semantic_calls.len(), 1); assert_eq!(semantic_calls[0].kind(), &SemanticCallKind::Source { @@ -1759,7 +1774,7 @@ fn test_directive_declare_source_no_resolver_records_call() { #[test] fn test_directive_tilde_declare_source_no_resolver_records_call() { - let index = index("x <- 1\n~declare(source(\"helpers.R\"))"); + let index = index_with_base("x <- 1\n~declare(source(\"helpers.R\"))"); let semantic_calls = index.semantic_calls(); assert_eq!(semantic_calls.len(), 1); assert_eq!(semantic_calls[0].kind(), &SemanticCallKind::Source { @@ -1805,6 +1820,12 @@ impl ImportsResolver for ConstResolver { fn resolve_source(&mut self, _path: &str) -> Option { Some(self.0.clone()) } + + fn resolve_effects(&mut self, name: &str, _: &[String], _: bool) -> Option { + // `source()` recognition runs on the resolve path, so a source-only + // resolver still has to resolve base effects for `source` to be seen. + effects_registry::lookup("base", name).copied() + } } /// Returns per-path resolutions; unknown paths yield `None`. @@ -1814,6 +1835,72 @@ impl ImportsResolver for MapResolver { fn resolve_source(&mut self, path: &str) -> Option { self.0.get(path).cloned() } + + fn resolve_effects(&mut self, name: &str, _: &[String], _: bool) -> Option { + effects_registry::lookup("base", name).copied() + } +} + +/// A source handler that resolves one call to a fixed collation of files, +/// standing in for a collation-style callee. Attached to the `source` name +/// (which passes the `is_annotated` front gate) by [`MultiFileResolver`]. +#[derive(Debug)] +struct CollationHandler; + +static COLLATION_HANDLER: CollationHandler = CollationHandler; + +impl EffectHandler for CollationHandler { + type Output = Vec; + + fn resolve(&self, _call: &RCall, _ctx: &CallContext) -> Option> { + Some(vec!["a.R".into(), "b.R".into()]) + } +} + +/// Resolves `source` to the multi-file [`CollationHandler`] and maps the +/// collated paths through `sources`. +struct MultiFileResolver { + sources: std::collections::HashMap, +} + +impl ImportsResolver for MultiFileResolver { + fn resolve_source(&mut self, path: &str) -> Option { + self.sources.get(path).cloned() + } + + fn resolve_effects(&mut self, name: &str, _: &[String], _: bool) -> Option { + if name == "source" { + return Some(EffectsHandlers { + arguments: None, + attach: None, + source: Some(&COLLATION_HANDLER), + }); + } + effects_registry::lookup("base", name).copied() + } +} + +/// Resolves `source` to a [`SourceAnnotation`] whose path sits at the second +/// positional slot, exercising the configurable `position`. +struct PositionResolver; + +static SOURCE_AT_POSITION_1: SourceAnnotation = SourceAnnotation { position: 1 }; + +impl ImportsResolver for PositionResolver { + fn resolve_source(&mut self, _path: &str) -> Option { + None + } + + fn resolve_effects(&mut self, name: &str, _: &[String], _: bool) -> Option { + if name == "source" { + return Some(EffectsHandlers { + arguments: None, + attach: None, + source: Some(&SOURCE_AT_POSITION_1), + }); + } + None + } } #[test] @@ -2037,3 +2124,71 @@ fn test_source_resolver_local_def_shadowed_by_source() { let def = &index.definitions(file)[def_id]; assert!(matches!(def.kind(), DefinitionKind::Import { .. })); } + +#[test] +fn test_source_resolver_multiple_files_each_emitted_and_injected() { + // A source handler can resolve one call to several files (a collation). + // Each file becomes its own `Source` semantic call and injects its own + // names, in file order, with each file's forwarded packages after it. + let sources = std::collections::HashMap::from([ + ("a.R".to_string(), SourceResolution { + url: Url::parse("file:///a.R").unwrap(), + names: vec!["a_name".into()], + packages: vec!["pkgA".into()], + }), + ("b.R".to_string(), SourceResolution { + url: Url::parse("file:///b.R").unwrap(), + names: vec!["b_name".into()], + packages: vec![], + }), + ]); + let code = "source(\"collate\")\na_name\nb_name\n"; + let index = build_test_index(code, MultiFileResolver { sources }); + + assert_eq!(semantic_call_kinds(&index), [ + &SemanticCallKind::Source { + path: "a.R".into(), + resolved: Some(Url::parse("file:///a.R").unwrap()), + }, + &SemanticCallKind::Attach { + package: "pkgA".into() + }, + &SemanticCallKind::Source { + path: "b.R".into(), + resolved: Some(Url::parse("file:///b.R").unwrap()), + }, + ]); + + // Both files' names are injected and resolve at their uses. + // Uses: source(0), a_name(1), b_name(2) + let file = ScopeId::from(0); + let map = index.use_def_map(file); + for use_index in [1, 2] { + let bindings = map.bindings_at_use(UseId::from(use_index)); + assert_eq!(bindings.definitions().len(), 1); + let def = &index.definitions(file)[bindings.definitions()[0]]; + assert!(matches!(def.kind(), DefinitionKind::Import { .. })); + } +} + +#[test] +fn test_source_resolver_honors_configured_path_position() { + // A `SourceAnnotation` with `position: 1` takes the path from the second + // positional argument, not the first. + let index = build_test_index("source(\"ignored\", \"real.R\")", PositionResolver); + assert_eq!(semantic_call_kinds(&index), [&SemanticCallKind::Source { + path: "real.R".into(), + resolved: None, + }]); +} + +#[test] +fn test_source_call_leading_named_arg_still_finds_path() { + // A named argument before the path doesn't consume the positional slot, so + // the path is still recognized (unlike full call-position matching). + let index = index_with_base("source(echo = TRUE, \"helpers.R\")"); + assert_eq!(semantic_call_kinds(&index), [&SemanticCallKind::Source { + path: "helpers.R".into(), + resolved: None, + }]); +}