Add completions.lsp_insert_mode setting to control what ranges are replaced when a completion is inserted (#27453)

This PR adds `completions.lsp_insert_mode` and effectively changes the
default from `"replace"` to `"replace_suffix"`, which automatically
detects whether to use the LSP `replace` range instead of `insert`
range.

`"replace_suffix"` was chosen as a default because it's more
conservative than `"replace_subsequence"`, considering that deleting
text is usually faster and less disruptive than having to rewrite a long
replaced word.

Fixes #27197
Fixes #23395 (again)
Fixes #4816 (again)

Release Notes:

- Added new setting `completions.lsp_insert_mode` that changes what will
be replaced when an LSP completion is accepted. The default is
`"replace_suffix"`, but it accepts 4 values: `"insert"` for replacing
only the text before the cursor, `"replace"` for replacing the whole
text, `"replace_suffix"` that acts like `"replace"` when the text after
the cursor is a suffix of the completion, and `"replace_subsequence"`
that acts like `"replace"` when the text around your cursor is a
subsequence of the completion (similiar to a fuzzy match). Check [the
documentation](https://zed.dev/docs/configuring-zed#LSP-Insert-Mode) for
more information.

---------

Co-authored-by: João Marcos <marcospb19@hotmail.com>
Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
This commit is contained in:
frederik-uni 2025-04-02 21:55:03 +02:00 committed by GitHub
parent 108ae0b5b0
commit 07a77792c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 467 additions and 13 deletions

View file

@ -23,7 +23,7 @@ use language::{
Override, Point,
language_settings::{
AllLanguageSettings, AllLanguageSettingsContent, CompletionSettings,
LanguageSettingsContent, PrettierSettings,
LanguageSettingsContent, LspInsertMode, PrettierSettings,
},
};
use language_settings::{Formatter, FormatterList, IndentGuideSettings};
@ -6382,7 +6382,7 @@ async fn test_autoindent_selections(cx: &mut TestAppContext) {
cx.run_until_parked();
cx.update(|_, cx| {
pretty_assertions::assert_eq!(
assert_eq!(
buffer.read(cx).text(),
indoc! { "
impl A {
@ -9198,6 +9198,203 @@ async fn test_signature_help(cx: &mut TestAppContext) {
.await;
}
#[gpui::test]
async fn test_completion_mode(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
completion_provider: Some(lsp::CompletionOptions {
resolve_provider: Some(true),
..Default::default()
}),
..Default::default()
},
cx,
)
.await;
struct Run {
run_description: &'static str,
initial_state: String,
buffer_marked_text: String,
completion_text: &'static str,
expected_with_insertion_mode: String,
expected_with_replace_mode: String,
expected_with_replace_subsequence_mode: String,
expected_with_replace_suffix_mode: String,
}
let runs = [
Run {
run_description: "Start of word matches completion text",
initial_state: "before ediˇ after".into(),
buffer_marked_text: "before <edi|> after".into(),
completion_text: "editor",
expected_with_insertion_mode: "before editorˇ after".into(),
expected_with_replace_mode: "before editorˇ after".into(),
expected_with_replace_subsequence_mode: "before editorˇ after".into(),
expected_with_replace_suffix_mode: "before editorˇ after".into(),
},
Run {
run_description: "Accept same text at the middle of the word",
initial_state: "before ediˇtor after".into(),
buffer_marked_text: "before <edi|tor> after".into(),
completion_text: "editor",
expected_with_insertion_mode: "before editorˇtor after".into(),
expected_with_replace_mode: "before ediˇtor after".into(),
expected_with_replace_subsequence_mode: "before ediˇtor after".into(),
expected_with_replace_suffix_mode: "before ediˇtor after".into(),
},
Run {
run_description: "End of word matches completion text -- cursor at end",
initial_state: "before torˇ after".into(),
buffer_marked_text: "before <tor|> after".into(),
completion_text: "editor",
expected_with_insertion_mode: "before editorˇ after".into(),
expected_with_replace_mode: "before editorˇ after".into(),
expected_with_replace_subsequence_mode: "before editorˇ after".into(),
expected_with_replace_suffix_mode: "before editorˇ after".into(),
},
Run {
run_description: "End of word matches completion text -- cursor at start",
initial_state: "before ˇtor after".into(),
buffer_marked_text: "before <|tor> after".into(),
completion_text: "editor",
expected_with_insertion_mode: "before editorˇtor after".into(),
expected_with_replace_mode: "before editorˇ after".into(),
expected_with_replace_subsequence_mode: "before editorˇ after".into(),
expected_with_replace_suffix_mode: "before editorˇ after".into(),
},
Run {
run_description: "Prepend text containing whitespace",
initial_state: "pˇfield: bool".into(),
buffer_marked_text: "<p|field>: bool".into(),
completion_text: "pub ",
expected_with_insertion_mode: "pub ˇfield: bool".into(),
expected_with_replace_mode: "pub ˇ: bool".into(),
expected_with_replace_subsequence_mode: "pub ˇfield: bool".into(),
expected_with_replace_suffix_mode: "pub ˇfield: bool".into(),
},
Run {
run_description: "Add element to start of list",
initial_state: "[element_ˇelement_2]".into(),
buffer_marked_text: "[<element_|element_2>]".into(),
completion_text: "element_1",
expected_with_insertion_mode: "[element_1ˇelement_2]".into(),
expected_with_replace_mode: "[element_1ˇ]".into(),
expected_with_replace_subsequence_mode: "[element_1ˇelement_2]".into(),
expected_with_replace_suffix_mode: "[element_1ˇelement_2]".into(),
},
Run {
run_description: "Add element to start of list -- first and second elements are equal",
initial_state: "[elˇelement]".into(),
buffer_marked_text: "[<el|element>]".into(),
completion_text: "element",
expected_with_insertion_mode: "[elementˇelement]".into(),
expected_with_replace_mode: "[elˇement]".into(),
expected_with_replace_subsequence_mode: "[elementˇelement]".into(),
expected_with_replace_suffix_mode: "[elˇement]".into(),
},
Run {
run_description: "Ends with matching suffix",
initial_state: "SubˇError".into(),
buffer_marked_text: "<Sub|Error>".into(),
completion_text: "SubscriptionError",
expected_with_insertion_mode: "SubscriptionErrorˇError".into(),
expected_with_replace_mode: "SubscriptionErrorˇ".into(),
expected_with_replace_subsequence_mode: "SubscriptionErrorˇ".into(),
expected_with_replace_suffix_mode: "SubscriptionErrorˇ".into(),
},
Run {
run_description: "Suffix is a subsequence -- contiguous",
initial_state: "SubˇErr".into(),
buffer_marked_text: "<Sub|Err>".into(),
completion_text: "SubscriptionError",
expected_with_insertion_mode: "SubscriptionErrorˇErr".into(),
expected_with_replace_mode: "SubscriptionErrorˇ".into(),
expected_with_replace_subsequence_mode: "SubscriptionErrorˇ".into(),
expected_with_replace_suffix_mode: "SubscriptionErrorˇErr".into(),
},
Run {
run_description: "Suffix is a subsequence -- non-contiguous -- replace intended",
initial_state: "Suˇscrirr".into(),
buffer_marked_text: "<Su|scrirr>".into(),
completion_text: "SubscriptionError",
expected_with_insertion_mode: "SubscriptionErrorˇscrirr".into(),
expected_with_replace_mode: "SubscriptionErrorˇ".into(),
expected_with_replace_subsequence_mode: "SubscriptionErrorˇ".into(),
expected_with_replace_suffix_mode: "SubscriptionErrorˇscrirr".into(),
},
Run {
run_description: "Suffix is a subsequence -- non-contiguous -- replace unintended",
initial_state: "foo(indˇix)".into(),
buffer_marked_text: "foo(<ind|ix>)".into(),
completion_text: "node_index",
expected_with_insertion_mode: "foo(node_indexˇix)".into(),
expected_with_replace_mode: "foo(node_indexˇ)".into(),
expected_with_replace_subsequence_mode: "foo(node_indexˇix)".into(),
expected_with_replace_suffix_mode: "foo(node_indexˇix)".into(),
},
];
for run in runs {
let run_variations = [
(LspInsertMode::Insert, run.expected_with_insertion_mode),
(LspInsertMode::Replace, run.expected_with_replace_mode),
(
LspInsertMode::ReplaceSubsequence,
run.expected_with_replace_subsequence_mode,
),
(
LspInsertMode::ReplaceSuffix,
run.expected_with_replace_suffix_mode,
),
];
for (lsp_insert_mode, expected_text) in run_variations {
eprintln!(
"run = {:?}, mode = {lsp_insert_mode:.?}",
run.run_description,
);
update_test_language_settings(&mut cx, |settings| {
settings.defaults.completions = Some(CompletionSettings {
lsp_insert_mode,
words: WordsCompletionMode::Disabled,
lsp: true,
lsp_fetch_timeout_ms: 0,
});
});
cx.set_state(&run.initial_state);
cx.update_editor(|editor, window, cx| {
editor.show_completions(&ShowCompletions { trigger: None }, window, cx);
});
let counter = Arc::new(AtomicUsize::new(0));
handle_completion_request_with_insert_and_replace(
&mut cx,
&run.buffer_marked_text,
vec![run.completion_text],
counter.clone(),
)
.await;
cx.condition(|editor, _| editor.context_menu_visible())
.await;
assert_eq!(counter.load(atomic::Ordering::Acquire), 1);
let apply_additional_edits = cx.update_editor(|editor, window, cx| {
editor
.confirm_completion(&ConfirmCompletion::default(), window, cx)
.unwrap()
});
cx.assert_editor_state(&expected_text);
handle_resolve_completion_request(&mut cx, None).await;
apply_additional_edits.await.unwrap();
}
}
}
#[gpui::test]
async fn test_completion(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@ -9419,6 +9616,7 @@ async fn test_word_completion(cx: &mut TestAppContext) {
words: WordsCompletionMode::Fallback,
lsp: true,
lsp_fetch_timeout_ms: 10,
lsp_insert_mode: LspInsertMode::Insert,
});
});
@ -9514,6 +9712,7 @@ async fn test_word_completions_do_not_duplicate_lsp_ones(cx: &mut TestAppContext
words: WordsCompletionMode::Enabled,
lsp: true,
lsp_fetch_timeout_ms: 0,
lsp_insert_mode: LspInsertMode::Insert,
});
});
@ -9576,6 +9775,7 @@ async fn test_word_completions_continue_on_typing(cx: &mut TestAppContext) {
words: WordsCompletionMode::Disabled,
lsp: true,
lsp_fetch_timeout_ms: 0,
lsp_insert_mode: LspInsertMode::Insert,
});
});
@ -9648,6 +9848,7 @@ async fn test_word_completions_usually_skip_digits(cx: &mut TestAppContext) {
words: WordsCompletionMode::Fallback,
lsp: false,
lsp_fetch_timeout_ms: 0,
lsp_insert_mode: LspInsertMode::Insert,
});
});
@ -18482,7 +18683,10 @@ pub fn handle_signature_help_request(
/// Handle completion request passing a marked string specifying where the completion
/// should be triggered from using '|' character, what range should be replaced, and what completions
/// should be returned using '<' and '>' to delimit the range
/// should be returned using '<' and '>' to delimit the range.
///
/// Also see `handle_completion_request_with_insert_and_replace`.
#[track_caller]
pub fn handle_completion_request(
cx: &mut EditorLspTestContext,
marked_string: &str,
@ -18532,6 +18736,66 @@ pub fn handle_completion_request(
}
}
/// Similar to `handle_completion_request`, but a [`CompletionTextEdit::InsertAndReplace`] will be
/// given instead, which also contains an `insert` range.
///
/// This function uses the cursor position to mimic what Rust-Analyzer provides as the `insert` range,
/// that is, `replace_range.start..cursor_pos`.
pub fn handle_completion_request_with_insert_and_replace(
cx: &mut EditorLspTestContext,
marked_string: &str,
completions: Vec<&'static str>,
counter: Arc<AtomicUsize>,
) -> impl Future<Output = ()> {
let complete_from_marker: TextRangeMarker = '|'.into();
let replace_range_marker: TextRangeMarker = ('<', '>').into();
let (_, mut marked_ranges) = marked_text_ranges_by(
marked_string,
vec![complete_from_marker.clone(), replace_range_marker.clone()],
);
let complete_from_position =
cx.to_lsp(marked_ranges.remove(&complete_from_marker).unwrap()[0].start);
let replace_range =
cx.to_lsp_range(marked_ranges.remove(&replace_range_marker).unwrap()[0].clone());
let mut request =
cx.set_request_handler::<lsp::request::Completion, _, _>(move |url, params, _| {
let completions = completions.clone();
counter.fetch_add(1, atomic::Ordering::Release);
async move {
assert_eq!(params.text_document_position.text_document.uri, url.clone());
assert_eq!(
params.text_document_position.position, complete_from_position,
"marker `|` position doesn't match",
);
Ok(Some(lsp::CompletionResponse::Array(
completions
.iter()
.map(|completion_text| lsp::CompletionItem {
label: completion_text.to_string(),
text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace(
lsp::InsertReplaceEdit {
insert: lsp::Range {
start: replace_range.start,
end: complete_from_position,
},
replace: replace_range,
new_text: completion_text.to_string(),
},
)),
..Default::default()
})
.collect(),
)))
}
});
async move {
request.next().await;
}
}
fn handle_resolve_completion_request(
cx: &mut EditorLspTestContext,
edits: Option<Vec<(&'static str, &'static str)>>,