Skip to content

Commit 96dff66

Browse files
committed
Implement unquoting and add support for .() and ..()
1 parent ebc86c2 commit 96dff66

7 files changed

Lines changed: 298 additions & 34 deletions

File tree

crates/oak_ide/tests/integration/find_references.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,19 @@ fn test_from_definition_site() {
5252
assert_eq!(ranges(&refs), vec![range(0, 1), range(7, 8)]);
5353
}
5454

55+
#[test]
56+
fn test_includes_bquote_hole_use() {
57+
// The `x` inside `bquote`'s `.()` hole is a live use, so find-references
58+
// from the definition returns it alongside the def.
59+
let source = "x <- 1\nbquote(.(x))\n";
60+
let mut db = OakDatabase::new();
61+
let file = upsert(&mut db, "test.R", source);
62+
63+
let hole = source.rfind('x').unwrap() as u32;
64+
let refs = find_references(&db, file, offset(0), true);
65+
assert_eq!(ranges(&refs), vec![range(0, 1), range(hole, hole + 1)]);
66+
}
67+
5568
#[test]
5669
fn test_shadowing_excludes_outer() {
5770
let source = "x <- 1\nf <- function() {\n x <- 2\n x\n}\n";

crates/oak_ide/tests/integration/goto_definition.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,23 @@ fn test_local_definition_navigates_to_binding() {
3535
assert_eq!(target.focus_range, range(0, 1));
3636
}
3737

38+
#[test]
39+
fn test_navigates_from_bquote_hole_use() {
40+
let mut db = OakDatabase::new();
41+
let source = "x <- 1\nbquote(.(x))\n";
42+
let file = upsert(&mut db, "a.R", source);
43+
44+
// Cursor on the `x` inside `bquote`'s `.()` hole, which is a live use.
45+
let hole = source.rfind('x').unwrap() as u32;
46+
let targets = goto_definition(&db, file, TextSize::from(hole));
47+
assert_eq!(targets.len(), 1);
48+
49+
let target = &targets[0];
50+
assert_eq!(target.file, file);
51+
assert_eq!(target.name, "x");
52+
assert_eq!(target.full_range, range(0, 1));
53+
}
54+
3855
#[test]
3956
fn test_navigates_from_trailing_edge_of_identifier() {
4057
let mut db = OakDatabase::new();

crates/oak_ide/tests/integration/rename.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,22 @@ fn test_rename_def_and_use() {
8484
]);
8585
}
8686

87+
#[test]
88+
fn test_rename_includes_bquote_hole_use() {
89+
// The `x` inside `bquote`'s `.()` hole is a real use, so renaming the
90+
// definition rewrites it along with the def.
91+
let source = "x <- 1\nbquote(.(x))\n";
92+
let mut db = OakDatabase::new();
93+
let file = upsert(&mut db, "test.R", source);
94+
95+
let hole = source.rfind('x').unwrap() as u32;
96+
let targets = rename(&db, file, offset(0), "y").unwrap();
97+
assert_eq!(edit_ranges(&targets), vec![
98+
range(0, 1),
99+
range(hole, hole + 1),
100+
]);
101+
}
102+
87103
#[test]
88104
fn test_rename_excludes_shadowed_outer() {
89105
let source = "x <- 1\nf <- function() {\n x <- 2\n x\n}\n";

crates/oak_semantic/src/builder/builder_nse.rs

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,11 @@ use super::is_super_assignment;
1818
use super::BoundNames;
1919
use super::SemanticIndexBuilder;
2020
use super::SourcedFile;
21-
use crate::effects::Argument;
22-
use crate::effects::ArgumentEffect;
2321
use crate::effects::AssignBinding;
2422
use crate::effects::CallContext;
2523
use crate::effects::Effects;
2624
use crate::effects::EffectsHandlers;
25+
use crate::effects::ResolvedArgumentEffect;
2726
use crate::effects::ResolvedArgumentEffects;
2827
use crate::effects_registry;
2928
use crate::resolver::ImportsResolver;
@@ -116,18 +115,16 @@ impl<R: ImportsResolver> SemanticIndexBuilder<R> {
116115
let Ok(arg) = item else { continue };
117116
let Some(value) = arg.value() else { continue };
118117

119-
match arg_effects[i] {
118+
match &arg_effects[i] {
120119
None => self.scan_expression(&value),
121-
// Quoted argument: captured unevaluated. No effects to scan, no
122-
// names to bind.
123-
Some(Argument {
124-
effect: ArgumentEffect::Quote,
125-
..
126-
}) => {},
127-
Some(Argument {
128-
effect: ArgumentEffect::Nse { scope, timing },
129-
..
130-
}) => match (scope, timing) {
120+
// Quoted argument: only the unquoted holes are live. Scan these,
121+
// suppress the rest.
122+
Some(ResolvedArgumentEffect::Quote { holes }) => {
123+
for hole in holes {
124+
self.scan_expression(hole);
125+
}
126+
},
127+
Some(ResolvedArgumentEffect::Nse { scope, timing }) => match (scope, timing) {
131128
// Calls like `evalq()`
132129
(NseScope::Current, NseTiming::Eager) => self.scan_expression(&value),
133130

@@ -496,16 +493,20 @@ impl<R: ImportsResolver> SemanticIndexBuilder<R> {
496493
let Ok(arg) = item else { continue };
497494
let Some(value) = arg.value() else { continue };
498495

499-
let Some(argument) = arg_effects[i] else {
496+
let Some(argument) = &arg_effects[i] else {
500497
self.collect_expression(&value);
501498
continue;
502499
};
503-
match argument.effect {
504-
ArgumentEffect::Nse { scope, timing } => {
505-
self.collect_nse_argument(scope, timing, &value)
500+
match argument {
501+
ResolvedArgumentEffect::Nse { scope, timing } => {
502+
self.collect_nse_argument(*scope, *timing, &value)
503+
},
504+
// Quoted argument: only the unquote holes are live.
505+
ResolvedArgumentEffect::Quote { holes } => {
506+
for hole in holes {
507+
self.collect_expression(hole);
508+
}
506509
},
507-
// Quoted argument: No uses inside.
508-
ArgumentEffect::Quote => {},
509510
}
510511
}
511512
}

crates/oak_semantic/src/effects.rs

Lines changed: 140 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ use aether_syntax::AnyRExpression;
33
use aether_syntax::AnyRValue;
44
use aether_syntax::RArgument;
55
use aether_syntax::RCall;
6+
use biome_rowan::AstNode;
67
use biome_rowan::AstPtr;
78
use biome_rowan::AstSeparatedList;
9+
use biome_rowan::WalkEvent;
810
// Re-exported so consumers building an `AssignBinding` (custom `EffectHandler`s)
911
// can name the `name_expr` field's type without depending on oak_core directly.
1012
pub use oak_core::range::RangedAstPtr;
@@ -140,6 +142,16 @@ impl CallContext {
140142
_ => None,
141143
}
142144
}
145+
146+
/// Statically evaluate an argument's value expression to a bool.
147+
pub fn resolve_static_bool(&self, value: &AnyRExpression) -> Option<bool> {
148+
match value {
149+
AnyRExpression::RTrueExpression(_) => Some(true),
150+
AnyRExpression::RFalseExpression(_) => Some(false),
151+
// Static resolution of expressions is not implemented yet
152+
_ => None,
153+
}
154+
}
143155
}
144156

145157
/// A formal a handler wants to locate in a call, by name and by its position in
@@ -155,9 +167,19 @@ pub struct Formal {
155167
}
156168

157169
/// A call's resolved argument effects: for each argument in call order, the
158-
/// annotated argument it matched, or `None` for a plain (standard-eval)
159-
/// argument.
160-
pub type ResolvedArgumentEffects = Vec<Option<&'static Argument>>;
170+
/// effect it resolved to, or `None` for a plain (standard-eval) argument.
171+
pub type ResolvedArgumentEffects = Vec<Option<ResolvedArgumentEffect>>;
172+
173+
/// The resolved, per-call effect of one argument. The builder consumes these.
174+
#[derive(Debug, Clone)]
175+
pub enum ResolvedArgumentEffect {
176+
/// Quote plus Eval in a controlled scope, fused.
177+
Nse { scope: NseScope, timing: NseTiming },
178+
/// Captured unevaluated. `holes` are the sub-expressions that escape back to
179+
/// evaluation (e.g. bquote's `.()` contents), walked normally; everything
180+
/// else in the argument is inert. Empty for a plain `quote()`.
181+
Quote { holes: Vec<AnyRExpression> },
182+
}
161183

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

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

212+
impl ArgumentEffect {
213+
fn resolve(self) -> ResolvedArgumentEffect {
214+
match self {
215+
ArgumentEffect::Nse { scope, timing } => ResolvedArgumentEffect::Nse { scope, timing },
216+
ArgumentEffect::Quote => ResolvedArgumentEffect::Quote { holes: Vec::new() },
217+
}
218+
}
219+
}
220+
190221
impl EffectHandler for ArgumentsAnnotation {
191222
type Output = ResolvedArgumentEffects;
192223

@@ -205,12 +236,113 @@ impl EffectHandler for ArgumentsAnnotation {
205236
Some(
206237
matched
207238
.into_iter()
208-
.map(|formal| formal.map(|i| &arguments[i]))
239+
.map(|formal| formal.map(|i| arguments[i].effect.resolve()))
209240
.collect(),
210241
)
211242
}
212243
}
213244

245+
/// Handler for `bquote()`. It quotes its `expr` argument like `quote()`, but a
246+
/// `.(X)` inside escapes back to evaluation, so `X` is a live sub-expression.
247+
/// Recognizing `.()` is specific to bquote, so it lives in this handler rather
248+
/// than in the shared [`ArgumentEffect`] vocabulary.
249+
#[derive(Debug, Clone, Copy)]
250+
pub struct BquoteHandler;
251+
252+
impl EffectHandler for BquoteHandler {
253+
type Output = ResolvedArgumentEffects;
254+
255+
fn resolve(&self, call: &RCall, ctx: &CallContext) -> Option<ResolvedArgumentEffects> {
256+
// `bquote(expr, where, splice)`: only `expr` (the first positional) is
257+
// quoted. The other arguments are ordinary values.
258+
let formals = [
259+
Formal {
260+
name: "expr",
261+
position: 0,
262+
},
263+
Formal {
264+
name: "splice",
265+
position: 2,
266+
},
267+
];
268+
let matched = ctx.match_arguments(call, &formals);
269+
270+
let args = call.arguments().ok()?;
271+
let values: Vec<Option<AnyRExpression>> = args
272+
.items()
273+
.iter()
274+
.map(|item| item.ok().and_then(|arg| arg.value()))
275+
.collect();
276+
277+
// `..()` only splices under `splice = TRUE`.
278+
// TODO: resolve a dynamic `splice` argument.
279+
let splice = matched
280+
.iter()
281+
.position(|formal| *formal == Some(1))
282+
.and_then(|i| values.get(i))
283+
.and_then(|value| value.as_ref())
284+
.and_then(|value| ctx.resolve_static_bool(value))
285+
.unwrap_or(false);
286+
287+
Some(
288+
matched
289+
.into_iter()
290+
.enumerate()
291+
.map(|(i, formal)| {
292+
// Only `expr` (formal 0) is quoted
293+
if formal != Some(0) {
294+
return None;
295+
}
296+
let holes = values
297+
.get(i)
298+
.and_then(|value| value.as_ref())
299+
.map(|expr| unquote_holes(expr, splice))
300+
.unwrap_or_default();
301+
Some(ResolvedArgumentEffect::Quote { holes })
302+
})
303+
.collect(),
304+
)
305+
}
306+
}
307+
308+
/// The unquote holes inside a bquote-quoted expression: the escaped argument of
309+
/// each `.(foo)` call, plus each `..(foo)` when `splice` is on.
310+
fn unquote_holes(expr: &AnyRExpression, splice: bool) -> Vec<AnyRExpression> {
311+
let mut holes = Vec::new();
312+
let mut preorder = expr.syntax().preorder();
313+
while let Some(event) = preorder.next() {
314+
let WalkEvent::Enter(node) = event else {
315+
continue;
316+
};
317+
let Some(call) = RCall::cast(node) else {
318+
continue;
319+
};
320+
if let Some(hole) = unquote_hole(&call, splice) {
321+
holes.push(hole);
322+
preorder.skip_subtree();
323+
}
324+
}
325+
holes
326+
}
327+
328+
/// The escaped expression of a `.(foo)` unquote call, or a `..(foo)` splice
329+
/// unquote when `splice` is on, or `None` when `call` isn't one. bquote's
330+
/// unquote operator is the function `.`, and its splice unquote is `..`.
331+
fn unquote_hole(call: &RCall, splice: bool) -> Option<AnyRExpression> {
332+
let AnyRExpression::RIdentifier(func) = call.function().ok()? else {
333+
return None;
334+
};
335+
let is_unquote = match func.name_text().as_str() {
336+
"." => true,
337+
".." => splice,
338+
_ => false,
339+
};
340+
if !is_unquote {
341+
return None;
342+
}
343+
call.arguments().ok()?.items().iter().next()?.ok()?.value()
344+
}
345+
214346
/// Declares how an attach function (`library()`, `require()`) names its package,
215347
/// and serves as the default [`EffectHandler`] for it by extracting that package
216348
/// from a call.

crates/oak_semantic/src/effects_registry.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use crate::effects::ArgumentEffect;
33
use crate::effects::ArgumentsAnnotation;
44
use crate::effects::AssignAnnotation;
55
use crate::effects::AttachAnnotation;
6+
use crate::effects::BquoteHandler;
67
use crate::effects::EffectsHandlers;
78
use crate::effects::SourceAnnotation;
89
use crate::semantic_index::NseScope::Current;
@@ -146,7 +147,18 @@ static REGISTRY: &[Entry] = &[
146147
nse!("base", "within.data.frame", ("expr", 1, Nested, Eager)),
147148
// base quote
148149
quoted!("base", "quote", ("expr", 0)),
149-
quoted!("base", "bquote", ("expr", 0)),
150+
// `bquote` quotes `expr` too, but its `.()` holes escape to evaluation, so
151+
// it needs a handler rather than a static per-argument effect.
152+
Entry {
153+
package: "base",
154+
function: "bquote",
155+
effects: EffectsHandlers {
156+
arguments: Some(&BquoteHandler),
157+
attach: None,
158+
source: None,
159+
assign: None,
160+
},
161+
},
150162
// base attach
151163
attach!("base", "library", 0, true),
152164
attach!("base", "require", 0, true),

0 commit comments

Comments
 (0)