From 97a9a5de10c0fba901dc560b7cf8137da38d1e0e Mon Sep 17 00:00:00 2001 From: claytonrcarter Date: Fri, 11 Apr 2025 18:20:43 -0400 Subject: [PATCH] snippets: Fix snippets for PHP and ERB languages (#27718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #21541 Closes #22726 This should fix snippets in languages, like PHP, that are based on the HTML syntax layer. To be honest, I don't totally get where HTML comes into it, but the issues outlined in #21541 and #22726 both boil down to "Zed only shows me HTML snippets in PHP/ERB files; I expected to see PHP/ERB snippets". This solution is based on the comments between @mrnugget and @osiewicz in #22726: resolve/combine snippets for all language layers at the given position, whereas current behavior is to resolve snippets only for the `.last()` language layer at the given position. - add `Buffer:languages_at()` (note the plural) - update `snippet_completions()` in `editor.rs` to loop over each language, gathering snippets as it goes - the primary logic for resolving snippets within a single language has not changed ### Verifying this change I couldn't find tests related to snippet and currently active languages (CI may show them to me 😆 ) but I can add some if desired and w/ perhaps a little coaching or prompting about another test to look to for inspiration. I have confirmed that this works for PHP, but I have not checked ERB because I'm not familiar with it or set up for it. To check this manually: 1. install the PHP extension 2. install at least 1 snippet for each of html, php and phpdoc. If you don't have any, these should work: ```sh # BEWARE these will clobber existing snippets! echo '{"dddd":{"body":"hello from phpdoc"}}' > ~/.config/zed/snippets/phpdoc.json echo '{"pppp":{"body":"hello from PHP"}}' > ~/.config/zed/snippets/php.json echo '{"hhhh":{"body":"hello from HTML"}}' > ~/.config/zed/snippets/html.json ``` 3. open any PHP file. If you don't have one, here's one that should work: ```php Task>> { - let language = buffer.read(cx).language_at(buffer_position); - let language_name = language.as_ref().map(|language| language.lsp_id()); + let languages = buffer.read(cx).languages_at(buffer_position); let snippet_store = project.snippets().read(cx); - let snippets = snippet_store.snippets_for(language_name, cx); - if snippets.is_empty() { + let scopes: Vec<_> = languages + .iter() + .filter_map(|language| { + let language_name = language.lsp_id(); + let snippets = snippet_store.snippets_for(Some(language_name), cx); + + if snippets.is_empty() { + None + } else { + Some((language.default_scope(), snippets)) + } + }) + .collect(); + + if scopes.is_empty() { return Task::ready(Ok(vec![])); } + let snapshot = buffer.read(cx).text_snapshot(); let chars: String = snapshot .reversed_chars_for_range(text::Anchor::MIN..buffer_position) .collect(); - - let scope = language.map(|language| language.default_scope()); let executor = cx.background_executor().clone(); cx.background_spawn(async move { - let classifier = CharClassifier::new(scope).for_completion(true); - let mut last_word = chars - .chars() - .take_while(|c| classifier.is_word(*c)) - .collect::(); - last_word = last_word.chars().rev().collect(); + let mut all_results: Vec = Vec::new(); + for (scope, snippets) in scopes.into_iter() { + let classifier = CharClassifier::new(Some(scope)).for_completion(true); + let mut last_word = chars + .chars() + .take_while(|c| classifier.is_word(*c)) + .collect::(); + last_word = last_word.chars().rev().collect(); - if last_word.is_empty() { - return Ok(vec![]); - } + if last_word.is_empty() { + return Ok(vec![]); + } - 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 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::>(); - - let mut matches = fuzzy::match_strings( - &candidates, - &last_word, - last_word.chars().any(|c| c.is_uppercase()), - 100, - &Default::default(), - executor, - ) - .await; - - // 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 = snippets + .iter() + .enumerate() + .flat_map(|(ix, snippet)| { + snippet + .prefix + .iter() + .map(move |prefix| StringMatchCandidate::new(ix, &prefix)) }) - }); - } + .collect::>(); - let matched_strings = matches - .into_iter() - .map(|m| m.string) - .collect::>(); + let mut matches = fuzzy::match_strings( + &candidates, + &last_word, + last_word.chars().any(|c| c.is_uppercase()), + 100, + &Default::default(), + executor.clone(), + ) + .await; - let result: Vec = snippets - .into_iter() - .filter_map(|snippet| { - let matching_prefix = snippet - .prefix - .iter() - .find(|prefix| matched_strings.contains(*prefix))?; - let start = as_offset - last_word.len(); - let start = snapshot.anchor_before(start); - let range = start..buffer_position; - let lsp_start = to_lsp(&start); - let lsp_range = lsp::Range { - start: lsp_start, - end: lsp_end, - }; - Some(Completion { - replace_range: range, - new_text: snippet.body.clone(), - source: CompletionSource::Lsp { - insert_range: None, - server_id: LanguageServerId(usize::MAX), - resolved: true, - lsp_completion: Box::new(lsp::CompletionItem { - label: snippet.prefix.first().unwrap().clone(), - kind: Some(CompletionItemKind::SNIPPET), - label_details: snippet.description.as_ref().map(|description| { - lsp::CompletionItemLabelDetails { - detail: Some(description.clone()), - description: None, - } + // 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::>(); + + let mut result: Vec = snippets + .iter() + .filter_map(|snippet| { + let matching_prefix = snippet + .prefix + .iter() + .find(|prefix| matched_strings.contains(*prefix))?; + let start = as_offset - last_word.len(); + let start = snapshot.anchor_before(start); + let range = start..buffer_position; + let lsp_start = to_lsp(&start); + let lsp_range = lsp::Range { + start: lsp_start, + end: lsp_end, + }; + Some(Completion { + replace_range: range, + new_text: snippet.body.clone(), + source: CompletionSource::Lsp { + insert_range: None, + server_id: LanguageServerId(usize::MAX), + resolved: true, + lsp_completion: Box::new(lsp::CompletionItem { + label: snippet.prefix.first().unwrap().clone(), + kind: Some(CompletionItemKind::SNIPPET), + label_details: snippet.description.as_ref().map(|description| { + lsp::CompletionItemLabelDetails { + detail: Some(description.clone()), + description: None, + } + }), + insert_text_format: Some(InsertTextFormat::SNIPPET), + text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( + lsp::InsertReplaceEdit { + new_text: snippet.body.clone(), + insert: lsp_range, + replace: lsp_range, + }, + )), + filter_text: Some(snippet.body.clone()), + sort_text: Some(char::MAX.to_string()), + ..lsp::CompletionItem::default() }), - insert_text_format: Some(InsertTextFormat::SNIPPET), - text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( - lsp::InsertReplaceEdit { - new_text: snippet.body.clone(), - insert: lsp_range, - replace: lsp_range, - }, - )), - filter_text: Some(snippet.body.clone()), - sort_text: Some(char::MAX.to_string()), - ..lsp::CompletionItem::default() + lsp_defaults: None, + }, + label: CodeLabel { + text: matching_prefix.clone(), + runs: Vec::new(), + filter_range: 0..matching_prefix.len(), + }, + icon_path: None, + documentation: snippet.description.clone().map(|description| { + CompletionDocumentation::SingleLine(description.into()) }), - lsp_defaults: None, - }, - label: CodeLabel { - text: matching_prefix.clone(), - runs: Vec::new(), - filter_range: 0..matching_prefix.len(), - }, - icon_path: None, - documentation: snippet - .description - .clone() - .map(|description| CompletionDocumentation::SingleLine(description.into())), - insert_text_mode: None, - confirm: None, + insert_text_mode: None, + confirm: None, + }) }) - }) - .collect(); + .collect(); - Ok(result) + all_results.append(&mut result); + } + + Ok(all_results) }) } diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 45535971f7..aeb870288c 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -1373,6 +1373,16 @@ impl Buffer { .or_else(|| self.language.clone()) } + /// Returns each [`Language`] for the active syntax layers at the given location. + pub fn languages_at(&self, position: D) -> Vec> { + let offset = position.to_offset(self); + self.syntax_map + .lock() + .layers_for_range(offset..offset, &self.text, false) + .map(|info| info.language.clone()) + .collect() + } + /// An integer version number that accounts for all updates besides /// the buffer's text itself (which is versioned via a version vector). pub fn non_text_state_update_count(&self) -> usize {