snippets: Fix snippets for PHP and ERB languages (#27718)

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
<?php

/**
 *
 */
function function_name()
{
}
```
4. Place your cursor in a PHPdoc comment (eg after the `/**` on line 3)
- you should be able to use the `dddd`, `pppp` and `hhhh` snippets; on
`main`, only the `dddd` snippet works here
5. Move your cursor to a non-comment PHP area (eg after the `{` on line
7)
- you should be able to use the `pppp` and `hhhh` snippets, but not
`dddd`; on `main`, only `hhhh` works here

### Performance

This adds 2 separate (not nested) loops to `snippet_completions()`, each
of which will iterate over the active language scopes at the given
location. I have not looked into the specifics of how many layers most
languages have, but I suspect that *most* users will see identical
performance as before because there will only be 1 scope active most of
the time.

In some cases, though (eg PHP, ERB, maybe template strings in JS), the
editor will be looping over more layers, possibly many in some deeply
injected/embedded cases (I'm thinking of a regex template string in a JS
heredoc string in a PHP script in an HTML file). I don't expect this to
be an issue – nor has it been in my usage and testing – but performance
of snippets could be affected in pathological cases.

### Alternate solutions

Instead of resolving snippets for *all* layers, we could just change how
we pick which language to resolve. Instead of always using `.last()`,
perhaps we could do something more clever. This feels like it could be
tricky and potentially error prone, though.

Release Notes:

- Snippets are now resolved for all languages active at the cursor
location.
- Fixed snippets in PHP, ERB and other languages whose syntax layers are
based on HTML
This commit is contained in:
claytonrcarter 2025-04-11 18:20:43 -04:00 committed by GitHub
parent 730f2e7083
commit 97a9a5de10
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 141 additions and 116 deletions

View file

@ -18853,143 +18853,158 @@ fn snippet_completions(
buffer_position: text::Anchor, buffer_position: text::Anchor,
cx: &mut App, cx: &mut App,
) -> Task<Result<Vec<Completion>>> { ) -> Task<Result<Vec<Completion>>> {
let language = buffer.read(cx).language_at(buffer_position); let languages = buffer.read(cx).languages_at(buffer_position);
let language_name = language.as_ref().map(|language| language.lsp_id());
let snippet_store = project.snippets().read(cx); 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![])); return Task::ready(Ok(vec![]));
} }
let snapshot = buffer.read(cx).text_snapshot(); let snapshot = buffer.read(cx).text_snapshot();
let chars: String = snapshot let chars: String = snapshot
.reversed_chars_for_range(text::Anchor::MIN..buffer_position) .reversed_chars_for_range(text::Anchor::MIN..buffer_position)
.collect(); .collect();
let scope = language.map(|language| language.default_scope());
let executor = cx.background_executor().clone(); let executor = cx.background_executor().clone();
cx.background_spawn(async move { cx.background_spawn(async move {
let classifier = CharClassifier::new(scope).for_completion(true); let mut all_results: Vec<Completion> = Vec::new();
let mut last_word = chars for (scope, snippets) in scopes.into_iter() {
.chars() let classifier = CharClassifier::new(Some(scope)).for_completion(true);
.take_while(|c| classifier.is_word(*c)) let mut last_word = chars
.collect::<String>(); .chars()
last_word = last_word.chars().rev().collect(); .take_while(|c| classifier.is_word(*c))
.collect::<String>();
last_word = last_word.chars().rev().collect();
if last_word.is_empty() { if last_word.is_empty() {
return Ok(vec![]); return Ok(vec![]);
} }
let as_offset = text::ToOffset::to_offset(&buffer_position, &snapshot); let as_offset = text::ToOffset::to_offset(&buffer_position, &snapshot);
let to_lsp = |point: &text::Anchor| { let to_lsp = |point: &text::Anchor| {
let end = text::ToPointUtf16::to_point_utf16(point, &snapshot); let end = text::ToPointUtf16::to_point_utf16(point, &snapshot);
point_to_lsp(end) point_to_lsp(end)
}; };
let lsp_end = to_lsp(&buffer_position); let lsp_end = to_lsp(&buffer_position);
let candidates = snippets let candidates = snippets
.iter() .iter()
.enumerate() .enumerate()
.flat_map(|(ix, snippet)| { .flat_map(|(ix, snippet)| {
snippet snippet
.prefix .prefix
.iter() .iter()
.map(move |prefix| StringMatchCandidate::new(ix, &prefix)) .map(move |prefix| StringMatchCandidate::new(ix, &prefix))
})
.collect::<Vec<StringMatchCandidate>>();
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)
}) })
}); .collect::<Vec<StringMatchCandidate>>();
}
let matched_strings = matches let mut matches = fuzzy::match_strings(
.into_iter() &candidates,
.map(|m| m.string) &last_word,
.collect::<HashSet<_>>(); last_word.chars().any(|c| c.is_uppercase()),
100,
&Default::default(),
executor.clone(),
)
.await;
let result: Vec<Completion> = snippets // Remove all candidates where the query's start does not match the start of any word in the candidate
.into_iter() if let Some(query_start) = last_word.chars().next() {
.filter_map(|snippet| { matches.retain(|string_match| {
let matching_prefix = snippet split_words(&string_match.string).any(|word| {
.prefix // Check that the first codepoint of the word as lowercase matches the first
.iter() // codepoint of the query as lowercase
.find(|prefix| matched_strings.contains(*prefix))?; word.chars()
let start = as_offset - last_word.len(); .flat_map(|codepoint| codepoint.to_lowercase())
let start = snapshot.anchor_before(start); .zip(query_start.to_lowercase())
let range = start..buffer_position; .all(|(word_cp, query_cp)| word_cp == query_cp)
let lsp_start = to_lsp(&start); })
let lsp_range = lsp::Range { });
start: lsp_start, }
end: lsp_end,
}; let matched_strings = matches
Some(Completion { .into_iter()
replace_range: range, .map(|m| m.string)
new_text: snippet.body.clone(), .collect::<HashSet<_>>();
source: CompletionSource::Lsp {
insert_range: None, let mut result: Vec<Completion> = snippets
server_id: LanguageServerId(usize::MAX), .iter()
resolved: true, .filter_map(|snippet| {
lsp_completion: Box::new(lsp::CompletionItem { let matching_prefix = snippet
label: snippet.prefix.first().unwrap().clone(), .prefix
kind: Some(CompletionItemKind::SNIPPET), .iter()
label_details: snippet.description.as_ref().map(|description| { .find(|prefix| matched_strings.contains(*prefix))?;
lsp::CompletionItemLabelDetails { let start = as_offset - last_word.len();
detail: Some(description.clone()), let start = snapshot.anchor_before(start);
description: None, 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), lsp_defaults: None,
text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( },
lsp::InsertReplaceEdit { label: CodeLabel {
new_text: snippet.body.clone(), text: matching_prefix.clone(),
insert: lsp_range, runs: Vec::new(),
replace: lsp_range, filter_range: 0..matching_prefix.len(),
}, },
)), icon_path: None,
filter_text: Some(snippet.body.clone()), documentation: snippet.description.clone().map(|description| {
sort_text: Some(char::MAX.to_string()), CompletionDocumentation::SingleLine(description.into())
..lsp::CompletionItem::default()
}), }),
lsp_defaults: None, insert_text_mode: None,
}, confirm: 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,
}) })
}) .collect();
.collect();
Ok(result) all_results.append(&mut result);
}
Ok(all_results)
}) })
} }

View file

@ -1373,6 +1373,16 @@ impl Buffer {
.or_else(|| self.language.clone()) .or_else(|| self.language.clone())
} }
/// Returns each [`Language`] for the active syntax layers at the given location.
pub fn languages_at<D: ToOffset>(&self, position: D) -> Vec<Arc<Language>> {
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 /// An integer version number that accounts for all updates besides
/// the buffer's text itself (which is versioned via a version vector). /// the buffer's text itself (which is versioned via a version vector).
pub fn non_text_state_update_count(&self) -> usize { pub fn non_text_state_update_count(&self) -> usize {