language: Respect combined injection sub-ranges for language queries (#48522)

Follow-up to https://github.com/zed-industries/zed/pull/41111.

PR #41111 introduced combined injection handling, but cursor language
queries still relied on layer range selection alone. For combined
injections this can surface a language from the outer combined layer
even when the cursor is outside that language's actual included
sub-range. So, we just need to add sub-range filtering based on
anchor-aware boundary checks in the same way we did in the previous PR.
That means: apply it in `Buffer::language_at`,
`Buffer::languages_at`, and `BufferSnapshot::language_scope_at`. All
places that rely on the described behavior.

I also added some additional test cases for the `HTML+ERB` lang to
verify language resolution for HTML and Ruby positions.
Thank you!

Closes https://github.com/zed-industries/zed/issues/48358

Release Notes:

- Respect combined injection sub-ranges for language queries
This commit is contained in:
Vitaly Slobodin authored and GitHub committed 2026-02-13 12:06:34 +01:00
1 parent c35110461e
commit 82f3eca559
2 files changed
+148 -40

No files matched your search

+33 -18
View File
@@ -1716,28 +1716,14 @@ impl Buffer {
/// Returns the [`Language`] at the given location.
pub fn language_at<D: ToOffset>(&self, position: D) -> Option<Arc<Language>> {
let offset = position.to_offset(self);
let mut is_first = true;
let start_anchor = self.anchor_before(offset);
let end_anchor = self.anchor_after(offset);
let text: &TextBufferSnapshot = &self.text;
self.syntax_map
.lock()
.layers_for_range(offset..offset, &self.text, false)
.layers_for_range(offset..offset, text, false)
.filter(|layer| {
if is_first {
is_first = false;
return true;
}
layer
.included_sub_ranges
.map(|sub_ranges| {
sub_ranges.iter().any(|sub_range| {
let is_before_start = sub_range.end.cmp(&start_anchor, self).is_lt();
let is_after_end = sub_range.start.cmp(&end_anchor, self).is_gt();
!is_before_start && !is_after_end
})
})
.unwrap_or(true)
.is_none_or(|ranges| offset_in_sub_ranges(ranges, offset, text))
})
.last()
.map(|info| info.language.clone())
@@ -1747,10 +1733,17 @@ impl Buffer {
/// 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);
let text: &TextBufferSnapshot = &self.text;
let mut languages: Vec<Arc<Language>> = self
.syntax_map
.lock()
.layers_for_range(offset..offset, &self.text, false)
.layers_for_range(offset..offset, text, false)
.filter(|layer| {
// For combined injections, check if offset is within the actual sub-ranges.
layer
.included_sub_ranges
.is_none_or(|ranges| offset_in_sub_ranges(ranges, offset, text))
})
.map(|info| info.language.clone())
.collect();
@@ -3340,6 +3333,21 @@ impl Buffer {
impl EventEmitter<BufferEvent> for Buffer {}
fn offset_in_sub_ranges(
sub_ranges: &[Range<Anchor>],
offset: usize,
snapshot: &TextBufferSnapshot,
) -> bool {
let start_anchor = snapshot.anchor_before(offset);
let end_anchor = snapshot.anchor_after(offset);
sub_ranges.iter().any(|sub_range| {
let is_before_start = sub_range.end.cmp(&start_anchor, snapshot).is_lt();
let is_after_end = sub_range.start.cmp(&end_anchor, snapshot).is_gt();
!is_before_start && !is_after_end
})
}
impl Deref for Buffer {
type Target = TextBuffer;
@@ -3854,12 +3862,19 @@ impl BufferSnapshot {
let offset = position.to_offset(self);
let mut scope = None;
let mut smallest_range_and_depth: Option<(Range<usize>, usize)> = None;
let text: &TextBufferSnapshot = self;
// Use the layer that has the smallest node intersecting the given point.
for layer in self
.syntax
.layers_for_range(offset..offset, &self.text, false)
{
if let Some(ranges) = layer.included_sub_ranges
&& !offset_in_sub_ranges(ranges, offset, text)
{
continue;
}
let mut cursor = layer.node().walk();
let mut range = None;
+115 -22
View File
@@ -2771,14 +2771,11 @@ fn test_language_scope_at_with_combined_injections(cx: &mut App) {
let mut buffer = Buffer::local(text, cx);
buffer.set_language_registry(language_registry.clone());
buffer.set_language(
language_registry
.language_for_name("HTML+ERB")
.now_or_never()
.unwrap()
.ok(),
cx,
);
let language = language_registry
.language_for_name("HTML+ERB")
.now_or_never()
.and_then(Result::ok);
buffer.set_language(language, cx);
let snapshot = buffer.snapshot();
let html_config = snapshot.language_scope_at(Point::new(2, 4)).unwrap();
@@ -2894,15 +2891,80 @@ fn test_language_at_for_markdown_code_block(cx: &mut App) {
}
#[gpui::test]
fn test_syntax_layer_at_for_injected_languages(cx: &mut App) {
fn test_syntax_layer_at_for_combined_injections(cx: &mut App) {
init_settings(cx, |_| {});
cx.new(|cx| {
// ERB template with HTML and Ruby content
let text = r#"
```html+erb
<div>Hello</div>
<%= link_to "Some", "https://zed.dev" %>
```
<div>Hello</div>
<%= link_to "Click", url %>
<p>World</p>
"#
.unindent();
let language_registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
language_registry.add(Arc::new(erb_lang()));
language_registry.add(Arc::new(html_lang()));
language_registry.add(Arc::new(ruby_lang()));
let mut buffer = Buffer::local(text, cx);
buffer.set_language_registry(language_registry.clone());
let language = language_registry
.language_for_name("HTML+ERB")
.now_or_never()
.and_then(Result::ok);
buffer.set_language(language, cx);
let snapshot = buffer.snapshot();
// Test language_at for HTML content (line 0: "<div>Hello</div>")
let html_point = Point::new(0, 4);
let language = snapshot.language_at(html_point).unwrap();
assert_eq!(
language.name().as_ref(),
"HTML",
"Expected HTML at {:?}, got {}",
html_point,
language.name()
);
// Test language_at for Ruby code (line 1: "<%= link_to ... %>")
let ruby_point = Point::new(1, 6);
let language = snapshot.language_at(ruby_point).unwrap();
assert_eq!(
language.name().as_ref(),
"Ruby",
"Expected Ruby at {:?}, got {}",
ruby_point,
language.name()
);
// Test language_at for HTML after Ruby (line 2: "<p>World</p>")
let html_after_ruby = Point::new(2, 2);
let language = snapshot.language_at(html_after_ruby).unwrap();
assert_eq!(
language.name().as_ref(),
"HTML",
"Expected HTML at {:?}, got {}",
html_after_ruby,
language.name()
);
buffer
});
}
#[gpui::test]
fn test_languages_at_for_combined_injections(cx: &mut App) {
init_settings(cx, |_| {});
cx.new(|cx| {
// ERB template with HTML and Ruby content
let text = r#"
<div>Hello</div>
<%= yield %>
<p>World</p>
"#
.unindent();
@@ -2922,16 +2984,47 @@ fn test_syntax_layer_at_for_injected_languages(cx: &mut App) {
cx,
);
let snapshot = buffer.snapshot();
// Test languages_at for HTML content - should NOT include Ruby
let html_point = Point::new(0, 4);
let languages = buffer.languages_at(html_point);
let language_names: Vec<_> = languages.iter().map(|language| language.name()).collect();
assert!(
language_names
.iter()
.any(|language_name| language_name.as_ref() == "HTML"),
"Expected HTML in languages at {:?}, got {:?}",
html_point,
language_names
);
assert!(
!language_names
.iter()
.any(|language_name| language_name.as_ref() == "Ruby"),
"Did not expect Ruby in languages at {:?}, got {:?}",
html_point,
language_names
);
// Test points in the code line
let html_point = Point::new(1, 4);
let language = snapshot.language_at(html_point).unwrap();
assert_eq!(language.name().as_ref(), "HTML");
let ruby_point = Point::new(2, 6);
let language = snapshot.language_at(ruby_point).unwrap();
assert_eq!(language.name().as_ref(), "Ruby");
// Test languages_at for Ruby code - should NOT include HTML
let ruby_point = Point::new(1, 6);
let languages = buffer.languages_at(ruby_point);
let language_names: Vec<_> = languages.iter().map(|language| language.name()).collect();
assert!(
language_names
.iter()
.any(|language_name| language_name.as_ref() == "Ruby"),
"Expected Ruby in languages at {:?}, got {:?}",
ruby_point,
language_names
);
assert!(
!language_names
.iter()
.any(|language_name| language_name.as_ref() == "HTML"),
"Did not expect HTML in languages at {:?}, got {:?}",
ruby_point,
language_names
);
buffer
});