diff --git a/crates/language/src/syntax_map.rs b/crates/language/src/syntax_map.rs index fbd81e3ab3367f..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, ); @@ -835,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, ); @@ -1950,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)) } } @@ -1981,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 11b286764ce2a1..15c49c9e342cf8 100644 --- a/crates/language/src/syntax_map/syntax_map_tests.rs +++ b/crates/language/src/syntax_map/syntax_map_tests.rs @@ -984,6 +984,93 @@ 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 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 3".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"], + "- one «»\n- two «»\n- three <«b»>3", + ); + + // 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", "tag"], + "- one «»\n- two «» \n- three <«b»>3", + ); +} + +#[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#" @@ -1416,6 +1503,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 {