Add support for multi-word snippet prefixes (#42398)
Supercedes #41126 Closes #39559, #35397, and #41426 Release Notes: - Added support for multi-word snippet prefixes --------- Co-authored-by: Agus Zubiaga <hi@aguz.me> Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com> Co-authored-by: Cole Miller <cole@zed.dev>
This commit is contained in:
co-authored by
Agus Zubiaga
Conrad Irwin
Cole Miller
parent
f62bfe1dfa
commit
da94f898e6
@@ -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 {
|
||||
|
||||
+189
-90
@@ -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();
|
||||
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);
|
||||
|
||||
if last_word.is_empty() {
|
||||
return Ok(CompletionResponse {
|
||||
completions: vec![],
|
||||
display_options: CompletionDisplayOptions::default(),
|
||||
is_incomplete: true,
|
||||
});
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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(|(snippet_ix, snippet)| {
|
||||
snippet
|
||||
.prefix
|
||||
.iter()
|
||||
.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();
|
||||
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;
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Take only the candidates with at least `word_count` many words
|
||||
let snippet_candidates_at_word_len =
|
||||
&sorted_snippet_candidates[..snippet_list_cutoff_index];
|
||||
|
||||
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 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 candidates = snippets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(ix, snippet)| {
|
||||
snippet
|
||||
.prefix
|
||||
.iter()
|
||||
.map(move |prefix| StringMatchCandidate::new(ix, prefix))
|
||||
})
|
||||
.collect::<Vec<StringMatchCandidate>>();
|
||||
|
||||
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;
|
||||
let lsp_end = to_lsp(&buffer_anchor);
|
||||
|
||||
if matches.len() >= MAX_RESULTS {
|
||||
is_incomplete = true;
|
||||
}
|
||||
|
||||
// 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 matched_strings = matches
|
||||
.into_iter()
|
||||
.map(|m| m.string)
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
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();
|
||||
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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user