Skip to content
Open
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
13 changes: 13 additions & 0 deletions crates/oak_ide/tests/integration/find_references.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ fn test_from_definition_site() {
assert_eq!(ranges(&refs), vec![range(0, 1), range(7, 8)]);
}

#[test]
fn test_includes_bquote_hole_use() {
// The `x` inside `bquote`'s `.()` hole is a live use, so find-references
// from the definition returns it alongside the def.
let source = "x <- 1\nbquote(.(x))\n";
let mut db = OakDatabase::new();
let file = upsert(&mut db, "test.R", source);

let hole = source.rfind('x').unwrap() as u32;
let refs = find_references(&db, file, offset(0), true);
assert_eq!(ranges(&refs), vec![range(0, 1), range(hole, hole + 1)]);
}

#[test]
fn test_shadowing_excludes_outer() {
let source = "x <- 1\nf <- function() {\n x <- 2\n x\n}\n";
Expand Down
17 changes: 17 additions & 0 deletions crates/oak_ide/tests/integration/goto_definition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,23 @@ fn test_local_definition_navigates_to_binding() {
assert_eq!(target.focus_range, range(0, 1));
}

#[test]
fn test_navigates_from_bquote_hole_use() {
let mut db = OakDatabase::new();
let source = "x <- 1\nbquote(.(x))\n";
let file = upsert(&mut db, "a.R", source);

// Cursor on the `x` inside `bquote`'s `.()` hole, which is a live use.
let hole = source.rfind('x').unwrap() as u32;
let targets = goto_definition(&db, file, TextSize::from(hole));
assert_eq!(targets.len(), 1);

let target = &targets[0];
assert_eq!(target.file, file);
assert_eq!(target.name, "x");
assert_eq!(target.full_range, range(0, 1));
}

#[test]
fn test_navigates_from_trailing_edge_of_identifier() {
let mut db = OakDatabase::new();
Expand Down
16 changes: 16 additions & 0 deletions crates/oak_ide/tests/integration/rename.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,22 @@ fn test_rename_def_and_use() {
]);
}

#[test]
fn test_rename_includes_bquote_hole_use() {
// The `x` inside `bquote`'s `.()` hole is a real use, so renaming the
// definition rewrites it along with the def.
let source = "x <- 1\nbquote(.(x))\n";
let mut db = OakDatabase::new();
let file = upsert(&mut db, "test.R", source);

let hole = source.rfind('x').unwrap() as u32;
let targets = rename(&db, file, offset(0), "y").unwrap();
assert_eq!(edit_ranges(&targets), vec![
range(0, 1),
range(hole, hole + 1),
]);
}

#[test]
fn test_rename_excludes_shadowed_outer() {
let source = "x <- 1\nf <- function() {\n x <- 2\n x\n}\n";
Expand Down
39 changes: 20 additions & 19 deletions crates/oak_semantic/src/builder/builder_nse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,11 @@ use super::is_super_assignment;
use super::BoundNames;
use super::SemanticIndexBuilder;
use super::SourcedFile;
use crate::effects::Argument;
use crate::effects::ArgumentEffect;
use crate::effects::AssignBinding;
use crate::effects::CallContext;
use crate::effects::Effects;
use crate::effects::EffectsHandlers;
use crate::effects::ResolvedArgumentEffect;
use crate::effects::ResolvedArgumentEffects;
use crate::effects_registry;
use crate::resolver::ImportsResolver;
Expand Down Expand Up @@ -116,18 +115,16 @@ impl<R: ImportsResolver> SemanticIndexBuilder<R> {
let Ok(arg) = item else { continue };
let Some(value) = arg.value() else { continue };

match arg_effects[i] {
match &arg_effects[i] {
None => self.scan_expression(&value),
// Quoted argument: captured unevaluated. No effects to scan, no
// names to bind.
Some(Argument {
effect: ArgumentEffect::Quote,
..
}) => {},
Some(Argument {
effect: ArgumentEffect::Nse { scope, timing },
..
}) => match (scope, timing) {
// 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::Nse { scope, timing }) => match (scope, timing) {
// Calls like `evalq()`
(NseScope::Current, NseTiming::Eager) => self.scan_expression(&value),

Expand Down Expand Up @@ -512,16 +509,20 @@ impl<R: ImportsResolver> SemanticIndexBuilder<R> {
let Ok(arg) = item else { continue };
let Some(value) = arg.value() else { continue };

let Some(argument) = arg_effects[i] else {
let Some(argument) = &arg_effects[i] else {
self.collect_expression(&value);
continue;
};
match argument.effect {
ArgumentEffect::Nse { scope, timing } => {
self.collect_nse_argument(scope, timing, &value)
match argument {
ResolvedArgumentEffect::Nse { scope, timing } => {
self.collect_nse_argument(*scope, *timing, &value)
},
// Quoted argument: only the unquote holes are live.
ResolvedArgumentEffect::Quote { holes } => {
for hole in holes {
self.collect_expression(hole);
}
},
// Quoted argument: No uses inside.
ArgumentEffect::Quote => {},
}
}
}
Expand Down
148 changes: 140 additions & 8 deletions crates/oak_semantic/src/effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ use aether_syntax::AnyRExpression;
use aether_syntax::AnyRValue;
use aether_syntax::RArgument;
use aether_syntax::RCall;
use biome_rowan::AstNode;
use biome_rowan::AstPtr;
use biome_rowan::AstSeparatedList;
use biome_rowan::WalkEvent;
// Re-exported so consumers building an `AssignBinding` (custom `EffectHandler`s)
// can name the `name_expr` field's type without depending on oak_core directly.
pub use oak_core::range::RangedAstPtr;
Expand Down Expand Up @@ -140,6 +142,16 @@ impl CallContext {
_ => None,
}
}

/// Statically evaluate an argument's value expression to a bool.
pub fn resolve_static_bool(&self, value: &AnyRExpression) -> Option<bool> {
match value {
AnyRExpression::RTrueExpression(_) => Some(true),
AnyRExpression::RFalseExpression(_) => Some(false),
// 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
Expand All @@ -155,9 +167,19 @@ pub struct Formal {
}

/// A call's resolved argument effects: for each argument in call order, the
/// annotated argument it matched, or `None` for a plain (standard-eval)
/// argument.
pub type ResolvedArgumentEffects = Vec<Option<&'static Argument>>;
/// effect it resolved to, or `None` for a plain (standard-eval) argument.
pub type ResolvedArgumentEffects = Vec<Option<ResolvedArgumentEffect>>;

/// The resolved, per-call effect of one argument. The builder consumes these.
#[derive(Debug, Clone)]
pub enum ResolvedArgumentEffect {
/// Quote plus Eval in a controlled scope, fused.
Nse { scope: NseScope, timing: NseTiming },
/// Captured unevaluated. `holes` are the sub-expressions that escape back to
/// evaluation (e.g. bquote's `.()` contents), walked normally; everything
/// else in the argument is inert. Empty for a plain `quote()`.
Quote { holes: Vec<AnyRExpression> },
}

/// Declares how a function evaluates its annotated arguments, and serves as the
/// default [`EffectHandler`] for it by matching the declaration to a call.
Expand All @@ -175,18 +197,27 @@ pub struct Argument {
}

/// What static operation an argument's evaluation calls for, mirroring R's
/// evaluation model. Absence (an argument not listed on an annotation) means
/// standard evaluation of an unquoted expression, the common case.
/// evaluation model.
#[derive(Debug, Clone, Copy)]
pub enum ArgumentEffect {
/// Quote plus Eval in a controlled scope, fused
Nse { scope: NseScope, timing: NseTiming },
/// Captured unevaluated, so its symbols are not uses and nothing in it runs.
/// `quote`, `bquote`. TODO:`bquote(.(foo))` should unquote `foo`. This
/// requires implementing the `Eval` effect.
/// `quote`. A function that unquotes (`bquote()`, whose `.()` holes escape)
/// can't be expressed statically, and must use a custom handler instead of
/// this variant.
Quote,
}

impl ArgumentEffect {
fn resolve(self) -> ResolvedArgumentEffect {
match self {
ArgumentEffect::Nse { scope, timing } => ResolvedArgumentEffect::Nse { scope, timing },
ArgumentEffect::Quote => ResolvedArgumentEffect::Quote { holes: Vec::new() },
}
}
}

impl EffectHandler for ArgumentsAnnotation {
type Output = ResolvedArgumentEffects;

Expand All @@ -205,12 +236,113 @@ impl EffectHandler for ArgumentsAnnotation {
Some(
matched
.into_iter()
.map(|formal| formal.map(|i| &arguments[i]))
.map(|formal| formal.map(|i| arguments[i].effect.resolve()))
.collect(),
)
}
}

/// Handler for `bquote()`. It quotes its `expr` argument like `quote()`, but a
/// `.(X)` inside escapes back to evaluation, so `X` is a live sub-expression.
/// Recognizing `.()` is specific to bquote, so it lives in this handler rather
/// than in the shared [`ArgumentEffect`] vocabulary.
#[derive(Debug, Clone, Copy)]
pub struct BquoteHandler;

impl EffectHandler for BquoteHandler {
type Output = ResolvedArgumentEffects;

fn resolve(&self, call: &RCall, ctx: &CallContext) -> Option<ResolvedArgumentEffects> {
// `bquote(expr, where, splice)`: only `expr` (the first positional) is
// quoted. The other arguments are ordinary values.
let formals = [
Formal {
name: "expr",
position: 0,
},
Formal {
name: "splice",
position: 2,
},
];
let matched = ctx.match_arguments(call, &formals);

let args = call.arguments().ok()?;
let values: Vec<Option<AnyRExpression>> = args
.items()
.iter()
.map(|item| item.ok().and_then(|arg| arg.value()))
.collect();

// `..()` only splices under `splice = TRUE`.
// TODO: resolve a dynamic `splice` argument.
let splice = matched
.iter()
.position(|formal| *formal == Some(1))
.and_then(|i| values.get(i))
.and_then(|value| value.as_ref())
.and_then(|value| ctx.resolve_static_bool(value))
.unwrap_or(false);

Some(
matched
.into_iter()
.enumerate()
.map(|(i, formal)| {
// Only `expr` (formal 0) is quoted
if formal != Some(0) {
return None;
}
let holes = values
.get(i)
.and_then(|value| value.as_ref())
.map(|expr| unquote_holes(expr, splice))
.unwrap_or_default();
Some(ResolvedArgumentEffect::Quote { holes })
})
.collect(),
)
}
}

/// The unquote holes inside a bquote-quoted expression: the escaped argument of
/// each `.(foo)` call, plus each `..(foo)` when `splice` is on.
fn unquote_holes(expr: &AnyRExpression, splice: bool) -> Vec<AnyRExpression> {
let mut holes = Vec::new();
let mut preorder = expr.syntax().preorder();
while let Some(event) = preorder.next() {
let WalkEvent::Enter(node) = event else {
continue;
};
let Some(call) = RCall::cast(node) else {
continue;
};
if let Some(hole) = unquote_hole(&call, splice) {
holes.push(hole);
preorder.skip_subtree();
}
}
holes
}

/// The escaped expression of a `.(foo)` unquote call, or a `..(foo)` splice
/// unquote when `splice` is on, or `None` when `call` isn't one. bquote's
/// unquote operator is the function `.`, and its splice unquote is `..`.
fn unquote_hole(call: &RCall, splice: bool) -> Option<AnyRExpression> {
let AnyRExpression::RIdentifier(func) = call.function().ok()? else {
return None;
};
let is_unquote = match func.name_text().as_str() {
"." => true,
".." => splice,
_ => false,
};
if !is_unquote {
return None;
}
call.arguments().ok()?.items().iter().next()?.ok()?.value()
}

/// Declares how an attach function (`library()`, `require()`) names its package,
/// and serves as the default [`EffectHandler`] for it by extracting that package
/// from a call.
Expand Down
14 changes: 13 additions & 1 deletion crates/oak_semantic/src/effects_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use crate::effects::ArgumentEffect;
use crate::effects::ArgumentsAnnotation;
use crate::effects::AssignAnnotation;
use crate::effects::AttachAnnotation;
use crate::effects::BquoteHandler;
use crate::effects::EffectsHandlers;
use crate::effects::SourceAnnotation;
use crate::semantic_index::NseScope::Current;
Expand Down Expand Up @@ -146,7 +147,18 @@ static REGISTRY: &[Entry] = &[
nse!("base", "within.data.frame", ("expr", 1, Nested, Eager)),
// base quote
quoted!("base", "quote", ("expr", 0)),
quoted!("base", "bquote", ("expr", 0)),
// `bquote` quotes `expr` too, but its `.()` holes escape to evaluation, so
// it needs a handler rather than a static per-argument effect.
Entry {
package: "base",
function: "bquote",
effects: EffectsHandlers {
arguments: Some(&BquoteHandler),
attach: None,
source: None,
assign: None,
},
},
// base attach
attach!("base", "library", 0, true),
attach!("base", "require", 0, true),
Expand Down
Loading
Loading