Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 80 additions & 139 deletions crates/oak_semantic/src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@

use std::sync::Arc;

use aether_syntax::AnyRArgumentName;
use aether_syntax::AnyRExpression;
use aether_syntax::AnyRParameterName;
use aether_syntax::AnyRValue;
Expand Down Expand Up @@ -651,7 +650,6 @@ impl<R: ImportsResolver> SemanticIndexBuilder<R> {
self.scan_expression(&func);
}
self.scan_call(call);
self.scan_semantic_call(call);
},

AnyRExpression::RForStatement(stmt) => {
Expand Down Expand Up @@ -751,44 +749,26 @@ impl<R: ImportsResolver> SemanticIndexBuilder<R> {
}
}

/// 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<SourceResolution> {
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
Expand All @@ -800,7 +780,7 @@ impl<R: ImportsResolver> SemanticIndexBuilder<R> {
}
}

self.call_resolutions.entry(range).or_default().source = Some(resolution);
Some(resolution)
}

/// Record a binding in the scan's flow state.
Expand Down Expand Up @@ -1241,12 +1221,15 @@ impl<R: ImportsResolver> SemanticIndexBuilder<R> {
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);
}
}

Expand Down Expand Up @@ -1284,115 +1267,66 @@ impl<R: ImportsResolver> SemanticIndexBuilder<R> {
// 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<String> {
let args = call.arguments().ok()?;

let mut path: Option<String> = 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 {
Expand Down Expand Up @@ -1450,17 +1384,24 @@ impl<R: ImportsResolver> SemanticIndexBuilder<R> {
///
/// - `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<ResolvedArgumentEffects>,
source: Option<SourceResolution>,
attach: Option<String>,
source: Vec<SourcedFile>,
}

/// 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<SourceResolution>,
}

/// The scan's flow-precise binding state: which names are bound at the current
Expand Down
37 changes: 31 additions & 6 deletions crates/oak_semantic/src/builder/builder_nse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,8 +27,9 @@ use crate::semantic_index::ScopeKind;
use crate::semantic_index::SemanticDiagnostic;

impl<R: ImportsResolver> SemanticIndexBuilder<R> {
/// 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
Expand All @@ -36,9 +38,9 @@ impl<R: ImportsResolver> SemanticIndexBuilder<R> {
/// 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 {
Expand All @@ -51,6 +53,22 @@ impl<R: ImportsResolver> SemanticIndexBuilder<R> {
}
}

// 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() {
Expand Down Expand Up @@ -235,8 +253,15 @@ impl<R: ImportsResolver> SemanticIndexBuilder<R> {
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, ...).
Expand Down
Loading
Loading