Skip to content

Commit 8463cda

Browse files
Use a "function context" to create more responsive completions (#819)
* Use a "function context" to create more responsive completions * Oops, I meant to keep this logging * Consistency pass re: text edits * Move `use` calls into `mod` blocks to avoid `#[cfg(test)]` proliferation (#826) * Try to convince copilot to stop adding custom messages to assertions * Handle usage that is technically a call, but still looks like a reference Also modify the helper that post-processes `sort_text` to recognize when we've already applied an intentional prefix * Add tooling around `sort_text` and work on the tests * There's an existing helper that gets the job done * Only mention filename * Remove unnecessary `.clone()` * Rename this node Co-authored-by: Davis Vaughan <davis@rstudio.com> * Explicitly handle case like `dplyr::@` + some renaming and refactoring * Be more explicit about what sort of node we are prepared to handle * Introduce `cursor_at_end` field into function context + more The main substantive move here is adding a boolean `cursor_at_end` field into the function context. The new test re: an exact match for a 3-letter function like `dir()` is the real-world usage that made me realize we needed this. Also implemented lots of feedback from @lionel-'s review at the same time. * Fix this comment * Prepare the way to use this test helper elsewhere and more flexibly * Streamline namespace tests `completions_from_namespace()` gets a new signature in this PR. Lets also streamline the tests with utilities similar to those in the function context tests. --------- Co-authored-by: Davis Vaughan <davis@rstudio.com>
1 parent bcdaed2 commit 8463cda

File tree

15 files changed

+895
-441
lines changed

15 files changed

+895
-441
lines changed

.github/copilot-instructions.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,7 @@ For error messages and logging, prefer direct formatting syntax: `Err(anyhow!("M
77
Use `log::trace!` instead of `log::debug!`.
88

99
Use fully qualified result types (`anyhow::Result`) instead of importing them.
10+
11+
When writing tests, prefer simple assertion macros without custom error messages:
12+
- Use `assert_eq!(actual, expected);` instead of `assert_eq!(actual, expected, "custom message");`
13+
- Use `assert!(condition);` instead of `assert!(condition, "custom message");`

crates/ark/src/lsp/completions.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@
77

88
mod completion_context;
99
mod completion_item;
10-
mod parameter_hints;
10+
mod function_context;
1111
mod provide;
1212
mod resolve;
1313
mod sources;
1414
mod types;
1515

16+
#[cfg(test)]
17+
mod tests;
18+
1619
pub(crate) use provide::provide_completions;
1720
pub(crate) use resolve::resolve_completion;

crates/ark/src/lsp/completions/completion_context.rs

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,50 +9,39 @@ use std::cell::OnceCell;
99

1010
use tree_sitter::Node;
1111

12-
use crate::lsp::completions::parameter_hints::ParameterHints;
13-
use crate::lsp::completions::parameter_hints::{self};
12+
use crate::lsp::completions::function_context::FunctionContext;
1413
use crate::lsp::completions::sources::composite::pipe::find_pipe_root;
1514
use crate::lsp::completions::sources::composite::pipe::PipeRoot;
1615
use crate::lsp::document_context::DocumentContext;
1716
use crate::lsp::state::WorldState;
1817
use crate::treesitter::node_find_containing_call;
19-
2018
pub(crate) struct CompletionContext<'a> {
2119
pub(crate) document_context: &'a DocumentContext<'a>,
2220
pub(crate) state: &'a WorldState,
23-
parameter_hints_cell: OnceCell<ParameterHints>,
2421
pipe_root_cell: OnceCell<Option<PipeRoot>>,
2522
containing_call_cell: OnceCell<Option<Node<'a>>>,
23+
function_context_cell: OnceCell<FunctionContext>,
2624
}
2725

2826
impl<'a> CompletionContext<'a> {
2927
pub fn new(document_context: &'a DocumentContext, state: &'a WorldState) -> Self {
3028
Self {
3129
document_context,
3230
state,
33-
parameter_hints_cell: OnceCell::new(),
3431
pipe_root_cell: OnceCell::new(),
3532
containing_call_cell: OnceCell::new(),
33+
function_context_cell: OnceCell::new(),
3634
}
3735
}
3836

39-
pub fn parameter_hints(&self) -> &ParameterHints {
40-
self.parameter_hints_cell.get_or_init(|| {
41-
parameter_hints::parameter_hints(
42-
self.document_context.node,
43-
&self.document_context.document.contents,
44-
)
45-
})
46-
}
47-
4837
pub fn pipe_root(&self) -> Option<PipeRoot> {
4938
let call_node = self.containing_call_node();
5039

5140
self.pipe_root_cell
5241
.get_or_init(|| match find_pipe_root(self.document_context, call_node) {
5342
Ok(root) => root,
5443
Err(e) => {
55-
log::error!("Error trying to find pipe root: {e}");
44+
log::trace!("Error trying to find pipe root: {e}");
5645
None
5746
},
5847
})
@@ -64,4 +53,9 @@ impl<'a> CompletionContext<'a> {
6453
.containing_call_cell
6554
.get_or_init(|| node_find_containing_call(self.document_context.node))
6655
}
56+
57+
pub fn function_context(&self) -> &FunctionContext {
58+
self.function_context_cell
59+
.get_or_init(|| FunctionContext::new(&self.document_context))
60+
}
6761
}

crates/ark/src/lsp/completions/completion_item.rs

Lines changed: 136 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ use tower_lsp::lsp_types::Range;
3737
use tower_lsp::lsp_types::TextEdit;
3838
use tree_sitter::Node;
3939

40-
use crate::lsp::completions::parameter_hints::ParameterHints;
40+
use crate::lsp::completions::function_context::ArgumentsStatus;
41+
use crate::lsp::completions::function_context::FunctionContext;
42+
use crate::lsp::completions::function_context::FunctionRefUsage;
4143
use crate::lsp::completions::types::CompletionData;
4244
use crate::lsp::completions::types::PromiseStrategy;
4345
use crate::lsp::document_context::DocumentContext;
@@ -119,6 +121,8 @@ pub(super) fn completion_item_from_assignment(
119121
item.documentation = Some(Documentation::MarkupContent(markup));
120122
item.kind = Some(CompletionItemKind::VARIABLE);
121123

124+
// TODO: This ad hoc completion item construction for a function does not
125+
// benefit from the logic in completion_item_from_function() :(
122126
if rhs.node_type() == NodeType::FunctionDefinition {
123127
if let Some(parameters) = rhs.child_by_field_name("parameters") {
124128
let parameters = context
@@ -167,36 +171,125 @@ pub(super) unsafe fn completion_item_from_package(
167171
pub(super) fn completion_item_from_function(
168172
name: &str,
169173
package: Option<&str>,
170-
parameter_hints: &ParameterHints,
174+
function_context: &FunctionContext,
171175
) -> anyhow::Result<CompletionItem> {
172-
let label = format!("{}", name);
176+
let label = name.to_string();
173177
let mut item = completion_item(label, CompletionData::Function {
174178
name: name.to_string(),
175179
package: package.map(|s| s.to_string()),
176180
})?;
177181

178182
item.kind = Some(CompletionItemKind::FUNCTION);
179183

184+
let insert_text = sym_quote_invalid(name);
185+
180186
let label_details = item_details(package);
181187
item.label_details = Some(label_details);
182188

183-
let insert_text = sym_quote_invalid(name);
189+
// Are we forming a completion item that's an exact match for an existing
190+
// function name that is already in the document?
191+
// This identifies scenarios where we need to edit text, not just insert it.
192+
let item_is_an_edit = name == function_context.name && !function_context.cursor_is_at_end;
193+
194+
// These settings are part of the trick we use to make it easy to accept
195+
// this matching completion item, which will feel like we're just moving
196+
// the cursor past existing text.
197+
if item_is_an_edit {
198+
item.sort_text = Some(format!("0-{name}"));
199+
item.preselect = Some(true);
200+
}
184201

185-
if parameter_hints.is_enabled() {
186-
item.insert_text_format = Some(InsertTextFormat::SNIPPET);
187-
item.insert_text = Some(format!("{insert_text}($0)"));
202+
if function_context.arguments_status == ArgumentsStatus::Absent {
203+
if item_is_an_edit {
204+
// This is a case like `dplyr::@across` or
205+
// `debug(dplyr::@across`), i.e. adding the `dplyr::`
206+
// namespace qualification after-the-fact.
207+
// We need to consume the existing function name (e.g. `across`) and
208+
// move the cursor to its end. We don't add parentheses, both
209+
// because it feels presumptuous and because we don't have a
210+
// practical way of doing so, in any case. We leave the arguments
211+
// just as we found them: absent.
212+
let text_edit = TextEdit {
213+
range: function_context.range,
214+
new_text: insert_text,
215+
};
216+
item.text_edit = Some(CompletionTextEdit::Edit(text_edit));
217+
218+
return Ok(item);
219+
}
188220

189-
// provide parameter completions after completing function
190-
item.command = Some(Command {
191-
title: "Trigger Parameter Hints".to_string(),
192-
command: "editor.action.triggerParameterHints".to_string(),
193-
..Default::default()
194-
});
195-
} else {
196-
item.insert_text_format = Some(InsertTextFormat::PLAIN_TEXT);
197-
item.insert_text = Some(insert_text);
221+
// These are the two most common, plain vanilla function completion
222+
// scenarios: typing out a known call or reference for the first time.
223+
match function_context.usage {
224+
FunctionRefUsage::Call => {
225+
item.insert_text_format = Some(InsertTextFormat::SNIPPET);
226+
item.insert_text = Some(format!("{insert_text}($0)"));
227+
item.command = Some(Command {
228+
title: "Trigger Parameter Hints".to_string(),
229+
command: "editor.action.triggerParameterHints".to_string(),
230+
..Default::default()
231+
});
232+
},
233+
FunctionRefUsage::Value => {
234+
item.insert_text_format = Some(InsertTextFormat::PLAIN_TEXT);
235+
item.insert_text = Some(insert_text);
236+
},
237+
}
238+
239+
return Ok(item);
240+
}
241+
242+
// Addresses a sequence that starts like this:
243+
// some_thing()
244+
// User realizes they want a different function and backspaces.
245+
// some_@()
246+
// User accepts one of the offered completions.
247+
// some_other_thing(@)
248+
// User is back on the Happy Path.
249+
// Also handles the case of editing `some_thing(foo = "bar")`, i.e. where
250+
// something already exists inside the parentheses.
251+
// Also handles the case of adding `pkg::` after the fact to an existing
252+
// call like `pkg::@fcn()` or `pkg::@fcn(foo = "bar)`.
253+
if function_context.usage == FunctionRefUsage::Call {
254+
// Tweak the range to cover the opening parenthesis "(" and
255+
// include the opening parenthesis in the new text.
256+
// The effect is to move the cursor inside the parentheses.
257+
let text_edit = TextEdit {
258+
range: {
259+
let mut range = function_context.range;
260+
range.end.character += 1;
261+
range
262+
},
263+
new_text: format!("{}(", insert_text),
264+
};
265+
item.text_edit = Some(CompletionTextEdit::Edit(text_edit));
266+
if function_context.arguments_status == ArgumentsStatus::Empty {
267+
item.command = Some(Command {
268+
title: "Trigger Parameter Hints".to_string(),
269+
command: "editor.action.triggerParameterHints".to_string(),
270+
..Default::default()
271+
});
272+
}
273+
274+
return Ok(item);
198275
}
199276

277+
// Fallback case which should really never happen, but let's be safe
278+
log::trace!(
279+
"completion_item_from_function() - Unexpected case:
280+
usage={usage:?},
281+
arguments_status={arguments_status:?},
282+
name='{name}',
283+
package={package:?},
284+
cursor_at_end={cursor_is_at_end}",
285+
usage = function_context.usage,
286+
arguments_status = function_context.arguments_status,
287+
cursor_is_at_end = function_context.cursor_is_at_end
288+
);
289+
290+
item.insert_text_format = Some(InsertTextFormat::PLAIN_TEXT);
291+
item.insert_text = Some(insert_text);
292+
200293
Ok(item)
201294
}
202295

@@ -250,7 +343,7 @@ pub(super) unsafe fn completion_item_from_object(
250343
envir: SEXP,
251344
package: Option<&str>,
252345
promise_strategy: PromiseStrategy,
253-
parameter_hints: &ParameterHints,
346+
function_context: &FunctionContext,
254347
) -> anyhow::Result<CompletionItem> {
255348
if r_typeof(object) == PROMSXP {
256349
return completion_item_from_promise(
@@ -259,7 +352,7 @@ pub(super) unsafe fn completion_item_from_object(
259352
envir,
260353
package,
261354
promise_strategy,
262-
parameter_hints,
355+
function_context,
263356
);
264357
}
265358

@@ -269,7 +362,7 @@ pub(super) unsafe fn completion_item_from_object(
269362
// In other words, when creating a completion item for these functions,
270363
// we should also figure out where we can receive the help from.
271364
if Rf_isFunction(object) != 0 {
272-
return completion_item_from_function(name, package, parameter_hints);
365+
return completion_item_from_function(name, package, function_context);
273366
}
274367

275368
let mut item = completion_item(name, CompletionData::Object {
@@ -300,7 +393,7 @@ pub(super) unsafe fn completion_item_from_promise(
300393
envir: SEXP,
301394
package: Option<&str>,
302395
promise_strategy: PromiseStrategy,
303-
parameter_hints: &ParameterHints,
396+
function_context: &FunctionContext,
304397
) -> anyhow::Result<CompletionItem> {
305398
if r_promise_is_forced(object) {
306399
// Promise has already been evaluated before.
@@ -312,7 +405,7 @@ pub(super) unsafe fn completion_item_from_promise(
312405
envir,
313406
package,
314407
promise_strategy,
315-
parameter_hints,
408+
function_context,
316409
);
317410
}
318411

@@ -329,7 +422,7 @@ pub(super) unsafe fn completion_item_from_promise(
329422
envir,
330423
package,
331424
promise_strategy,
332-
parameter_hints,
425+
function_context,
333426
);
334427
}
335428

@@ -370,7 +463,7 @@ pub(super) unsafe fn completion_item_from_namespace(
370463
name: &str,
371464
namespace: SEXP,
372465
package: &str,
373-
parameter_hints: &ParameterHints,
466+
function_context: &FunctionContext,
374467
) -> anyhow::Result<CompletionItem> {
375468
// We perform two passes to locate the object. It is normal for the first pass to
376469
// error when the `namespace` doesn't have a binding for `name` because the associated
@@ -385,7 +478,7 @@ pub(super) unsafe fn completion_item_from_namespace(
385478
namespace,
386479
Some(package),
387480
PromiseStrategy::Force,
388-
parameter_hints,
481+
function_context,
389482
) {
390483
Ok(item) => return Ok(item),
391484
Err(error) => error,
@@ -398,7 +491,7 @@ pub(super) unsafe fn completion_item_from_namespace(
398491
imports,
399492
Some(package),
400493
PromiseStrategy::Force,
401-
parameter_hints,
494+
function_context,
402495
) {
403496
Ok(item) => return Ok(item),
404497
Err(error) => error,
@@ -423,10 +516,21 @@ pub(super) unsafe fn completion_item_from_lazydata(
423516
let promise_strategy = PromiseStrategy::Simple;
424517

425518
// Lazydata objects are never functions, so this doesn't really matter
426-
let parameter_hints = ParameterHints::Disabled;
519+
let dummy_function_context = FunctionContext {
520+
name: String::new(),
521+
range: Default::default(),
522+
usage: FunctionRefUsage::Call,
523+
arguments_status: ArgumentsStatus::Absent,
524+
cursor_is_at_end: true,
525+
};
427526

428-
match completion_item_from_symbol(name, env, Some(package), promise_strategy, &parameter_hints)
429-
{
527+
match completion_item_from_symbol(
528+
name,
529+
env,
530+
Some(package),
531+
promise_strategy,
532+
&dummy_function_context,
533+
) {
430534
Ok(item) => Ok(item),
431535
Err(err) => {
432536
// Should be impossible, but we'll be extra safe
@@ -440,7 +544,7 @@ pub(super) unsafe fn completion_item_from_symbol(
440544
envir: SEXP,
441545
package: Option<&str>,
442546
promise_strategy: PromiseStrategy,
443-
parameter_hints: &ParameterHints,
547+
function_context: &FunctionContext,
444548
) -> anyhow::Result<CompletionItem> {
445549
let symbol = r_symbol!(name);
446550

@@ -477,7 +581,7 @@ pub(super) unsafe fn completion_item_from_symbol(
477581
envir,
478582
package,
479583
promise_strategy,
480-
parameter_hints,
584+
function_context,
481585
)
482586
}
483587

@@ -553,12 +657,11 @@ fn completion_item_from_dot_dot_dot(
553657
start: position,
554658
end: position,
555659
};
556-
let textedit = TextEdit {
660+
let text_edit = TextEdit {
557661
range,
558662
new_text: "".to_string(),
559663
};
560-
let textedit = CompletionTextEdit::Edit(textedit);
561-
item.text_edit = Some(textedit);
664+
item.text_edit = Some(CompletionTextEdit::Edit(text_edit));
562665

563666
Ok(item)
564667
}

0 commit comments

Comments
 (0)