Detailed changes
@@ -109,6 +109,8 @@ impl ContextPickerCompletionProvider {
icon_path: Some(mode.icon().path().into()),
documentation: None,
source: project::CompletionSource::Custom,
+ match_start: None,
+ snippet_deduplication_key: None,
insert_text_mode: None,
// This ensures that when a user accepts this completion, the
// completion menu will still be shown after "@category " is
@@ -146,6 +148,8 @@ impl ContextPickerCompletionProvider {
documentation: None,
insert_text_mode: None,
source: project::CompletionSource::Custom,
+ match_start: None,
+ snippet_deduplication_key: None,
icon_path: Some(icon_for_completion),
confirm: Some(confirm_completion_callback(
thread_entry.title().clone(),
@@ -177,6 +181,8 @@ impl ContextPickerCompletionProvider {
documentation: None,
insert_text_mode: None,
source: project::CompletionSource::Custom,
+ match_start: None,
+ snippet_deduplication_key: None,
icon_path: Some(icon_path),
confirm: Some(confirm_completion_callback(
rule.title,
@@ -233,6 +239,8 @@ impl ContextPickerCompletionProvider {
documentation: None,
source: project::CompletionSource::Custom,
icon_path: Some(completion_icon_path),
+ match_start: None,
+ snippet_deduplication_key: None,
insert_text_mode: None,
confirm: Some(confirm_completion_callback(
file_name,
@@ -284,6 +292,8 @@ impl ContextPickerCompletionProvider {
documentation: None,
source: project::CompletionSource::Custom,
icon_path: Some(icon_path),
+ match_start: None,
+ snippet_deduplication_key: None,
insert_text_mode: None,
confirm: Some(confirm_completion_callback(
symbol.name.into(),
@@ -316,6 +326,8 @@ impl ContextPickerCompletionProvider {
documentation: None,
source: project::CompletionSource::Custom,
icon_path: Some(icon_path),
+ match_start: None,
+ snippet_deduplication_key: None,
insert_text_mode: None,
confirm: Some(confirm_completion_callback(
url_to_fetch.to_string().into(),
@@ -384,6 +396,8 @@ impl ContextPickerCompletionProvider {
icon_path: Some(action.icon().path().into()),
documentation: None,
source: project::CompletionSource::Custom,
+ match_start: None,
+ snippet_deduplication_key: None,
insert_text_mode: None,
// This ensures that when a user accepts this completion, the
// completion menu will still be shown after "@category " is
@@ -774,6 +788,8 @@ impl CompletionProvider for ContextPickerCompletionProvider {
)),
source: project::CompletionSource::Custom,
icon_path: None,
+ match_start: None,
+ snippet_deduplication_key: None,
insert_text_mode: None,
confirm: Some(Arc::new({
let editor = editor.clone();
@@ -278,6 +278,8 @@ impl ContextPickerCompletionProvider {
icon_path: Some(mode.icon().path().into()),
documentation: None,
source: project::CompletionSource::Custom,
+ match_start: None,
+ snippet_deduplication_key: None,
insert_text_mode: None,
// This ensures that when a user accepts this completion, the
// completion menu will still be shown after "@category " is
@@ -386,6 +388,8 @@ impl ContextPickerCompletionProvider {
icon_path: Some(action.icon().path().into()),
documentation: None,
source: project::CompletionSource::Custom,
+ match_start: None,
+ snippet_deduplication_key: None,
insert_text_mode: None,
// This ensures that when a user accepts this completion, the
// completion menu will still be shown after "@category " is
@@ -417,6 +421,8 @@ impl ContextPickerCompletionProvider {
replace_range: source_range.clone(),
new_text,
label: CodeLabel::plain(thread_entry.title().to_string(), None),
+ match_start: None,
+ snippet_deduplication_key: None,
documentation: None,
insert_text_mode: None,
source: project::CompletionSource::Custom,
@@ -484,6 +490,8 @@ impl ContextPickerCompletionProvider {
replace_range: source_range.clone(),
new_text,
label: CodeLabel::plain(rules.title.to_string(), None),
+ match_start: None,
+ snippet_deduplication_key: None,
documentation: None,
insert_text_mode: None,
source: project::CompletionSource::Custom,
@@ -524,6 +532,8 @@ impl ContextPickerCompletionProvider {
documentation: None,
source: project::CompletionSource::Custom,
icon_path: Some(IconName::ToolWeb.path().into()),
+ match_start: None,
+ snippet_deduplication_key: None,
insert_text_mode: None,
confirm: Some(confirm_completion_callback(
IconName::ToolWeb.path().into(),
@@ -612,6 +622,8 @@ impl ContextPickerCompletionProvider {
documentation: None,
source: project::CompletionSource::Custom,
icon_path: Some(completion_icon_path),
+ match_start: None,
+ snippet_deduplication_key: None,
insert_text_mode: None,
confirm: Some(confirm_completion_callback(
crease_icon_path,
@@ -689,6 +701,8 @@ impl ContextPickerCompletionProvider {
documentation: None,
source: project::CompletionSource::Custom,
icon_path: Some(IconName::Code.path().into()),
+ match_start: None,
+ snippet_deduplication_key: None,
insert_text_mode: None,
confirm: Some(confirm_completion_callback(
IconName::Code.path().into(),
@@ -127,6 +127,8 @@ impl SlashCommandCompletionProvider {
new_text,
label: command.label(cx),
icon_path: None,
+ match_start: None,
+ snippet_deduplication_key: None,
insert_text_mode: None,
confirm,
source: CompletionSource::Custom,
@@ -232,6 +234,8 @@ impl SlashCommandCompletionProvider {
icon_path: None,
new_text,
documentation: None,
+ match_start: None,
+ snippet_deduplication_key: None,
confirm,
insert_text_mode: None,
source: CompletionSource::Custom,
@@ -677,6 +677,8 @@ impl ConsoleQueryBarCompletionProvider {
),
new_text: string_match.string.clone(),
label: CodeLabel::plain(string_match.string.clone(), None),
+ match_start: None,
+ snippet_deduplication_key: None,
icon_path: None,
documentation: Some(CompletionDocumentation::MultiLineMarkdown(
variable_value.into(),
@@ -790,6 +792,8 @@ impl ConsoleQueryBarCompletionProvider {
documentation: completion.detail.map(|detail| {
CompletionDocumentation::MultiLineMarkdown(detail.into())
}),
+ match_start: None,
+ snippet_deduplication_key: None,
confirm: None,
source: project::CompletionSource::Dap { sort_text },
insert_text_mode: None,
@@ -305,6 +305,8 @@ impl CompletionBuilder {
icon_path: None,
insert_text_mode: None,
confirm: None,
+ match_start: None,
+ snippet_deduplication_key: None,
}
}
}
@@ -17,7 +17,6 @@ use project::{CompletionDisplayOptions, CompletionSource};
use task::DebugScenario;
use task::TaskContext;
-use std::collections::VecDeque;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::{
@@ -36,12 +35,13 @@ use util::ResultExt;
use crate::hover_popover::{hover_markdown_style, open_markdown_url};
use crate::{
- CodeActionProvider, CompletionId, CompletionItemKind, CompletionProvider, DisplayRow, Editor,
- EditorStyle, ResolvedTasks,
+ CodeActionProvider, CompletionId, CompletionProvider, DisplayRow, Editor, EditorStyle,
+ ResolvedTasks,
actions::{ConfirmCodeAction, ConfirmCompletion},
split_words, styled_runs_for_code_label,
};
use crate::{CodeActionSource, EditorSettings};
+use collections::{HashSet, VecDeque};
use settings::{Settings, SnippetSortOrder};
pub const MENU_GAP: Pixels = px(4.);
@@ -220,7 +220,9 @@ pub struct CompletionsMenu {
pub is_incomplete: bool,
pub buffer: Entity<Buffer>,
pub completions: Rc<RefCell<Box<[Completion]>>>,
- match_candidates: Arc<[StringMatchCandidate]>,
+ /// String match candidate for each completion, grouped by `match_start`.
+ match_candidates: Arc<[(Option<text::Anchor>, Vec<StringMatchCandidate>)]>,
+ /// Entries displayed in the menu, which is a filtered and sorted subset of `match_candidates`.
pub entries: Rc<RefCell<Box<[StringMatch]>>>,
pub selected_item: usize,
filter_task: Task<()>,
@@ -308,6 +310,8 @@ impl CompletionsMenu {
.iter()
.enumerate()
.map(|(id, completion)| StringMatchCandidate::new(id, completion.label.filter_text()))
+ .into_group_map_by(|candidate| completions[candidate.id].match_start)
+ .into_iter()
.collect();
let completions_menu = Self {
@@ -355,6 +359,8 @@ impl CompletionsMenu {
replace_range: selection.start.text_anchor..selection.end.text_anchor,
new_text: choice.to_string(),
label: CodeLabel::plain(choice.to_string(), None),
+ match_start: None,
+ snippet_deduplication_key: None,
icon_path: None,
documentation: None,
confirm: None,
@@ -363,11 +369,14 @@ impl CompletionsMenu {
})
.collect();
- let match_candidates = choices
- .iter()
- .enumerate()
- .map(|(id, completion)| StringMatchCandidate::new(id, completion))
- .collect();
+ let match_candidates = Arc::new([(
+ None,
+ choices
+ .iter()
+ .enumerate()
+ .map(|(id, completion)| StringMatchCandidate::new(id, completion))
+ .collect(),
+ )]);
let entries = choices
.iter()
.enumerate()
@@ -948,7 +957,7 @@ impl CompletionsMenu {
}
let mat = &self.entries.borrow()[self.selected_item];
- let completions = self.completions.borrow_mut();
+ let completions = self.completions.borrow();
let multiline_docs = match completions[mat.candidate_id].documentation.as_ref() {
Some(CompletionDocumentation::MultiLinePlainText(text)) => div().child(text.clone()),
Some(CompletionDocumentation::SingleLineAndMultiLinePlainText {
@@ -1026,57 +1035,74 @@ impl CompletionsMenu {
pub fn filter(
&mut self,
- query: Option<Arc<String>>,
+ query: Arc<String>,
+ query_end: text::Anchor,
+ buffer: &Entity<Buffer>,
provider: Option<Rc<dyn CompletionProvider>>,
window: &mut Window,
cx: &mut Context<Editor>,
) {
self.cancel_filter.store(true, Ordering::Relaxed);
- if let Some(query) = query {
- self.cancel_filter = Arc::new(AtomicBool::new(false));
- let matches = self.do_async_filtering(query, cx);
- let id = self.id;
- self.filter_task = cx.spawn_in(window, async move |editor, cx| {
- let matches = matches.await;
- editor
- .update_in(cx, |editor, window, cx| {
- editor.with_completions_menu_matching_id(id, |this| {
- if let Some(this) = this {
- this.set_filter_results(matches, provider, window, cx);
- }
- });
- })
- .ok();
- });
- } else {
- self.filter_task = Task::ready(());
- let matches = self.unfiltered_matches();
- self.set_filter_results(matches, provider, window, cx);
- }
+ self.cancel_filter = Arc::new(AtomicBool::new(false));
+ let matches = self.do_async_filtering(query, query_end, buffer, cx);
+ let id = self.id;
+ self.filter_task = cx.spawn_in(window, async move |editor, cx| {
+ let matches = matches.await;
+ editor
+ .update_in(cx, |editor, window, cx| {
+ editor.with_completions_menu_matching_id(id, |this| {
+ if let Some(this) = this {
+ this.set_filter_results(matches, provider, window, cx);
+ }
+ });
+ })
+ .ok();
+ });
}
pub fn do_async_filtering(
&self,
query: Arc<String>,
+ query_end: text::Anchor,
+ buffer: &Entity<Buffer>,
cx: &Context<Editor>,
) -> Task<Vec<StringMatch>> {
- let matches_task = cx.background_spawn({
- let query = query.clone();
- let match_candidates = self.match_candidates.clone();
- let cancel_filter = self.cancel_filter.clone();
- let background_executor = cx.background_executor().clone();
- async move {
- fuzzy::match_strings(
- &match_candidates,
- &query,
- query.chars().any(|c| c.is_uppercase()),
- false,
- 1000,
- &cancel_filter,
- background_executor,
- )
- .await
+ let buffer_snapshot = buffer.read(cx).snapshot();
+ let background_executor = cx.background_executor().clone();
+ let match_candidates = self.match_candidates.clone();
+ let cancel_filter = self.cancel_filter.clone();
+ let default_query = query.clone();
+
+ let matches_task = cx.background_spawn(async move {
+ let queries_and_candidates = match_candidates
+ .iter()
+ .map(|(query_start, candidates)| {
+ let query_for_batch = match query_start {
+ Some(start) => {
+ Arc::new(buffer_snapshot.text_for_range(*start..query_end).collect())
+ }
+ None => default_query.clone(),
+ };
+ (query_for_batch, candidates)
+ })
+ .collect_vec();
+
+ let mut results = vec![];
+ for (query, match_candidates) in queries_and_candidates {
+ results.extend(
+ fuzzy::match_strings(
+ &match_candidates,
+ &query,
+ query.chars().any(|c| c.is_uppercase()),
+ false,
+ 1000,
+ &cancel_filter,
+ background_executor.clone(),
+ )
+ .await,
+ );
}
+ results
});
let completions = self.completions.clone();
@@ -1085,45 +1111,31 @@ impl CompletionsMenu {
cx.foreground_executor().spawn(async move {
let mut matches = matches_task.await;
+ let completions_ref = completions.borrow();
+
if sort_completions {
matches = Self::sort_string_matches(
matches,
- Some(&query),
+ Some(&query), // used for non-snippets only
snippet_sort_order,
- completions.borrow().as_ref(),
+ &completions_ref,
);
}
+ // Remove duplicate snippet prefixes (e.g., "cool code" will match
+ // the text "c c" in two places; we should only show the longer one)
+ let mut snippets_seen = HashSet::<(usize, usize)>::default();
+ matches.retain(|result| {
+ match completions_ref[result.candidate_id].snippet_deduplication_key {
+ Some(key) => snippets_seen.insert(key),
+ None => true,
+ }
+ });
+
matches
})
}
- /// Like `do_async_filtering` but there is no filter query, so no need to spawn tasks.
- pub fn unfiltered_matches(&self) -> Vec<StringMatch> {
- let mut matches = self
- .match_candidates
- .iter()
- .enumerate()
- .map(|(candidate_id, candidate)| StringMatch {
- candidate_id,
- score: Default::default(),
- positions: Default::default(),
- string: candidate.string.clone(),
- })
- .collect();
-
- if self.sort_completions {
- matches = Self::sort_string_matches(
- matches,
- None,
- self.snippet_sort_order,
- self.completions.borrow().as_ref(),
- );
- }
-
- matches
- }
-
pub fn set_filter_results(
&mut self,
matches: Vec<StringMatch>,
@@ -1166,28 +1178,13 @@ impl CompletionsMenu {
.and_then(|c| c.to_lowercase().next());
if snippet_sort_order == SnippetSortOrder::None {
- matches.retain(|string_match| {
- let completion = &completions[string_match.candidate_id];
-
- let is_snippet = matches!(
- &completion.source,
- CompletionSource::Lsp { lsp_completion, .. }
- if lsp_completion.kind == Some(CompletionItemKind::SNIPPET)
- );
-
- !is_snippet
- });
+ matches
+ .retain(|string_match| !completions[string_match.candidate_id].is_snippet_kind());
}
matches.sort_unstable_by_key(|string_match| {
let completion = &completions[string_match.candidate_id];
- let is_snippet = matches!(
- &completion.source,
- CompletionSource::Lsp { lsp_completion, .. }
- if lsp_completion.kind == Some(CompletionItemKind::SNIPPET)
- );
-
let sort_text = match &completion.source {
CompletionSource::Lsp { lsp_completion, .. } => lsp_completion.sort_text.as_deref(),
CompletionSource::Dap { sort_text } => Some(sort_text.as_str()),
@@ -1199,14 +1196,17 @@ impl CompletionsMenu {
let score = string_match.score;
let sort_score = Reverse(OrderedFloat(score));
- let query_start_doesnt_match_split_words = query_start_lower
- .map(|query_char| {
- !split_words(&string_match.string).any(|word| {
- word.chars().next().and_then(|c| c.to_lowercase().next())
- == Some(query_char)
+ // Snippets do their own first-letter matching logic elsewhere.
+ let is_snippet = completion.is_snippet_kind();
+ let query_start_doesnt_match_split_words = !is_snippet
+ && query_start_lower
+ .map(|query_char| {
+ !split_words(&string_match.string).any(|word| {
+ word.chars().next().and_then(|c| c.to_lowercase().next())
+ == Some(query_char)
+ })
})
- })
- .unwrap_or(false);
+ .unwrap_or(false);
if query_start_doesnt_match_split_words {
MatchTier::OtherMatch { sort_score }
@@ -1218,6 +1218,7 @@ impl CompletionsMenu {
SnippetSortOrder::None => Reverse(0),
};
let sort_positions = string_match.positions.clone();
+ // This exact matching won't work for multi-word snippets, but it's fine
let sort_exact = Reverse(if Some(completion.label.filter_text()) == query {
1
} else {
@@ -5517,7 +5517,14 @@ impl Editor {
if let Some(CodeContextMenu::Completions(menu)) = self.context_menu.borrow_mut().as_mut() {
if filter_completions {
- menu.filter(query.clone(), provider.clone(), window, cx);
+ menu.filter(
+ query.clone().unwrap_or_default(),
+ buffer_position.text_anchor,
+ &buffer,
+ provider.clone(),
+ window,
+ cx,
+ );
}
// When `is_incomplete` is false, no need to re-query completions when the current query
// is a suffix of the initial query.
@@ -5526,7 +5533,7 @@ impl Editor {
// If the new query is a suffix of the old query (typing more characters) and
// the previous result was complete, the existing completions can be filtered.
//
- // Note that this is always true for snippet completions.
+ // Note that snippet completions are always complete.
let query_matches = match (&menu.initial_query, &query) {
(Some(initial_query), Some(query)) => query.starts_with(initial_query.as_ref()),
(None, _) => true,
@@ -5656,12 +5663,15 @@ impl Editor {
};
let mut words = if load_word_completions {
- cx.background_spawn(async move {
- buffer_snapshot.words_in_range(WordsQuery {
- fuzzy_contents: None,
- range: word_search_range,
- skip_digits,
- })
+ cx.background_spawn({
+ let buffer_snapshot = buffer_snapshot.clone();
+ async move {
+ buffer_snapshot.words_in_range(WordsQuery {
+ fuzzy_contents: None,
+ range: word_search_range,
+ skip_digits,
+ })
+ }
})
} else {
Task::ready(BTreeMap::default())
@@ -5671,8 +5681,11 @@ impl Editor {
&& provider.show_snippets()
&& let Some(project) = self.project()
{
+ let char_classifier = buffer_snapshot
+ .char_classifier_at(buffer_position)
+ .scope_context(Some(CharScopeContext::Completion));
project.update(cx, |project, cx| {
- snippet_completions(project, &buffer, buffer_position, cx)
+ snippet_completions(project, &buffer, buffer_position, char_classifier, cx)
})
} else {
Task::ready(Ok(CompletionResponse {
@@ -5727,6 +5740,8 @@ impl Editor {
replace_range: word_replace_range.clone(),
new_text: word.clone(),
label: CodeLabel::plain(word, None),
+ match_start: None,
+ snippet_deduplication_key: None,
icon_path: None,
documentation: None,
source: CompletionSource::BufferWord {
@@ -5775,11 +5790,12 @@ impl Editor {
);
let query = if filter_completions { query } else { None };
- let matches_task = if let Some(query) = query {
- menu.do_async_filtering(query, cx)
- } else {
- Task::ready(menu.unfiltered_matches())
- };
+ let matches_task = menu.do_async_filtering(
+ query.unwrap_or_default(),
+ buffer_position,
+ &buffer,
+ cx,
+ );
(menu, matches_task)
}) else {
return;
@@ -5796,7 +5812,7 @@ impl Editor {
return;
};
- // Only valid to take prev_menu because it the new menu is immediately set
+ // Only valid to take prev_menu because either the new menu is immediately set
// below, or the menu is hidden.
if let Some(CodeContextMenu::Completions(prev_menu)) =
editor.context_menu.borrow_mut().take()
@@ -23201,10 +23217,11 @@ impl CodeActionProvider for Entity<Project> {
fn snippet_completions(
project: &Project,
buffer: &Entity<Buffer>,
- buffer_position: text::Anchor,
+ buffer_anchor: text::Anchor,
+ classifier: CharClassifier,
cx: &mut App,
) -> Task<Result<CompletionResponse>> {
- let languages = buffer.read(cx).languages_at(buffer_position);
+ let languages = buffer.read(cx).languages_at(buffer_anchor);
let snippet_store = project.snippets().read(cx);
let scopes: Vec<_> = languages
@@ -23233,97 +23250,146 @@ fn snippet_completions(
let executor = cx.background_executor().clone();
cx.background_spawn(async move {
+ let is_word_char = |c| classifier.is_word(c);
+
let mut is_incomplete = false;
let mut completions: Vec<Completion> = Vec::new();
- for (scope, snippets) in scopes.into_iter() {
- let classifier =
- CharClassifier::new(Some(scope)).scope_context(Some(CharScopeContext::Completion));
-
- const MAX_WORD_PREFIX_LEN: usize = 128;
- let last_word: String = snapshot
- .reversed_chars_for_range(text::Anchor::MIN..buffer_position)
- .take(MAX_WORD_PREFIX_LEN)
- .take_while(|c| classifier.is_word(*c))
- .collect::<String>()
- .chars()
- .rev()
- .collect();
- if last_word.is_empty() {
- return Ok(CompletionResponse {
- completions: vec![],
- display_options: CompletionDisplayOptions::default(),
- is_incomplete: true,
- });
- }
+ const MAX_PREFIX_LEN: usize = 128;
+ let buffer_offset = text::ToOffset::to_offset(&buffer_anchor, &snapshot);
+ let window_start = buffer_offset.saturating_sub(MAX_PREFIX_LEN);
+ let window_start = snapshot.clip_offset(window_start, Bias::Left);
- let as_offset = text::ToOffset::to_offset(&buffer_position, &snapshot);
- let to_lsp = |point: &text::Anchor| {
- let end = text::ToPointUtf16::to_point_utf16(point, &snapshot);
- point_to_lsp(end)
- };
- let lsp_end = to_lsp(&buffer_position);
+ let max_buffer_window: String = snapshot
+ .text_for_range(window_start..buffer_offset)
+ .collect();
+
+ if max_buffer_window.is_empty() {
+ return Ok(CompletionResponse {
+ completions: vec![],
+ display_options: CompletionDisplayOptions::default(),
+ is_incomplete: true,
+ });
+ }
- let candidates = snippets
+ for (_scope, snippets) in scopes.into_iter() {
+ // Sort snippets by word count to match longer snippet prefixes first.
+ let mut sorted_snippet_candidates = snippets
.iter()
.enumerate()
- .flat_map(|(ix, snippet)| {
+ .flat_map(|(snippet_ix, snippet)| {
snippet
.prefix
.iter()
- .map(move |prefix| StringMatchCandidate::new(ix, prefix))
+ .enumerate()
+ .map(move |(prefix_ix, prefix)| {
+ let word_count =
+ snippet_candidate_suffixes(prefix, is_word_char).count();
+ ((snippet_ix, prefix_ix), prefix, word_count)
+ })
})
- .collect::<Vec<StringMatchCandidate>>();
+ .collect_vec();
+ sorted_snippet_candidates
+ .sort_unstable_by_key(|(_, _, word_count)| Reverse(*word_count));
+
+ // Each prefix may be matched multiple times; the completion menu must filter out duplicates.
+
+ let buffer_windows = snippet_candidate_suffixes(&max_buffer_window, is_word_char)
+ .take(
+ sorted_snippet_candidates
+ .first()
+ .map(|(_, _, word_count)| *word_count)
+ .unwrap_or_default(),
+ )
+ .collect_vec();
const MAX_RESULTS: usize = 100;
- let mut matches = fuzzy::match_strings(
- &candidates,
- &last_word,
- last_word.chars().any(|c| c.is_uppercase()),
- true,
- MAX_RESULTS,
- &Default::default(),
- executor.clone(),
- )
- .await;
+ // Each match also remembers how many characters from the buffer it consumed
+ let mut matches: Vec<(StringMatch, usize)> = vec![];
+
+ let mut snippet_list_cutoff_index = 0;
+ for (buffer_index, buffer_window) in buffer_windows.iter().enumerate().rev() {
+ let word_count = buffer_index + 1;
+ // Increase `snippet_list_cutoff_index` until we have all of the
+ // snippets with sufficiently many words.
+ while sorted_snippet_candidates
+ .get(snippet_list_cutoff_index)
+ .is_some_and(|(_ix, _prefix, snippet_word_count)| {
+ *snippet_word_count >= word_count
+ })
+ {
+ snippet_list_cutoff_index += 1;
+ }
- if matches.len() >= MAX_RESULTS {
- is_incomplete = true;
- }
+ // Take only the candidates with at least `word_count` many words
+ let snippet_candidates_at_word_len =
+ &sorted_snippet_candidates[..snippet_list_cutoff_index];
- // Remove all candidates where the query's start does not match the start of any word in the candidate
- if let Some(query_start) = last_word.chars().next() {
- matches.retain(|string_match| {
- split_words(&string_match.string).any(|word| {
- // Check that the first codepoint of the word as lowercase matches the first
- // codepoint of the query as lowercase
- word.chars()
- .flat_map(|codepoint| codepoint.to_lowercase())
- .zip(query_start.to_lowercase())
- .all(|(word_cp, query_cp)| word_cp == query_cp)
+ let candidates = snippet_candidates_at_word_len
+ .iter()
+ .map(|(_snippet_ix, prefix, _snippet_word_count)| prefix)
+ .enumerate() // index in `sorted_snippet_candidates`
+ // First char must match
+ .filter(|(_ix, prefix)| {
+ itertools::equal(
+ prefix
+ .chars()
+ .next()
+ .into_iter()
+ .flat_map(|c| c.to_lowercase()),
+ buffer_window
+ .chars()
+ .next()
+ .into_iter()
+ .flat_map(|c| c.to_lowercase()),
+ )
})
- });
+ .map(|(ix, prefix)| StringMatchCandidate::new(ix, prefix))
+ .collect::<Vec<StringMatchCandidate>>();
+
+ matches.extend(
+ fuzzy::match_strings(
+ &candidates,
+ &buffer_window,
+ buffer_window.chars().any(|c| c.is_uppercase()),
+ true,
+ MAX_RESULTS - matches.len(), // always prioritize longer snippets
+ &Default::default(),
+ executor.clone(),
+ )
+ .await
+ .into_iter()
+ .map(|string_match| (string_match, buffer_window.len())),
+ );
+
+ if matches.len() >= MAX_RESULTS {
+ break;
+ }
}
- let matched_strings = matches
- .into_iter()
- .map(|m| m.string)
- .collect::<HashSet<_>>();
+ let to_lsp = |point: &text::Anchor| {
+ let end = text::ToPointUtf16::to_point_utf16(point, &snapshot);
+ point_to_lsp(end)
+ };
+ let lsp_end = to_lsp(&buffer_anchor);
- completions.extend(snippets.iter().filter_map(|snippet| {
- let matching_prefix = snippet
- .prefix
- .iter()
- .find(|prefix| matched_strings.contains(*prefix))?;
- let start = as_offset - last_word.len();
+ if matches.len() >= MAX_RESULTS {
+ is_incomplete = true;
+ }
+
+ completions.extend(matches.iter().map(|(string_match, buffer_window_len)| {
+ let ((snippet_index, prefix_index), matching_prefix, _snippet_word_count) =
+ sorted_snippet_candidates[string_match.candidate_id];
+ let snippet = &snippets[snippet_index];
+ let start = buffer_offset - buffer_window_len;
let start = snapshot.anchor_before(start);
- let range = start..buffer_position;
+ let range = start..buffer_anchor;
let lsp_start = to_lsp(&start);
let lsp_range = lsp::Range {
start: lsp_start,
end: lsp_end,
};
- Some(Completion {
+ Completion {
replace_range: range,
new_text: snippet.body.clone(),
source: CompletionSource::Lsp {
@@ -23353,7 +23419,11 @@ fn snippet_completions(
}),
lsp_defaults: None,
},
- label: CodeLabel::plain(matching_prefix.clone(), None),
+ label: CodeLabel {
+ text: matching_prefix.clone(),
+ runs: Vec::new(),
+ filter_range: 0..matching_prefix.len(),
+ },
icon_path: None,
documentation: Some(CompletionDocumentation::SingleLineAndMultiLinePlainText {
single_line: snippet.name.clone().into(),
@@ -23364,8 +23434,10 @@ fn snippet_completions(
}),
insert_text_mode: None,
confirm: None,
- })
- }))
+ match_start: Some(start),
+ snippet_deduplication_key: Some((snippet_index, prefix_index)),
+ }
+ }));
}
Ok(CompletionResponse {
@@ -24611,6 +24683,33 @@ pub(crate) fn split_words(text: &str) -> impl std::iter::Iterator<Item = &str> +
})
}
+/// Given a string of text immediately before the cursor, iterates over possible
+/// strings a snippet could match to. More precisely: returns an iterator over
+/// suffixes of `text` created by splitting at word boundaries (before & after
+/// every non-word character).
+///
+/// Shorter suffixes are returned first.
+pub(crate) fn snippet_candidate_suffixes(
+ text: &str,
+ is_word_char: impl Fn(char) -> bool,
+) -> impl std::iter::Iterator<Item = &str> {
+ let mut prev_index = text.len();
+ let mut prev_codepoint = None;
+ text.char_indices()
+ .rev()
+ .chain([(0, '\0')])
+ .filter_map(move |(index, codepoint)| {
+ let prev_index = std::mem::replace(&mut prev_index, index);
+ let prev_codepoint = prev_codepoint.replace(codepoint)?;
+ if is_word_char(prev_codepoint) && is_word_char(codepoint) {
+ None
+ } else {
+ let chunk = &text[prev_index..]; // go to end of string
+ Some(chunk)
+ }
+ })
+}
+
pub trait RangeToAnchorExt: Sized {
fn to_anchors(self, snapshot: &MultiBufferSnapshot) -> Range<Anchor>;
@@ -11414,6 +11414,53 @@ async fn test_snippet_indentation(cx: &mut TestAppContext) {
ˇ"});
}
+#[gpui::test]
+async fn test_snippet_with_multi_word_prefix(cx: &mut TestAppContext) {
+ init_test(cx, |_| {});
+
+ let mut cx = EditorTestContext::new(cx).await;
+ cx.update_editor(|editor, _, cx| {
+ editor.project().unwrap().update(cx, |project, cx| {
+ project.snippets().update(cx, |snippets, _cx| {
+ let snippet = project::snippet_provider::Snippet {
+ prefix: vec!["multi word".to_string()],
+ body: "this is many words".to_string(),
+ description: Some("description".to_string()),
+ name: "multi-word snippet test".to_string(),
+ };
+ snippets.add_snippet_for_test(
+ None,
+ PathBuf::from("test_snippets.json"),
+ vec![Arc::new(snippet)],
+ );
+ });
+ })
+ });
+
+ for (input_to_simulate, should_match_snippet) in [
+ ("m", true),
+ ("m ", true),
+ ("m w", true),
+ ("aa m w", true),
+ ("aa m g", false),
+ ] {
+ cx.set_state("ˇ");
+ cx.simulate_input(input_to_simulate); // fails correctly
+
+ cx.update_editor(|editor, _, _| {
+ let Some(CodeContextMenu::Completions(context_menu)) = &*editor.context_menu.borrow()
+ else {
+ assert!(!should_match_snippet); // no completions! don't even show the menu
+ return;
+ };
+ assert!(context_menu.visible());
+ let completions = context_menu.completions.borrow();
+
+ assert_eq!(!completions.is_empty(), should_match_snippet);
+ });
+ }
+}
+
#[gpui::test]
async fn test_document_format_during_save(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@@ -17369,6 +17416,41 @@ fn test_split_words() {
assert_eq!(split(":do_the_thing"), &[":", "do_", "the_", "thing"]);
}
+#[test]
+fn test_split_words_for_snippet_prefix() {
+ fn split(text: &str) -> Vec<&str> {
+ snippet_candidate_suffixes(text, |c| c.is_alphanumeric() || c == '_').collect()
+ }
+
+ assert_eq!(split("HelloWorld"), &["HelloWorld"]);
+ assert_eq!(split("hello_world"), &["hello_world"]);
+ assert_eq!(split("_hello_world_"), &["_hello_world_"]);
+ assert_eq!(split("Hello_World"), &["Hello_World"]);
+ assert_eq!(split("helloWOrld"), &["helloWOrld"]);
+ assert_eq!(split("helloworld"), &["helloworld"]);
+ assert_eq!(
+ split("this@is!@#$^many . symbols"),
+ &[
+ "symbols",
+ " symbols",
+ ". symbols",
+ " . symbols",
+ " . symbols",
+ " . symbols",
+ "many . symbols",
+ "^many . symbols",
+ "$^many . symbols",
+ "#$^many . symbols",
+ "@#$^many . symbols",
+ "!@#$^many . symbols",
+ "is!@#$^many . symbols",
+ "@is!@#$^many . symbols",
+ "this@is!@#$^many . symbols",
+ ],
+ );
+ assert_eq!(split("a.s"), &["s", ".s", "a.s"]);
+}
+
#[gpui::test]
async fn test_move_to_enclosing_bracket(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@@ -25620,6 +25702,195 @@ pub fn check_displayed_completions(expected: Vec<&'static str>, cx: &mut EditorL
});
}
+#[gpui::test]
+async fn test_mixed_completions_with_multi_word_snippet(cx: &mut TestAppContext) {
+ init_test(cx, |_| {});
+ let mut cx = EditorLspTestContext::new_rust(
+ lsp::ServerCapabilities {
+ completion_provider: Some(lsp::CompletionOptions {
+ ..Default::default()
+ }),
+ ..Default::default()
+ },
+ cx,
+ )
+ .await;
+ cx.lsp
+ .set_request_handler::<lsp::request::Completion, _, _>(move |_, _| async move {
+ Ok(Some(lsp::CompletionResponse::Array(vec![
+ lsp::CompletionItem {
+ label: "unsafe".into(),
+ text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
+ range: lsp::Range {
+ start: lsp::Position {
+ line: 0,
+ character: 9,
+ },
+ end: lsp::Position {
+ line: 0,
+ character: 11,
+ },
+ },
+ new_text: "unsafe".to_string(),
+ })),
+ insert_text_mode: Some(lsp::InsertTextMode::AS_IS),
+ ..Default::default()
+ },
+ ])))
+ });
+
+ cx.update_editor(|editor, _, cx| {
+ editor.project().unwrap().update(cx, |project, cx| {
+ project.snippets().update(cx, |snippets, _cx| {
+ snippets.add_snippet_for_test(
+ None,
+ PathBuf::from("test_snippets.json"),
+ vec![
+ Arc::new(project::snippet_provider::Snippet {
+ prefix: vec![
+ "unlimited word count".to_string(),
+ "unlimit word count".to_string(),
+ "unlimited unknown".to_string(),
+ ],
+ body: "this is many words".to_string(),
+ description: Some("description".to_string()),
+ name: "multi-word snippet test".to_string(),
+ }),
+ Arc::new(project::snippet_provider::Snippet {
+ prefix: vec!["unsnip".to_string(), "@few".to_string()],
+ body: "fewer words".to_string(),
+ description: Some("alt description".to_string()),
+ name: "other name".to_string(),
+ }),
+ Arc::new(project::snippet_provider::Snippet {
+ prefix: vec!["ab aa".to_string()],
+ body: "abcd".to_string(),
+ description: None,
+ name: "alphabet".to_string(),
+ }),
+ ],
+ );
+ });
+ })
+ });
+
+ let get_completions = |cx: &mut EditorLspTestContext| {
+ cx.update_editor(|editor, _, _| match &*editor.context_menu.borrow() {
+ Some(CodeContextMenu::Completions(context_menu)) => {
+ let entries = context_menu.entries.borrow();
+ entries
+ .iter()
+ .map(|entry| entry.string.clone())
+ .collect_vec()
+ }
+ _ => vec![],
+ })
+ };
+
+ // snippets:
+ // @foo
+ // foo bar
+ //
+ // when typing:
+ //
+ // when typing:
+ // - if I type a symbol "open the completions with snippets only"
+ // - if I type a word character "open the completions menu" (if it had been open snippets only, clear it out)
+ //
+ // stuff we need:
+ // - filtering logic change?
+ // - remember how far back the completion started.
+
+ let test_cases: &[(&str, &[&str])] = &[
+ (
+ "un",
+ &[
+ "unsafe",
+ "unlimit word count",
+ "unlimited unknown",
+ "unlimited word count",
+ "unsnip",
+ ],
+ ),
+ (
+ "u ",
+ &[
+ "unlimit word count",
+ "unlimited unknown",
+ "unlimited word count",
+ ],
+ ),
+ ("u a", &["ab aa", "unsafe"]), // unsAfe
+ (
+ "u u",
+ &[
+ "unsafe",
+ "unlimit word count",
+ "unlimited unknown", // ranked highest among snippets
+ "unlimited word count",
+ "unsnip",
+ ],
+ ),
+ ("uw c", &["unlimit word count", "unlimited word count"]),
+ (
+ "u w",
+ &[
+ "unlimit word count",
+ "unlimited word count",
+ "unlimited unknown",
+ ],
+ ),
+ ("u w ", &["unlimit word count", "unlimited word count"]),
+ (
+ "u ",
+ &[
+ "unlimit word count",
+ "unlimited unknown",
+ "unlimited word count",
+ ],
+ ),
+ ("wor", &[]),
+ ("uf", &["unsafe"]),
+ ("af", &["unsafe"]),
+ ("afu", &[]),
+ (
+ "ue",
+ &["unsafe", "unlimited unknown", "unlimited word count"],
+ ),
+ ("@", &["@few"]),
+ ("@few", &["@few"]),
+ ("@ ", &[]),
+ ("a@", &["@few"]),
+ ("a@f", &["@few", "unsafe"]),
+ ("a@fw", &["@few"]),
+ ("a", &["ab aa", "unsafe"]),
+ ("aa", &["ab aa"]),
+ ("aaa", &["ab aa"]),
+ ("ab", &["ab aa"]),
+ ("ab ", &["ab aa"]),
+ ("ab a", &["ab aa", "unsafe"]),
+ ("ab ab", &["ab aa"]),
+ ("ab ab aa", &["ab aa"]),
+ ];
+
+ for &(input_to_simulate, expected_completions) in test_cases {
+ cx.set_state("fn a() { ˇ }\n");
+ for c in input_to_simulate.split("") {
+ cx.simulate_input(c);
+ cx.run_until_parked();
+ }
+ let expected_completions = expected_completions
+ .iter()
+ .map(|s| s.to_string())
+ .collect_vec();
+ assert_eq!(
+ get_completions(&mut cx),
+ expected_completions,
+ "< actual / expected >, input = {input_to_simulate:?}",
+ );
+ }
+}
+
/// 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.
@@ -59,6 +59,17 @@ impl EditorTestContext {
})
.await
.unwrap();
+
+ let language = project
+ .read_with(cx, |project, _cx| {
+ project.languages().language_for_name("Plain Text")
+ })
+ .await
+ .unwrap();
+ buffer.update(cx, |buffer, cx| {
+ buffer.set_language(Some(language), cx);
+ });
+
let editor = cx.add_window(|window, cx| {
let editor = build_editor_with_project(
project,
@@ -664,6 +664,8 @@ impl CompletionProvider for RustStyleCompletionProvider {
replace_range: replace_range.clone(),
new_text: format!(".{}()", method.name),
label: CodeLabel::plain(method.name.to_string(), None),
+ match_start: None,
+ snippet_deduplication_key: None,
icon_path: None,
documentation: method.documentation.map(|documentation| {
CompletionDocumentation::MultiLineMarkdown(documentation.into())
@@ -2993,6 +2993,8 @@ impl CompletionProvider for KeyContextCompletionProvider {
documentation: None,
source: project::CompletionSource::Custom,
icon_path: None,
+ match_start: None,
+ snippet_deduplication_key: None,
insert_text_mode: None,
confirm: None,
})
@@ -18,6 +18,7 @@ test-support = [
"client/test-support",
"language/test-support",
"settings/test-support",
+ "snippet_provider/test-support",
"text/test-support",
"prettier/test-support",
"worktree/test-support",
@@ -107,6 +108,7 @@ pretty_assertions.workspace = true
release_channel.workspace = true
rpc = { workspace = true, features = ["test-support"] }
settings = { workspace = true, features = ["test-support"] }
+snippet_provider = { workspace = true, features = ["test-support"] }
unindent.workspace = true
util = { workspace = true, features = ["test-support"] }
worktree = { workspace = true, features = ["test-support"] }
@@ -10143,6 +10143,8 @@ impl LspStore {
source: completion.source,
documentation: None,
label: CodeLabel::default(),
+ match_start: None,
+ snippet_deduplication_key: None,
insert_text_mode: None,
icon_path: None,
confirm: None,
@@ -12847,6 +12849,8 @@ async fn populate_labels_for_completions(
source: completion.source,
icon_path: None,
confirm: None,
+ match_start: None,
+ snippet_deduplication_key: None,
});
}
None => {
@@ -12861,6 +12865,8 @@ async fn populate_labels_for_completions(
insert_text_mode: None,
icon_path: None,
confirm: None,
+ match_start: None,
+ snippet_deduplication_key: None,
});
}
}
@@ -103,6 +103,7 @@ use search_history::SearchHistory;
use settings::{InvalidSettingsError, RegisterSetting, Settings, SettingsLocation, SettingsStore};
use smol::channel::Receiver;
use snippet::Snippet;
+pub use snippet_provider;
use snippet_provider::SnippetProvider;
use std::{
borrow::Cow,
@@ -476,6 +477,12 @@ pub struct Completion {
pub source: CompletionSource,
/// A path to an icon for this completion that is shown in the menu.
pub icon_path: Option<SharedString>,
+ /// Text starting here and ending at the cursor will be used as the query for filtering this completion.
+ ///
+ /// If None, the start of the surrounding word is used.
+ pub match_start: Option<text::Anchor>,
+ /// Key used for de-duplicating snippets. If None, always considered unique.
+ pub snippet_deduplication_key: Option<(usize, usize)>,
/// Whether to adjust indentation (the default) or not.
pub insert_text_mode: Option<InsertTextMode>,
/// An optional callback to invoke when this completion is confirmed.
@@ -5643,6 +5650,15 @@ impl Completion {
}
/// Whether this completion is a snippet.
+ pub fn is_snippet_kind(&self) -> bool {
+ matches!(
+ &self.source,
+ CompletionSource::Lsp { lsp_completion, .. }
+ if lsp_completion.kind == Some(CompletionItemKind::SNIPPET)
+ )
+ }
+
+ /// Whether this completion is a snippet or snippet-style LSP completion.
pub fn is_snippet(&self) -> bool {
self.source
// `lsp::CompletionListItemDefaults` has `insert_text_format` field
@@ -8,6 +8,9 @@ license = "GPL-3.0-or-later"
[lints]
workspace = true
+[features]
+test-support = []
+
[dependencies]
anyhow.workspace = true
collections.workspace = true
@@ -235,6 +235,19 @@ impl SnippetProvider {
user_snippets
}
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn add_snippet_for_test(
+ &mut self,
+ language: SnippetKind,
+ path: PathBuf,
+ snippet: Vec<Arc<Snippet>>,
+ ) {
+ self.snippets
+ .entry(language)
+ .or_default()
+ .insert(path, snippet);
+ }
+
pub fn snippets_for(&self, language: SnippetKind, cx: &App) -> Vec<Arc<Snippet>> {
let mut requested_snippets = self.lookup_snippets::<true>(&language, cx);