From 962f73b9fc51df02f29c2794e5a4d49e06e266b5 Mon Sep 17 00:00:00 2001 From: Artin Date: Mon, 10 Aug 2026 23:06:11 +0800 Subject: [PATCH 1/3] language: Keep sibling injection layers alive when reparsing a layer --- crates/language/src/syntax_map.rs | 18 +++- .../src/syntax_map/syntax_map_tests.rs | 92 +++++++++++++++++++ 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/crates/language/src/syntax_map.rs b/crates/language/src/syntax_map.rs index fbd81e3ab3367f..98df51115f9baa 100644 --- a/crates/language/src/syntax_map.rs +++ b/crates/language/src/syntax_map.rs @@ -810,14 +810,24 @@ impl SyntaxSnapshot { grammar.injection_config.as_ref().zip(registry.as_ref()), changed_ranges.is_empty(), ) { - // Handle invalidation and reactivation of injections on comment update + // Handle invalidation and reactivation of injections on comment update. + // The expanded ranges are clamped to this layer's own range, because a + // layer only owns the injections nested inside of it. Without clamping, + // reparsing one layer would invalidate the injections of sibling layers + // on the adjacent rows, which nothing would then reparse. let mut expanded_ranges: Vec<_> = changed_ranges .iter() - .map(|range| { + .filter_map(|range| { let start_row = range.start.to_point(text).row.saturating_sub(1); let end_row = range.end.to_point(text).row.saturating_add(2); - text.point_to_offset(Point::new(start_row, 0)) - ..text.point_to_offset(Point::new(end_row, 0)).min(text.len()) + let start = text + .point_to_offset(Point::new(start_row, 0)) + .max(step_start_byte); + let end = text + .point_to_offset(Point::new(end_row, 0)) + .min(text.len()) + .min(step_end_byte); + (start < end).then_some(start..end) }) .collect(); expanded_ranges.sort_unstable_by_key(|r| r.start); diff --git a/crates/language/src/syntax_map/syntax_map_tests.rs b/crates/language/src/syntax_map/syntax_map_tests.rs index 11b286764ce2a1..5e9c83d569be99 100644 --- a/crates/language/src/syntax_map/syntax_map_tests.rs +++ b/crates/language/src/syntax_map/syntax_map_tests.rs @@ -984,6 +984,48 @@ fn test_comment_triggered_injection_toggle(cx: &mut App) { ); } +#[gpui::test] +fn test_injections_are_preserved_when_a_sibling_layer_is_reparsed(cx: &mut App) { + let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); + let markdown = markdown_lang(); + registry.add(markdown.clone()); + registry.add(Arc::new(markdown_inline_lang_with_html_injections())); + registry.add(Arc::new(html_lang_with_injections())); + + // Each list item is a separate inline layer, so each HTML comment lives in its own + // injection layer. + let mut buffer = Buffer::new( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + "- one \n- two \n- three ".to_string(), + ); + + let mut syntax_map = SyntaxMap::new(&buffer); + syntax_map.set_language_registry(registry); + syntax_map.reparse(markdown.clone(), &buffer); + + assert_capture_ranges( + &syntax_map, + &buffer, + &["comment"], + "- one «»\n- two «»\n- three «»", + ); + + // Editing one list item must not invalidate the injections of the adjacent items, + // whose own layers are not reparsed and would therefore never be restored. + let second_item_end = buffer.as_rope().to_string().rfind('\n').unwrap(); + buffer.edit([(second_item_end..second_item_end, " ")]); + syntax_map.interpolate(&buffer); + syntax_map.reparse(markdown, &buffer); + + assert_capture_ranges( + &syntax_map, + &buffer, + &["comment"], + "- one «»\n- two «» \n- three «»", + ); +} + #[gpui::test] fn test_syntax_map_languages_loading_with_erb(cx: &mut App) { let text = r#" @@ -1416,6 +1458,56 @@ fn html_lang() -> Language { .unwrap() } +/// Like [`markdown_inline_lang`], but injecting HTML into inline HTML tags, the way +/// the real Markdown-Inline queries do. +fn markdown_inline_lang_with_html_injections() -> Language { + Language::new( + LanguageConfig { + name: "Markdown-Inline".into(), + hidden: true, + ..LanguageConfig::default() + }, + Some(tree_sitter_md::INLINE_LANGUAGE.into()), + ) + .with_highlights_query("(emphasis) @emphasis") + .unwrap() + .with_injection_query( + r#" + ((html_tag) @injection.content + (#set! injection.language "html") + (#set! injection.combined)) + "#, + ) + .unwrap() +} + +/// Like [`html_lang`], but with an injection query, so that reparsing an HTML layer +/// invalidates the layers injected into it. +fn html_lang_with_injections() -> Language { + Language::new( + LanguageConfig { + name: "HTML".into(), + matcher: (LanguageMatcher { + path_suffixes: vec!["html".to_string()], + ..Default::default() + }) + .into(), + ..Default::default() + }, + Some(tree_sitter_html::LANGUAGE.into()), + ) + .with_highlights_query("(comment) @comment (tag_name) @tag") + .unwrap() + .with_injection_query( + r#" + (script_element + (raw_text) @injection.content + (#set! injection.language "javascript")) + "#, + ) + .unwrap() +} + fn ruby_lang() -> Language { Language::new( LanguageConfig { From 173e9062ca1b2e9cf638af9660b75b2bf4e22564 Mon Sep 17 00:00:00 2001 From: Artin Date: Sat, 29 Aug 2026 23:46:33 +0800 Subject: [PATCH 2/3] language: Cover inline HTML tags in the sibling injection test The lost highlighting is not specific to HTML comments: any highlight produced by the injected layer disappears, so assert an inline tag in an adjacent list item as well. --- crates/language/src/syntax_map/syntax_map_tests.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/language/src/syntax_map/syntax_map_tests.rs b/crates/language/src/syntax_map/syntax_map_tests.rs index 5e9c83d569be99..aa1bdf633df338 100644 --- a/crates/language/src/syntax_map/syntax_map_tests.rs +++ b/crates/language/src/syntax_map/syntax_map_tests.rs @@ -992,12 +992,12 @@ fn test_injections_are_preserved_when_a_sibling_layer_is_reparsed(cx: &mut App) registry.add(Arc::new(markdown_inline_lang_with_html_injections())); registry.add(Arc::new(html_lang_with_injections())); - // Each list item is a separate inline layer, so each HTML comment lives in its own - // injection layer. + // Each list item is a separate inline layer, so the inline HTML of each item lives + // in its own injection layer. let mut buffer = Buffer::new( ReplicaId::LOCAL, BufferId::new(1).unwrap(), - "- one \n- two \n- three ".to_string(), + "- one \n- two \n- three 3".to_string(), ); let mut syntax_map = SyntaxMap::new(&buffer); @@ -1007,8 +1007,8 @@ fn test_injections_are_preserved_when_a_sibling_layer_is_reparsed(cx: &mut App) assert_capture_ranges( &syntax_map, &buffer, - &["comment"], - "- one «»\n- two «»\n- three «»", + &["comment", "tag"], + "- one «»\n- two «»\n- three <«b»>3", ); // Editing one list item must not invalidate the injections of the adjacent items, @@ -1021,8 +1021,8 @@ fn test_injections_are_preserved_when_a_sibling_layer_is_reparsed(cx: &mut App) assert_capture_ranges( &syntax_map, &buffer, - &["comment"], - "- one «»\n- two «» \n- three «»", + &["comment", "tag"], + "- one «»\n- two «» \n- three <«b»>3", ); } From 5e5642b5761eb9864c4d43472806689829863476 Mon Sep 17 00:00:00 2001 From: Artin Date: Sun, 30 Aug 2026 00:23:19 +0800 Subject: [PATCH 3/3] language: Only invalidate injections owned by the reparsed layer Clamping the expanded ranges to the reparsed layer's own range still let the invalidation discard the injections of a layer that merely begins where the reparsed layer ends, such as the paragraph following an HTML block in Markdown. That layer is not reparsed, so its discarded injections are never recreated. Record which layer each changed region belongs to and skip layers that are not nested inside of it. This subsumes the clamping, so the expanded ranges are back to their original form. --- crates/language/src/syntax_map.rs | 38 ++++++++++------ .../src/syntax_map/syntax_map_tests.rs | 45 +++++++++++++++++++ 2 files changed, 69 insertions(+), 14 deletions(-) diff --git a/crates/language/src/syntax_map.rs b/crates/language/src/syntax_map.rs index 98df51115f9baa..e32f2ea08dbef4 100644 --- a/crates/language/src/syntax_map.rs +++ b/crates/language/src/syntax_map.rs @@ -247,6 +247,9 @@ enum ParseMode { struct ChangedRegion { depth: usize, range: Range, + /// The range of the layer whose injections are being invalidated. Only layers nested + /// inside of it can be invalidated, because only that layer will recreate them. + owner_range: Range, } #[derive(Default)] @@ -625,6 +628,7 @@ impl SyntaxSnapshot { ChangedRegion { depth: layer.depth + 1, range: layer.range.clone(), + owner_range: layer.range.clone(), }, text, ); @@ -810,24 +814,14 @@ impl SyntaxSnapshot { grammar.injection_config.as_ref().zip(registry.as_ref()), changed_ranges.is_empty(), ) { - // Handle invalidation and reactivation of injections on comment update. - // The expanded ranges are clamped to this layer's own range, because a - // layer only owns the injections nested inside of it. Without clamping, - // reparsing one layer would invalidate the injections of sibling layers - // on the adjacent rows, which nothing would then reparse. + // Handle invalidation and reactivation of injections on comment update let mut expanded_ranges: Vec<_> = changed_ranges .iter() - .filter_map(|range| { + .map(|range| { let start_row = range.start.to_point(text).row.saturating_sub(1); let end_row = range.end.to_point(text).row.saturating_add(2); - let start = text - .point_to_offset(Point::new(start_row, 0)) - .max(step_start_byte); - let end = text - .point_to_offset(Point::new(end_row, 0)) - .min(text.len()) - .min(step_end_byte); - (start < end).then_some(start..end) + text.point_to_offset(Point::new(start_row, 0)) + ..text.point_to_offset(Point::new(end_row, 0)).min(text.len()) }) .collect(); expanded_ranges.sort_unstable_by_key(|r| r.start); @@ -845,6 +839,7 @@ impl SyntaxSnapshot { depth: step.depth + 1, range: text.anchor_before(range.start) ..text.anchor_after(range.end), + owner_range: step.range.clone(), }, text, ); @@ -1960,6 +1955,10 @@ impl ChangedRegion { Ord::cmp(&self.depth, &other.depth) .then_with(|| range_a.start.cmp(&range_b.start, buffer)) .then_with(|| range_b.end.cmp(&range_a.end, buffer)) + // Regions that differ only in their owner must both be kept, as they + // invalidate different layers. + .then_with(|| self.owner_range.start.cmp(&other.owner_range.start, buffer)) + .then_with(|| other.owner_range.end.cmp(&self.owner_range.end, buffer)) } } @@ -1991,6 +1990,17 @@ impl ChangeRegionSet { if region.range.start.cmp(&layer.range.end, text).is_ge() { break; } + // Only the layer that owns an injection will recreate it, so a layer that + // merely abuts or overlaps the owner must be left alone. + if region + .owner_range + .start + .cmp(&layer.range.start, text) + .is_gt() + || region.owner_range.end.cmp(&layer.range.end, text).is_lt() + { + continue; + } return true; } false diff --git a/crates/language/src/syntax_map/syntax_map_tests.rs b/crates/language/src/syntax_map/syntax_map_tests.rs index aa1bdf633df338..15c49c9e342cf8 100644 --- a/crates/language/src/syntax_map/syntax_map_tests.rs +++ b/crates/language/src/syntax_map/syntax_map_tests.rs @@ -1026,6 +1026,51 @@ fn test_injections_are_preserved_when_a_sibling_layer_is_reparsed(cx: &mut App) ); } +#[gpui::test] +fn test_injections_are_preserved_when_an_abutting_layer_is_reparsed(cx: &mut App) { + let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); + let markdown = markdown_lang(); + registry.add(markdown.clone()); + registry.add(Arc::new(markdown_inline_lang_with_html_injections())); + registry.add(Arc::new(html_lang_with_injections())); + + // A line starting with a comment is an HTML block, so the first two lines become HTML + // layers of their own, while the last two lines are one paragraph whose inline layer + // has HTML injected into it. The second HTML block ends exactly where that paragraph + // begins. + let text = " a\n b\nc d\ne f"; + let mut buffer = Buffer::new( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + text.to_string(), + ); + + let mut syntax_map = SyntaxMap::new(&buffer); + syntax_map.set_language_registry(registry); + syntax_map.reparse(markdown.clone(), &buffer); + + assert_capture_ranges( + &syntax_map, + &buffer, + &["comment", "tag"], + "«» a\n«» b\n<«b»>c d\n<«i»>e f", + ); + + // Reparsing the second HTML block must not invalidate the HTML injected into the + // paragraph that starts at its end offset. + let second_block_end = text.find("\n").unwrap(); + buffer.edit([(second_block_end..second_block_end, " ")]); + syntax_map.interpolate(&buffer); + syntax_map.reparse(markdown, &buffer); + + assert_capture_ranges( + &syntax_map, + &buffer, + &["comment", "tag"], + "«» a\n«» b \n<«b»>c d\n<«i»>e f", + ); +} + #[gpui::test] fn test_syntax_map_languages_loading_with_erb(cx: &mut App) { let text = r#"