Extend symbol ranges by their annotation range when suggesting edits (#15677)

Antonio Scandurra and Nathan created

Release Notes:

- N/A

---------

Co-authored-by: Nathan <nathan@zed.dev>

Change summary

crates/assistant/src/context.rs         |  27 +
crates/language/src/buffer.rs           | 278 ++++++++++++++++----------
crates/language/src/buffer_tests.rs     |  57 +++++
crates/language/src/language.rs         |   4 
crates/language/src/outline.rs          |   1 
crates/languages/src/rust/outline.scm   |   3 
crates/multi_buffer/src/multi_buffer.rs |  12 +
7 files changed, 261 insertions(+), 121 deletions(-)

Detailed changes

crates/assistant/src/context.rs 🔗

@@ -598,6 +598,10 @@ impl EditOperation {
                 buffer.update(&mut cx, |buffer, _| {
                     let outline_item = &outline.items[candidate.id];
                     let symbol_range = outline_item.range.to_point(buffer);
+                    let annotation_range = outline_item
+                        .annotation_range
+                        .as_ref()
+                        .map(|range| range.to_point(buffer));
                     let body_range = outline_item
                         .body_range
                         .as_ref()
@@ -606,23 +610,28 @@ impl EditOperation {
 
                     match kind {
                         EditOperationKind::PrependChild { .. } => {
-                            let position = buffer.anchor_after(body_range.start);
-                            position..position
+                            let anchor = buffer.anchor_after(body_range.start);
+                            anchor..anchor
                         }
                         EditOperationKind::AppendChild { .. } => {
-                            let position = buffer.anchor_before(body_range.end);
-                            position..position
+                            let anchor = buffer.anchor_before(body_range.end);
+                            anchor..anchor
                         }
                         EditOperationKind::InsertSiblingBefore { .. } => {
-                            let position = buffer.anchor_before(symbol_range.start);
-                            position..position
+                            let anchor = buffer.anchor_before(
+                                annotation_range.map_or(symbol_range.start, |annotation_range| {
+                                    annotation_range.start
+                                }),
+                            );
+                            anchor..anchor
                         }
                         EditOperationKind::InsertSiblingAfter { .. } => {
-                            let position = buffer.anchor_after(symbol_range.end);
-                            position..position
+                            let anchor = buffer.anchor_after(symbol_range.end);
+                            anchor..anchor
                         }
                         EditOperationKind::Update { .. } | EditOperationKind::Delete { .. } => {
-                            let start = Point::new(symbol_range.start.row, 0);
+                            let start = annotation_range.map_or(symbol_range.start, |range| range.start);
+                            let start = Point::new(start.row, 0);
                             let end = Point::new(
                                 symbol_range.end.row,
                                 buffer.line_len(symbol_range.end.row),

crates/language/src/buffer.rs 🔗

@@ -10,11 +10,11 @@ use crate::{
     markdown::parse_markdown,
     outline::OutlineItem,
     syntax_map::{
-        SyntaxLayer, SyntaxMap, SyntaxMapCapture, SyntaxMapCaptures, SyntaxMapMatches,
-        SyntaxSnapshot, ToTreeSitterPoint,
+        SyntaxLayer, SyntaxMap, SyntaxMapCapture, SyntaxMapCaptures, SyntaxMapMatch,
+        SyntaxMapMatches, SyntaxSnapshot, ToTreeSitterPoint,
     },
     task_context::RunnableRange,
-    LanguageScope, Outline, RunnableCapture, RunnableTag,
+    LanguageScope, Outline, OutlineConfig, RunnableCapture, RunnableTag,
 };
 use anyhow::{anyhow, Context, Result};
 use async_watch as watch;
@@ -2768,130 +2768,44 @@ impl BufferSnapshot {
             .collect::<Vec<_>>();
 
         let mut items = Vec::new();
+        let mut annotation_row_ranges: Vec<Range<u32>> = Vec::new();
         while let Some(mat) = matches.peek() {
             let config = &configs[mat.grammar_index];
-            let item_node = mat.captures.iter().find_map(|cap| {
-                if cap.index == config.item_capture_ix {
-                    Some(cap.node)
-                } else {
-                    None
-                }
-            })?;
-
-            let item_range = item_node.byte_range();
-            if item_range.end < range.start || item_range.start > range.end {
-                matches.advance();
-                continue;
-            }
-
-            let mut open_index = None;
-            let mut close_index = None;
-
-            let mut buffer_ranges = Vec::new();
-            for capture in mat.captures {
-                let node_is_name;
-                if capture.index == config.name_capture_ix {
-                    node_is_name = true;
-                } else if Some(capture.index) == config.context_capture_ix
-                    || (Some(capture.index) == config.extra_context_capture_ix
-                        && include_extra_context)
+            if let Some(item) =
+                self.next_outline_item(config, &mat, &range, include_extra_context, theme)
+            {
+                items.push(item);
+            } else if let Some(capture) = mat
+                .captures
+                .iter()
+                .find(|capture| Some(capture.index) == config.annotation_capture_ix)
+            {
+                let capture_range = capture.node.start_position()..capture.node.end_position();
+                let mut capture_row_range =
+                    capture_range.start.row as u32..capture_range.end.row as u32;
+                if capture_range.end.row > capture_range.start.row && capture_range.end.column == 0
                 {
-                    node_is_name = false;
-                } else {
-                    if Some(capture.index) == config.open_capture_ix {
-                        open_index = Some(capture.node.end_byte());
-                    } else if Some(capture.index) == config.close_capture_ix {
-                        close_index = Some(capture.node.start_byte());
-                    }
-
-                    continue;
+                    capture_row_range.end -= 1;
                 }
-
-                let mut range = capture.node.start_byte()..capture.node.end_byte();
-                let start = capture.node.start_position();
-                if capture.node.end_position().row > start.row {
-                    range.end =
-                        range.start + self.line_len(start.row as u32) as usize - start.column;
-                }
-
-                if !range.is_empty() {
-                    buffer_ranges.push((range, node_is_name));
-                }
-            }
-
-            if buffer_ranges.is_empty() {
-                matches.advance();
-                continue;
-            }
-
-            let mut text = String::new();
-            let mut highlight_ranges = Vec::new();
-            let mut name_ranges = Vec::new();
-            let mut chunks = self.chunks(
-                buffer_ranges.first().unwrap().0.start..buffer_ranges.last().unwrap().0.end,
-                true,
-            );
-            let mut last_buffer_range_end = 0;
-            for (buffer_range, is_name) in buffer_ranges {
-                if !text.is_empty() && buffer_range.start > last_buffer_range_end {
-                    text.push(' ');
-                }
-                last_buffer_range_end = buffer_range.end;
-                if is_name {
-                    let mut start = text.len();
-                    let end = start + buffer_range.len();
-
-                    // When multiple names are captured, then the matcheable text
-                    // includes the whitespace in between the names.
-                    if !name_ranges.is_empty() {
-                        start -= 1;
-                    }
-
-                    name_ranges.push(start..end);
-                }
-
-                let mut offset = buffer_range.start;
-                chunks.seek(offset);
-                for mut chunk in chunks.by_ref() {
-                    if chunk.text.len() > buffer_range.end - offset {
-                        chunk.text = &chunk.text[0..(buffer_range.end - offset)];
-                        offset = buffer_range.end;
+                if let Some(last_row_range) = annotation_row_ranges.last_mut() {
+                    if last_row_range.end >= capture_row_range.start.saturating_sub(1) {
+                        last_row_range.end = capture_row_range.end;
                     } else {
-                        offset += chunk.text.len();
-                    }
-                    let style = chunk
-                        .syntax_highlight_id
-                        .zip(theme)
-                        .and_then(|(highlight, theme)| highlight.style(theme));
-                    if let Some(style) = style {
-                        let start = text.len();
-                        let end = start + chunk.text.len();
-                        highlight_ranges.push((start..end, style));
-                    }
-                    text.push_str(chunk.text);
-                    if offset >= buffer_range.end {
-                        break;
+                        annotation_row_ranges.push(capture_row_range);
                     }
+                } else {
+                    annotation_row_ranges.push(capture_row_range);
                 }
             }
-
             matches.advance();
-
-            items.push(OutlineItem {
-                depth: 0, // We'll calculate the depth later
-                range: item_range,
-                text,
-                highlight_ranges,
-                name_ranges,
-                body_range: open_index.zip(close_index).map(|(start, end)| start..end),
-            });
         }
 
         items.sort_by_key(|item| (item.range.start, Reverse(item.range.end)));
 
         // Assign depths based on containment relationships and convert to anchors.
-        let mut item_ends_stack = Vec::<usize>::new();
+        let mut item_ends_stack = Vec::<Point>::new();
         let mut anchor_items = Vec::new();
+        let mut annotation_row_ranges = annotation_row_ranges.into_iter().peekable();
         for item in items {
             while let Some(last_end) = item_ends_stack.last().copied() {
                 if last_end < item.range.end {
@@ -2901,6 +2815,20 @@ impl BufferSnapshot {
                 }
             }
 
+            let mut annotation_row_range = None;
+            while let Some(next_annotation_row_range) = annotation_row_ranges.peek() {
+                let row_preceding_item = item.range.start.row.saturating_sub(1);
+                if next_annotation_row_range.end < row_preceding_item {
+                    annotation_row_ranges.next();
+                } else {
+                    if next_annotation_row_range.end == row_preceding_item {
+                        annotation_row_range = Some(next_annotation_row_range.clone());
+                        annotation_row_ranges.next();
+                    }
+                    break;
+                }
+            }
+
             anchor_items.push(OutlineItem {
                 depth: item_ends_stack.len(),
                 range: self.anchor_after(item.range.start)..self.anchor_before(item.range.end),
@@ -2910,6 +2838,13 @@ impl BufferSnapshot {
                 body_range: item.body_range.map(|body_range| {
                     self.anchor_after(body_range.start)..self.anchor_before(body_range.end)
                 }),
+                annotation_range: annotation_row_range.map(|annotation_range| {
+                    self.anchor_after(Point::new(annotation_range.start, 0))
+                        ..self.anchor_before(Point::new(
+                            annotation_range.end,
+                            self.line_len(annotation_range.end),
+                        ))
+                }),
             });
             item_ends_stack.push(item.range.end);
         }
@@ -2917,6 +2852,125 @@ impl BufferSnapshot {
         Some(anchor_items)
     }
 
+    fn next_outline_item(
+        &self,
+        config: &OutlineConfig,
+        mat: &SyntaxMapMatch,
+        range: &Range<usize>,
+        include_extra_context: bool,
+        theme: Option<&SyntaxTheme>,
+    ) -> Option<OutlineItem<Point>> {
+        let item_node = mat.captures.iter().find_map(|cap| {
+            if cap.index == config.item_capture_ix {
+                Some(cap.node)
+            } else {
+                None
+            }
+        })?;
+
+        let item_byte_range = item_node.byte_range();
+        if item_byte_range.end < range.start || item_byte_range.start > range.end {
+            return None;
+        }
+        let item_point_range = Point::from_ts_point(item_node.start_position())
+            ..Point::from_ts_point(item_node.end_position());
+
+        let mut open_point = None;
+        let mut close_point = None;
+        let mut buffer_ranges = Vec::new();
+        for capture in mat.captures {
+            let node_is_name;
+            if capture.index == config.name_capture_ix {
+                node_is_name = true;
+            } else if Some(capture.index) == config.context_capture_ix
+                || (Some(capture.index) == config.extra_context_capture_ix && include_extra_context)
+            {
+                node_is_name = false;
+            } else {
+                if Some(capture.index) == config.open_capture_ix {
+                    open_point = Some(Point::from_ts_point(capture.node.end_position()));
+                } else if Some(capture.index) == config.close_capture_ix {
+                    close_point = Some(Point::from_ts_point(capture.node.start_position()));
+                }
+
+                continue;
+            }
+
+            let mut range = capture.node.start_byte()..capture.node.end_byte();
+            let start = capture.node.start_position();
+            if capture.node.end_position().row > start.row {
+                range.end = range.start + self.line_len(start.row as u32) as usize - start.column;
+            }
+
+            if !range.is_empty() {
+                buffer_ranges.push((range, node_is_name));
+            }
+        }
+        if buffer_ranges.is_empty() {
+            return None;
+        }
+        let mut text = String::new();
+        let mut highlight_ranges = Vec::new();
+        let mut name_ranges = Vec::new();
+        let mut chunks = self.chunks(
+            buffer_ranges.first().unwrap().0.start..buffer_ranges.last().unwrap().0.end,
+            true,
+        );
+        let mut last_buffer_range_end = 0;
+        for (buffer_range, is_name) in buffer_ranges {
+            if !text.is_empty() && buffer_range.start > last_buffer_range_end {
+                text.push(' ');
+            }
+            last_buffer_range_end = buffer_range.end;
+            if is_name {
+                let mut start = text.len();
+                let end = start + buffer_range.len();
+
+                // When multiple names are captured, then the matcheable text
+                // includes the whitespace in between the names.
+                if !name_ranges.is_empty() {
+                    start -= 1;
+                }
+
+                name_ranges.push(start..end);
+            }
+
+            let mut offset = buffer_range.start;
+            chunks.seek(offset);
+            for mut chunk in chunks.by_ref() {
+                if chunk.text.len() > buffer_range.end - offset {
+                    chunk.text = &chunk.text[0..(buffer_range.end - offset)];
+                    offset = buffer_range.end;
+                } else {
+                    offset += chunk.text.len();
+                }
+                let style = chunk
+                    .syntax_highlight_id
+                    .zip(theme)
+                    .and_then(|(highlight, theme)| highlight.style(theme));
+                if let Some(style) = style {
+                    let start = text.len();
+                    let end = start + chunk.text.len();
+                    highlight_ranges.push((start..end, style));
+                }
+                text.push_str(chunk.text);
+                if offset >= buffer_range.end {
+                    break;
+                }
+            }
+        }
+
+        Some(OutlineItem {
+            depth: 0, // We'll calculate the depth later
+            range: item_point_range,
+            text,
+            highlight_ranges,
+            name_ranges,
+            body_range: open_point.zip(close_point).map(|(start, end)| start..end),
+            annotation_range: None,
+        })
+    }
+
     /// For each grammar in the language, runs the provided
     /// [tree_sitter::Query] against the given range.
     pub fn matches(

crates/language/src/buffer_tests.rs 🔗

@@ -775,6 +775,61 @@ async fn test_outline_with_extra_context(cx: &mut gpui::TestAppContext) {
     );
 }
 
+#[gpui::test]
+fn test_outline_annotations(cx: &mut AppContext) {
+    // Add this new test case
+    let text = r#"
+        /// This is a doc comment
+        /// that spans multiple lines
+        fn annotated_function() {
+            // This is not an annotation
+        }
+
+        // This is a single-line annotation
+        fn another_function() {}
+
+        fn unannotated_function() {}
+
+        // This comment is not an annotation
+
+        fn function_after_blank_line() {}
+    "#
+    .unindent();
+
+    let buffer =
+        cx.new_model(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx));
+    let outline = buffer
+        .update(cx, |buffer, _| buffer.snapshot().outline(None))
+        .unwrap();
+
+    assert_eq!(
+        outline
+            .items
+            .into_iter()
+            .map(|item| (
+                item.text,
+                item.depth,
+                item.annotation_range
+                    .map(|range| { buffer.read(cx).text_for_range(range).collect::<String>() })
+            ))
+            .collect::<Vec<_>>(),
+        &[
+            (
+                "fn annotated_function".to_string(),
+                0,
+                Some("/// This is a doc comment\n/// that spans multiple lines".to_string())
+            ),
+            (
+                "fn another_function".to_string(),
+                0,
+                Some("// This is a single-line annotation".to_string())
+            ),
+            ("fn unannotated_function".to_string(), 0, None),
+            ("fn function_after_blank_line".to_string(), 0, None),
+        ]
+    );
+}
+
 #[gpui::test]
 async fn test_symbols_containing(cx: &mut gpui::TestAppContext) {
     let text = r#"
@@ -2603,6 +2658,8 @@ fn rust_lang() -> Language {
     .unwrap()
     .with_outline_query(
         r#"
+        (line_comment) @annotation
+
         (struct_item
             "struct" @context
             name: (_) @name) @item

crates/language/src/language.rs 🔗

@@ -864,6 +864,7 @@ pub struct OutlineConfig {
     pub extra_context_capture_ix: Option<u32>,
     pub open_capture_ix: Option<u32>,
     pub close_capture_ix: Option<u32>,
+    pub annotation_capture_ix: Option<u32>,
 }
 
 #[derive(Debug)]
@@ -1049,6 +1050,7 @@ impl Language {
         let mut extra_context_capture_ix = None;
         let mut open_capture_ix = None;
         let mut close_capture_ix = None;
+        let mut annotation_capture_ix = None;
         get_capture_indices(
             &query,
             &mut [
@@ -1058,6 +1060,7 @@ impl Language {
                 ("context.extra", &mut extra_context_capture_ix),
                 ("open", &mut open_capture_ix),
                 ("close", &mut close_capture_ix),
+                ("annotation", &mut annotation_capture_ix),
             ],
         );
         if let Some((item_capture_ix, name_capture_ix)) = item_capture_ix.zip(name_capture_ix) {
@@ -1069,6 +1072,7 @@ impl Language {
                 extra_context_capture_ix,
                 open_capture_ix,
                 close_capture_ix,
+                annotation_capture_ix,
             });
         }
         Ok(self)

crates/language/src/outline.rs 🔗

@@ -21,6 +21,7 @@ pub struct OutlineItem<T> {
     pub highlight_ranges: Vec<(Range<usize>, HighlightStyle)>,
     pub name_ranges: Vec<Range<usize>>,
     pub body_range: Option<Range<T>>,
+    pub annotation_range: Option<Range<T>>,
 }
 
 impl<T> Outline<T> {

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -3646,6 +3646,12 @@ impl MultiBufferSnapshot {
                                     ..self.anchor_in_excerpt(*excerpt_id, body_range.end)?,
                             )
                         }),
+                        annotation_range: item.annotation_range.and_then(|annotation_range| {
+                            Some(
+                                self.anchor_in_excerpt(*excerpt_id, annotation_range.start)?
+                                    ..self.anchor_in_excerpt(*excerpt_id, annotation_range.end)?,
+                            )
+                        }),
                     })
                 })
                 .collect(),
@@ -3681,6 +3687,12 @@ impl MultiBufferSnapshot {
                                     ..self.anchor_in_excerpt(excerpt_id, body_range.end)?,
                             )
                         }),
+                        annotation_range: item.annotation_range.and_then(|body_range| {
+                            Some(
+                                self.anchor_in_excerpt(excerpt_id, body_range.start)?
+                                    ..self.anchor_in_excerpt(excerpt_id, body_range.end)?,
+                            )
+                        }),
                     })
                 })
                 .collect(),