Include newlines in between combined injection ranges on different lines

Max Brunsfeld created

Change summary

crates/language/src/syntax_map.rs                  |  90 +++++++++++
crates/language/src/syntax_map/syntax_map_tests.rs | 120 ++++++++++++++-
crates/zed/src/languages/heex/config.toml          |   2 
crates/zed/src/languages/heex/highlights.scm       |   6 
4 files changed, 201 insertions(+), 17 deletions(-)

Detailed changes

crates/language/src/syntax_map.rs 🔗

@@ -569,11 +569,19 @@ impl SyntaxSnapshot {
                                 range.end = range.end.saturating_sub(step_start_byte);
                             }
 
-                            included_ranges = splice_included_ranges(
+                            let changed_indices;
+                            (included_ranges, changed_indices) = splice_included_ranges(
                                 old_tree.included_ranges(),
                                 &parent_layer_changed_ranges,
                                 &included_ranges,
                             );
+                            insert_newlines_between_ranges(
+                                changed_indices,
+                                &mut included_ranges,
+                                &text,
+                                step_start_byte,
+                                step_start_point,
+                            );
                         }
 
                         if included_ranges.is_empty() {
@@ -586,7 +594,7 @@ impl SyntaxSnapshot {
                         }
 
                         log::trace!(
-                            "update layer. language:{}, start:{:?}, ranges:{:?}",
+                            "update layer. language:{}, start:{:?}, included_ranges:{:?}",
                             language.name(),
                             LogAnchorRange(&step.range, text),
                             LogIncludedRanges(&included_ranges),
@@ -608,6 +616,16 @@ impl SyntaxSnapshot {
                             }),
                         );
                     } else {
+                        if matches!(step.mode, ParseMode::Combined { .. }) {
+                            insert_newlines_between_ranges(
+                                0..included_ranges.len(),
+                                &mut included_ranges,
+                                text,
+                                step_start_byte,
+                                step_start_point,
+                            );
+                        }
+
                         if included_ranges.is_empty() {
                             included_ranges.push(tree_sitter::Range {
                                 start_byte: 0,
@@ -1275,14 +1293,20 @@ fn get_injections(
     }
 }
 
+/// Update the given list of included `ranges`, removing any ranges that intersect
+/// `removed_ranges`, and inserting the given `new_ranges`.
+///
+/// Returns a new vector of ranges, and the range of the vector that was changed,
+/// from the previous `ranges` vector.
 pub(crate) fn splice_included_ranges(
     mut ranges: Vec<tree_sitter::Range>,
     removed_ranges: &[Range<usize>],
     new_ranges: &[tree_sitter::Range],
-) -> Vec<tree_sitter::Range> {
+) -> (Vec<tree_sitter::Range>, Range<usize>) {
     let mut removed_ranges = removed_ranges.iter().cloned().peekable();
     let mut new_ranges = new_ranges.into_iter().cloned().peekable();
     let mut ranges_ix = 0;
+    let mut changed_portion = usize::MAX..0;
     loop {
         let next_new_range = new_ranges.peek();
         let next_removed_range = removed_ranges.peek();
@@ -1344,11 +1368,69 @@ pub(crate) fn splice_included_ranges(
             }
         }
 
+        changed_portion.start = changed_portion.start.min(start_ix);
+        changed_portion.end = changed_portion.end.max(if insert.is_some() {
+            start_ix + 1
+        } else {
+            start_ix
+        });
+
         ranges.splice(start_ix..end_ix, insert);
         ranges_ix = start_ix;
     }
 
-    ranges
+    if changed_portion.end < changed_portion.start {
+        changed_portion = 0..0;
+    }
+
+    (ranges, changed_portion)
+}
+
+/// Ensure there are newline ranges in between content range that appear on
+/// different lines. For performance, only iterate through the given range of
+/// indices. All of the ranges in the array are relative to a given start byte
+/// and point.
+fn insert_newlines_between_ranges(
+    indices: Range<usize>,
+    ranges: &mut Vec<tree_sitter::Range>,
+    text: &text::BufferSnapshot,
+    start_byte: usize,
+    start_point: Point,
+) {
+    let mut ix = indices.end + 1;
+    while ix > indices.start {
+        ix -= 1;
+        if 0 == ix || ix == ranges.len() {
+            continue;
+        }
+
+        let range_b = ranges[ix].clone();
+        let range_a = &mut ranges[ix - 1];
+        if range_a.end_point.column == 0 {
+            continue;
+        }
+
+        if range_a.end_point.row < range_b.start_point.row {
+            let end_point = start_point + Point::from_ts_point(range_a.end_point);
+            let line_end = Point::new(end_point.row, text.line_len(end_point.row));
+            if end_point.column as u32 >= line_end.column {
+                range_a.end_byte += 1;
+                range_a.end_point.row += 1;
+                range_a.end_point.column = 0;
+            } else {
+                let newline_offset = text.point_to_offset(line_end);
+                ranges.insert(
+                    ix,
+                    tree_sitter::Range {
+                        start_byte: newline_offset - start_byte,
+                        end_byte: newline_offset - start_byte + 1,
+                        start_point: (line_end - start_point).to_ts_point(),
+                        end_point: ((line_end - start_point) + Point::new(1, 0)).to_ts_point(),
+                    },
+                )
+            }
+        }
+    }
 }
 
 impl OwnedSyntaxLayerInfo {

crates/language/src/syntax_map/syntax_map_tests.rs 🔗

@@ -11,7 +11,7 @@ use util::test::marked_text_ranges;
 fn test_splice_included_ranges() {
     let ranges = vec![ts_range(20..30), ts_range(50..60), ts_range(80..90)];
 
-    let new_ranges = splice_included_ranges(
+    let (new_ranges, change) = splice_included_ranges(
         ranges.clone(),
         &[54..56, 58..68],
         &[ts_range(50..54), ts_range(59..67)],
@@ -25,14 +25,16 @@ fn test_splice_included_ranges() {
             ts_range(80..90),
         ]
     );
+    assert_eq!(change, 1..3);
 
-    let new_ranges = splice_included_ranges(ranges.clone(), &[70..71, 91..100], &[]);
+    let (new_ranges, change) = splice_included_ranges(ranges.clone(), &[70..71, 91..100], &[]);
     assert_eq!(
         new_ranges,
         &[ts_range(20..30), ts_range(50..60), ts_range(80..90)]
     );
+    assert_eq!(change, 2..3);
 
-    let new_ranges =
+    let (new_ranges, change) =
         splice_included_ranges(ranges.clone(), &[], &[ts_range(0..2), ts_range(70..75)]);
     assert_eq!(
         new_ranges,
@@ -44,16 +46,21 @@ fn test_splice_included_ranges() {
             ts_range(80..90)
         ]
     );
+    assert_eq!(change, 0..4);
 
-    let new_ranges = splice_included_ranges(ranges.clone(), &[30..50], &[ts_range(25..55)]);
+    let (new_ranges, change) =
+        splice_included_ranges(ranges.clone(), &[30..50], &[ts_range(25..55)]);
     assert_eq!(new_ranges, &[ts_range(25..55), ts_range(80..90)]);
+    assert_eq!(change, 0..1);
 
     // does not create overlapping ranges
-    let new_ranges = splice_included_ranges(ranges.clone(), &[0..18], &[ts_range(20..32)]);
+    let (new_ranges, change) =
+        splice_included_ranges(ranges.clone(), &[0..18], &[ts_range(20..32)]);
     assert_eq!(
         new_ranges,
         &[ts_range(20..32), ts_range(50..60), ts_range(80..90)]
     );
+    assert_eq!(change, 0..1);
 
     fn ts_range(range: Range<usize>) -> tree_sitter::Range {
         tree_sitter::Range {
@@ -511,7 +518,7 @@ fn test_removing_injection_by_replacing_across_boundary() {
 }
 
 #[gpui::test]
-fn test_combined_injections() {
+fn test_combined_injections_simple() {
     let (buffer, syntax_map) = test_edit_sequence(
         "ERB",
         &[
@@ -653,33 +660,78 @@ fn test_combined_injections_editing_after_last_injection() {
 
 #[gpui::test]
 fn test_combined_injections_inside_injections() {
-    let (_buffer, _syntax_map) = test_edit_sequence(
+    let (buffer, syntax_map) = test_edit_sequence(
         "Markdown",
         &[
             r#"
-                here is some ERB code:
+                here is
+                some
+                ERB code:
 
                 ```erb
                 <ul>
                 <% people.each do |person| %>
                     <li><%= person.name %></li>
+                    <li><%= person.age %></li>
                 <% end %>
                 </ul>
                 ```
             "#,
             r#"
-                here is some ERB code:
+                here is
+                some
+                ERB code:
 
                 ```erb
                 <ul>
                 <% people«2».each do |person| %>
                     <li><%= person.name %></li>
+                    <li><%= person.age %></li>
+                <% end %>
+                </ul>
+                ```
+            "#,
+            // Inserting a comment character inside one code directive
+            // does not cause the other code directive to become a comment,
+            // because newlines are included in between each injection range.
+            r#"
+                here is
+                some
+                ERB code:
+
+                ```erb
+                <ul>
+                <% people2.each do |person| %>
+                    <li><%= «# »person.name %></li>
+                    <li><%= person.age %></li>
                 <% end %>
                 </ul>
                 ```
             "#,
         ],
     );
+
+    // Check that the code directive below the ruby comment is
+    // not parsed as a comment.
+    assert_capture_ranges(
+        &syntax_map,
+        &buffer,
+        &["method"],
+        "
+            here is
+            some
+            ERB code:
+
+            ```erb
+            <ul>
+            <% people2.«each» do |person| %>
+                <li><%= # person.name %></li>
+                <li><%= person.«age» %></li>
+            <% end %>
+            </ul>
+            ```
+        ",
+    );
 }
 
 #[gpui::test]
@@ -984,11 +1036,14 @@ fn check_interpolation(
 
 fn test_edit_sequence(language_name: &str, steps: &[&str]) -> (Buffer, SyntaxMap) {
     let registry = Arc::new(LanguageRegistry::test());
+    registry.add(Arc::new(elixir_lang()));
+    registry.add(Arc::new(heex_lang()));
     registry.add(Arc::new(rust_lang()));
     registry.add(Arc::new(ruby_lang()));
     registry.add(Arc::new(html_lang()));
     registry.add(Arc::new(erb_lang()));
     registry.add(Arc::new(markdown_lang()));
+
     let language = registry
         .language_for_name(language_name)
         .now_or_never()
@@ -1074,6 +1129,7 @@ fn ruby_lang() -> Language {
         r#"
             ["if" "do" "else" "end"] @keyword
             (instance_variable) @ivar
+            (call method: (identifier) @method)
         "#,
     )
     .unwrap()
@@ -1158,6 +1214,52 @@ fn markdown_lang() -> Language {
     .unwrap()
 }
 
+fn elixir_lang() -> Language {
+    Language::new(
+        LanguageConfig {
+            name: "Elixir".into(),
+            path_suffixes: vec!["ex".into()],
+            ..Default::default()
+        },
+        Some(tree_sitter_elixir::language()),
+    )
+    .with_highlights_query(
+        r#"
+
+        "#,
+    )
+    .unwrap()
+}
+
+fn heex_lang() -> Language {
+    Language::new(
+        LanguageConfig {
+            name: "HEEx".into(),
+            path_suffixes: vec!["heex".into()],
+            ..Default::default()
+        },
+        Some(tree_sitter_heex::language()),
+    )
+    .with_injection_query(
+        r#"
+        (
+          (directive
+            [
+              (partial_expression_value)
+              (expression_value)
+              (ending_expression_value)
+            ] @content)
+          (#set! language "elixir")
+          (#set! combined)
+        )
+
+        ((expression (expression_value) @content)
+         (#set! language "elixir"))
+        "#,
+    )
+    .unwrap()
+}
+
 fn range_for_text(buffer: &Buffer, text: &str) -> Range<usize> {
     let start = buffer.as_rope().to_string().find(text).unwrap();
     start..start + text.len()

crates/zed/src/languages/heex/config.toml 🔗

@@ -4,4 +4,4 @@ autoclose_before = ">})"
 brackets = [
     { start = "<", end = ">", close = true, newline = true },
 ]
-block_comment = ["<%!--", "--%>"]
+block_comment = ["<%!-- ", " --%>"]

crates/zed/src/languages/heex/highlights.scm 🔗

@@ -1,10 +1,7 @@
 ; HEEx delimiters
 [
-  "--%>"
-  "-->"
   "/>"
   "<!"
-  "<!--"
   "<"
   "</"
   "</:"
@@ -21,6 +18,9 @@
   "<%%="
   "<%="
   "%>"
+  "--%>"
+  "-->"
+  "<!--"
 ] @keyword
 
 ; HEEx operators are highlighted as such