edit predictions: Add new excerpt logic (not yet used) (#38226)

Michael Sloan and agus created

Release Notes:

- N/A

---------

Co-authored-by: agus <agus@zed.dev>

Change summary

Cargo.lock                                                    |  16 
Cargo.toml                                                    |   2 
crates/edit_prediction_context/Cargo.toml                     |  29 
crates/edit_prediction_context/LICENSE-GPL                    |   1 
crates/edit_prediction_context/src/edit_prediction_context.rs |   3 
crates/edit_prediction_context/src/excerpt.rs                 | 595 +++++
crates/language/src/buffer.rs                                 |  73 
crates/language/src/outline.rs                                |   9 
crates/multi_buffer/src/multi_buffer.rs                       |  12 
crates/outline_panel/src/outline_panel.rs                     |   1 
10 files changed, 718 insertions(+), 23 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5039,6 +5039,22 @@ dependencies = [
  "zeta",
 ]
 
+[[package]]
+name = "edit_prediction_context"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "indoc",
+ "language",
+ "log",
+ "pretty_assertions",
+ "text",
+ "tree-sitter",
+ "util",
+ "workspace-hack",
+ "zlog",
+]
+
 [[package]]
 name = "editor"
 version = "0.1.0"

Cargo.toml 🔗

@@ -56,6 +56,7 @@ members = [
     "crates/docs_preprocessor",
     "crates/edit_prediction",
     "crates/edit_prediction_button",
+    "crates/edit_prediction_context",
     "crates/editor",
     "crates/eval",
     "crates/explorer_command_injector",
@@ -312,6 +313,7 @@ icons = { path = "crates/icons" }
 image_viewer = { path = "crates/image_viewer" }
 edit_prediction = { path = "crates/edit_prediction" }
 edit_prediction_button = { path = "crates/edit_prediction_button" }
+edit_prediction_context = { path = "crates/edit_prediction_context" }
 inspector_ui = { path = "crates/inspector_ui" }
 install_cli = { path = "crates/install_cli" }
 jj = { path = "crates/jj" }

crates/edit_prediction_context/Cargo.toml 🔗

@@ -0,0 +1,29 @@
+[package]
+name = "edit_prediction_context"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/edit_prediction_context.rs"
+
+[dependencies]
+language.workspace = true
+workspace-hack.workspace = true
+tree-sitter.workspace = true
+text.workspace = true
+log.workspace = true
+util.workspace = true
+
+[dev-dependencies]
+gpui = { workspace = true, features = ["test-support"] }
+indoc.workspace = true
+language = { workspace = true, features = ["test-support"] }
+pretty_assertions.workspace = true
+text = { workspace = true, features = ["test-support"] }
+util = { workspace = true, features = ["test-support"] }
+zlog.workspace = true

crates/edit_prediction_context/src/excerpt.rs 🔗

@@ -0,0 +1,595 @@
+use language::BufferSnapshot;
+use std::ops::Range;
+use text::{OffsetRangeExt as _, Point, ToOffset as _, ToPoint as _};
+use tree_sitter::{Node, TreeCursor};
+use util::RangeExt;
+
+// TODO:
+//
+// - Test parent signatures
+//
+// - Decide whether to count signatures against the excerpt size. Could instead defer this to prompt
+// planning.
+//
+// - Still return an excerpt even if the line around the cursor doesn't fit (e.g. for a markdown
+// paragraph).
+//
+// - Truncation of long lines.
+//
+// - Filter outer syntax layers that don't support edit prediction.
+
+#[derive(Debug, Clone)]
+pub struct EditPredictionExcerptOptions {
+    /// Limit for the number of bytes in the window around the cursor.
+    pub max_bytes: usize,
+    /// Minimum number of bytes in the window around the cursor. When syntax tree selection results
+    /// in an excerpt smaller than this, it will fall back on line-based selection.
+    pub min_bytes: usize,
+    /// Target ratio of bytes before the cursor divided by total bytes in the window.
+    pub target_before_cursor_over_total_bytes: f32,
+    /// Whether to include parent signatures
+    pub include_parent_signatures: bool,
+}
+
+#[derive(Clone)]
+pub struct EditPredictionExcerpt {
+    pub range: Range<usize>,
+    pub parent_signature_ranges: Vec<Range<usize>>,
+    pub size: usize,
+}
+
+impl EditPredictionExcerpt {
+    /// Selects an excerpt around a buffer position, attempting to choose logical boundaries based
+    /// on TreeSitter structure and approximately targeting a goal ratio of bytesbefore vs after the
+    /// cursor. When `include_parent_signatures` is true, the excerpt also includes the signatures
+    /// of parent outline items.
+    ///
+    /// First tries to use AST node boundaries to select the excerpt, and falls back on line-based
+    /// expansion.
+    ///
+    /// Returns `None` if the line around the cursor doesn't fit.
+    pub fn select_from_buffer(
+        query_point: Point,
+        buffer: &BufferSnapshot,
+        options: &EditPredictionExcerptOptions,
+    ) -> Option<Self> {
+        if buffer.len() <= options.max_bytes {
+            log::debug!(
+                "using entire file for excerpt since source length ({}) <= window max bytes ({})",
+                buffer.len(),
+                options.max_bytes
+            );
+            return Some(EditPredictionExcerpt::new(0..buffer.len(), Vec::new()));
+        }
+
+        let query_offset = query_point.to_offset(buffer);
+        let query_range = Point::new(query_point.row, 0).to_offset(buffer)
+            ..Point::new(query_point.row + 1, 0).to_offset(buffer);
+        if query_range.len() >= options.max_bytes {
+            return None;
+        }
+
+        // TODO: Don't compute text / annotation_range / skip converting to and from anchors.
+        let outline_items = if options.include_parent_signatures {
+            buffer
+                .outline_items_containing(query_range.clone(), false, None)
+                .into_iter()
+                .flat_map(|item| {
+                    Some(ExcerptOutlineItem {
+                        item_range: item.range.to_offset(&buffer),
+                        signature_range: item.signature_range?.to_offset(&buffer),
+                    })
+                })
+                .collect()
+        } else {
+            Vec::new()
+        };
+
+        let excerpt_selector = ExcerptSelector {
+            query_offset,
+            query_range,
+            outline_items: &outline_items,
+            buffer,
+            options,
+        };
+
+        if let Some(excerpt_ranges) = excerpt_selector.select_tree_sitter_nodes() {
+            if excerpt_ranges.size >= options.min_bytes {
+                return Some(excerpt_ranges);
+            }
+            log::debug!(
+                "tree-sitter excerpt was {} bytes, smaller than min of {}, falling back on line-based selection",
+                excerpt_ranges.size,
+                options.min_bytes
+            );
+        } else {
+            log::debug!(
+                "couldn't find excerpt via tree-sitter, falling back on line-based selection"
+            );
+        }
+
+        excerpt_selector.select_lines()
+    }
+
+    fn new(range: Range<usize>, parent_signature_ranges: Vec<Range<usize>>) -> Self {
+        let size = range.len()
+            + parent_signature_ranges
+                .iter()
+                .map(|r| r.len())
+                .sum::<usize>();
+        Self {
+            range,
+            parent_signature_ranges,
+            size,
+        }
+    }
+
+    fn with_expanded_range(&self, new_range: Range<usize>) -> Self {
+        if !new_range.contains_inclusive(&self.range) {
+            // this is an issue because parent_signature_ranges may be incorrect
+            log::error!("bug: with_expanded_range called with disjoint range");
+        }
+        let mut parent_signature_ranges = Vec::with_capacity(self.parent_signature_ranges.len());
+        let mut size = new_range.len();
+        for range in &self.parent_signature_ranges {
+            if range.contains_inclusive(&new_range) {
+                break;
+            }
+            parent_signature_ranges.push(range.clone());
+            size += range.len();
+        }
+        Self {
+            range: new_range,
+            parent_signature_ranges,
+            size,
+        }
+    }
+
+    fn parent_signatures_size(&self) -> usize {
+        self.size - self.range.len()
+    }
+}
+
+struct ExcerptSelector<'a> {
+    query_offset: usize,
+    query_range: Range<usize>,
+    outline_items: &'a [ExcerptOutlineItem],
+    buffer: &'a BufferSnapshot,
+    options: &'a EditPredictionExcerptOptions,
+}
+
+struct ExcerptOutlineItem {
+    item_range: Range<usize>,
+    signature_range: Range<usize>,
+}
+
+impl<'a> ExcerptSelector<'a> {
+    /// Finds the largest node that is smaller than the window size and contains `query_range`.
+    fn select_tree_sitter_nodes(&self) -> Option<EditPredictionExcerpt> {
+        let selected_layer_root = self.select_syntax_layer()?;
+        let mut cursor = selected_layer_root.walk();
+
+        loop {
+            let excerpt_range = node_line_start(cursor.node()).to_offset(&self.buffer)
+                ..node_line_end(cursor.node()).to_offset(&self.buffer);
+            if excerpt_range.contains_inclusive(&self.query_range) {
+                let excerpt = self.make_excerpt(excerpt_range);
+                if excerpt.size <= self.options.max_bytes {
+                    return Some(self.expand_to_siblings(&mut cursor, excerpt));
+                }
+            } else {
+                // TODO: Should still be able to handle this case via AST nodes. For example, this
+                // can happen if the cursor is between two methods in a large class file.
+                return None;
+            }
+
+            if cursor
+                .goto_first_child_for_byte(self.query_range.start)
+                .is_none()
+            {
+                return None;
+            }
+        }
+    }
+
+    /// Select the smallest syntax layer that exceeds max_len, or the largest if none exceed max_len.
+    fn select_syntax_layer(&self) -> Option<Node<'_>> {
+        let mut smallest_exceeding_max_len: Option<Node<'_>> = None;
+        let mut largest: Option<Node<'_>> = None;
+        for layer in self
+            .buffer
+            .syntax_layers_for_range(self.query_range.start..self.query_range.start, true)
+        {
+            let layer_range = layer.node().byte_range();
+            if !layer_range.contains_inclusive(&self.query_range) {
+                continue;
+            }
+
+            if layer_range.len() > self.options.max_bytes {
+                match &smallest_exceeding_max_len {
+                    None => smallest_exceeding_max_len = Some(layer.node()),
+                    Some(existing) => {
+                        if layer_range.len() < existing.byte_range().len() {
+                            smallest_exceeding_max_len = Some(layer.node());
+                        }
+                    }
+                }
+            } else {
+                match &largest {
+                    None => largest = Some(layer.node()),
+                    Some(existing) if layer_range.len() > existing.byte_range().len() => {
+                        largest = Some(layer.node())
+                    }
+                    _ => {}
+                }
+            }
+        }
+
+        smallest_exceeding_max_len.or(largest)
+    }
+
+    // motivation for this and `goto_previous_named_sibling` is to avoid including things like
+    // trailing unnamed "}" in body nodes
+    fn goto_next_named_sibling(cursor: &mut TreeCursor) -> bool {
+        while cursor.goto_next_sibling() {
+            if cursor.node().is_named() {
+                return true;
+            }
+        }
+        false
+    }
+
+    fn goto_previous_named_sibling(cursor: &mut TreeCursor) -> bool {
+        while cursor.goto_previous_sibling() {
+            if cursor.node().is_named() {
+                return true;
+            }
+        }
+        false
+    }
+
+    fn expand_to_siblings(
+        &self,
+        cursor: &mut TreeCursor,
+        mut excerpt: EditPredictionExcerpt,
+    ) -> EditPredictionExcerpt {
+        let mut forward_cursor = cursor.clone();
+        let backward_cursor = cursor;
+        let mut forward_done = !Self::goto_next_named_sibling(&mut forward_cursor);
+        let mut backward_done = !Self::goto_previous_named_sibling(backward_cursor);
+        loop {
+            if backward_done && forward_done {
+                break;
+            }
+
+            let mut forward = None;
+            while !forward_done {
+                let new_end = node_line_end(forward_cursor.node()).to_offset(&self.buffer);
+                if new_end > excerpt.range.end {
+                    let new_excerpt = excerpt.with_expanded_range(excerpt.range.start..new_end);
+                    if new_excerpt.size <= self.options.max_bytes {
+                        forward = Some(new_excerpt);
+                        break;
+                    } else {
+                        log::debug!("halting forward expansion, as it doesn't fit");
+                        forward_done = true;
+                        break;
+                    }
+                }
+                forward_done = !Self::goto_next_named_sibling(&mut forward_cursor);
+            }
+
+            let mut backward = None;
+            while !backward_done {
+                let new_start = node_line_start(backward_cursor.node()).to_offset(&self.buffer);
+                if new_start < excerpt.range.start {
+                    let new_excerpt = excerpt.with_expanded_range(new_start..excerpt.range.end);
+                    if new_excerpt.size <= self.options.max_bytes {
+                        backward = Some(new_excerpt);
+                        break;
+                    } else {
+                        log::debug!("halting backward expansion, as it doesn't fit");
+                        backward_done = true;
+                        break;
+                    }
+                }
+                backward_done = !Self::goto_previous_named_sibling(backward_cursor);
+            }
+
+            let go_forward = match (forward, backward) {
+                (Some(forward), Some(backward)) => {
+                    let go_forward = self.is_better_excerpt(&forward, &backward);
+                    if go_forward {
+                        excerpt = forward;
+                    } else {
+                        excerpt = backward;
+                    }
+                    go_forward
+                }
+                (Some(forward), None) => {
+                    log::debug!("expanding forward, since backward expansion has halted");
+                    excerpt = forward;
+                    true
+                }
+                (None, Some(backward)) => {
+                    log::debug!("expanding backward, since forward expansion has halted");
+                    excerpt = backward;
+                    false
+                }
+                (None, None) => break,
+            };
+
+            if go_forward {
+                forward_done = !Self::goto_next_named_sibling(&mut forward_cursor);
+            } else {
+                backward_done = !Self::goto_previous_named_sibling(backward_cursor);
+            }
+        }
+
+        excerpt
+    }
+
+    fn select_lines(&self) -> Option<EditPredictionExcerpt> {
+        // early return if line containing query_offset is already too large
+        let excerpt = self.make_excerpt(self.query_range.clone());
+        if excerpt.size > self.options.max_bytes {
+            log::debug!(
+                "excerpt for cursor line is {} bytes, which exceeds the window",
+                excerpt.size
+            );
+            return None;
+        }
+        let signatures_size = excerpt.parent_signatures_size();
+        let bytes_remaining = self.options.max_bytes.saturating_sub(signatures_size);
+
+        let before_bytes =
+            (self.options.target_before_cursor_over_total_bytes * bytes_remaining as f32) as usize;
+
+        let start_point = {
+            let offset = self.query_offset.saturating_sub(before_bytes);
+            let point = offset.to_point(self.buffer);
+            Point::new(point.row + 1, 0)
+        };
+        let start_offset = start_point.to_offset(&self.buffer);
+        let end_point = {
+            let offset = start_offset + bytes_remaining;
+            let point = offset.to_point(self.buffer);
+            Point::new(point.row, 0)
+        };
+        let end_offset = end_point.to_offset(&self.buffer);
+
+        // this could be expanded further since recalculated `signature_size` may be smaller, but
+        // skipping that for now for simplicity
+        //
+        // TODO: could also consider checking if lines immediately before / after fit.
+        let excerpt = self.make_excerpt(start_offset..end_offset);
+        if excerpt.size > self.options.max_bytes {
+            log::error!(
+                "bug: line-based excerpt selection has size {}, \
+                which is {} bytes larger than the max size",
+                excerpt.size,
+                excerpt.size - self.options.max_bytes
+            );
+        }
+        return Some(excerpt);
+    }
+
+    fn make_excerpt(&self, range: Range<usize>) -> EditPredictionExcerpt {
+        let parent_signature_ranges = self
+            .outline_items
+            .iter()
+            .filter(|item| item.item_range.contains_inclusive(&range))
+            .map(|item| item.signature_range.clone())
+            .collect();
+        EditPredictionExcerpt::new(range, parent_signature_ranges)
+    }
+
+    /// Returns `true` if the `forward` excerpt is a better choice than the `backward` excerpt.
+    fn is_better_excerpt(
+        &self,
+        forward: &EditPredictionExcerpt,
+        backward: &EditPredictionExcerpt,
+    ) -> bool {
+        let forward_ratio = self.excerpt_range_ratio(forward);
+        let backward_ratio = self.excerpt_range_ratio(backward);
+        let forward_delta =
+            (forward_ratio - self.options.target_before_cursor_over_total_bytes).abs();
+        let backward_delta =
+            (backward_ratio - self.options.target_before_cursor_over_total_bytes).abs();
+        let forward_is_better = forward_delta <= backward_delta;
+        if forward_is_better {
+            log::debug!(
+                "expanding forward since {} is closer than {} to {}",
+                forward_ratio,
+                backward_ratio,
+                self.options.target_before_cursor_over_total_bytes
+            );
+        } else {
+            log::debug!(
+                "expanding backward since {} is closer than {} to {}",
+                backward_ratio,
+                forward_ratio,
+                self.options.target_before_cursor_over_total_bytes
+            );
+        }
+        forward_is_better
+    }
+
+    /// Returns the ratio of bytes before the cursor over bytes within the range.
+    fn excerpt_range_ratio(&self, excerpt: &EditPredictionExcerpt) -> f32 {
+        let Some(bytes_before_cursor) = self.query_offset.checked_sub(excerpt.range.start) else {
+            log::error!("bug: edit prediction cursor offset is not outside the excerpt");
+            return 0.0;
+        };
+        bytes_before_cursor as f32 / excerpt.range.len() as f32
+    }
+}
+
+fn node_line_start(node: Node) -> Point {
+    Point::new(node.start_position().row as u32, 0)
+}
+
+fn node_line_end(node: Node) -> Point {
+    Point::new(node.end_position().row as u32 + 1, 0)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use gpui::{AppContext, TestAppContext};
+    use language::{Buffer, Language, LanguageConfig, LanguageMatcher, tree_sitter_rust};
+    use util::test::{generate_marked_text, marked_text_offsets_by};
+
+    fn create_buffer(text: &str, cx: &mut TestAppContext) -> BufferSnapshot {
+        let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang().into(), cx));
+        buffer.read_with(cx, |buffer, _| buffer.snapshot())
+    }
+
+    fn rust_lang() -> Language {
+        Language::new(
+            LanguageConfig {
+                name: "Rust".into(),
+                matcher: LanguageMatcher {
+                    path_suffixes: vec!["rs".to_string()],
+                    ..Default::default()
+                },
+                ..Default::default()
+            },
+            Some(tree_sitter_rust::LANGUAGE.into()),
+        )
+        .with_outline_query(include_str!("../../languages/src/rust/outline.scm"))
+        .unwrap()
+    }
+
+    fn cursor_and_excerpt_range(text: &str) -> (String, usize, Range<usize>) {
+        let (text, offsets) = marked_text_offsets_by(text, vec!['ˇ', '«', '»']);
+        (text, offsets[&'ˇ'][0], offsets[&'«'][0]..offsets[&'»'][0])
+    }
+
+    fn check_example(options: EditPredictionExcerptOptions, text: &str, cx: &mut TestAppContext) {
+        let (text, cursor, expected_excerpt) = cursor_and_excerpt_range(text);
+
+        let buffer = create_buffer(&text, cx);
+        let cursor_point = cursor.to_point(&buffer);
+
+        let excerpt = EditPredictionExcerpt::select_from_buffer(cursor_point, &buffer, &options)
+            .expect("Should select an excerpt");
+        pretty_assertions::assert_eq!(
+            generate_marked_text(&text, std::slice::from_ref(&excerpt.range), false),
+            generate_marked_text(&text, &[expected_excerpt], false)
+        );
+        assert!(excerpt.size <= options.max_bytes);
+        assert!(excerpt.range.contains(&cursor));
+    }
+
+    #[gpui::test]
+    fn test_ast_based_selection_current_node(cx: &mut TestAppContext) {
+        zlog::init_test();
+        let text = r#"
+fn main() {
+    let x = 1;
+«    let ˇy = 2;
+»    let z = 3;
+}"#;
+
+        let options = EditPredictionExcerptOptions {
+            max_bytes: 20,
+            min_bytes: 10,
+            target_before_cursor_over_total_bytes: 0.5,
+            include_parent_signatures: false,
+        };
+
+        check_example(options, text, cx);
+    }
+
+    #[gpui::test]
+    fn test_ast_based_selection_parent_node(cx: &mut TestAppContext) {
+        zlog::init_test();
+        let text = r#"
+fn foo() {}
+
+«fn main() {
+    let x = 1;
+    let ˇy = 2;
+    let z = 3;
+}

+fn bar() {}"#;
+
+        let options = EditPredictionExcerptOptions {
+            max_bytes: 65,
+            min_bytes: 10,
+            target_before_cursor_over_total_bytes: 0.5,
+            include_parent_signatures: false,
+        };
+
+        check_example(options, text, cx);
+    }
+
+    #[gpui::test]
+    fn test_ast_based_selection_expands_to_siblings(cx: &mut TestAppContext) {
+        zlog::init_test();
+        let text = r#"
+fn main() {
+«    let x = 1;
+    let ˇy = 2;
+    let z = 3;
+»}"#;
+
+        let options = EditPredictionExcerptOptions {
+            max_bytes: 50,
+            min_bytes: 10,
+            target_before_cursor_over_total_bytes: 0.5,
+            include_parent_signatures: false,
+        };
+
+        check_example(options, text, cx);
+    }
+
+    #[gpui::test]
+    fn test_line_based_selection(cx: &mut TestAppContext) {
+        zlog::init_test();
+        let text = r#"
+fn main() {
+    let x = 1;
+«    if true {
+        let ˇy = 2;
+    }
+    let z = 3;
+»}"#;
+
+        let options = EditPredictionExcerptOptions {
+            max_bytes: 60,
+            min_bytes: 45,
+            target_before_cursor_over_total_bytes: 0.5,
+            include_parent_signatures: false,
+        };
+
+        check_example(options, text, cx);
+    }
+
+    #[gpui::test]
+    fn test_line_based_selection_with_before_cursor_ratio(cx: &mut TestAppContext) {
+        zlog::init_test();
+        let text = r#"
+    fn main() {
+«        let a = 1;
+        let b = 2;
+        let c = 3;
+        let ˇd = 4;
+        let e = 5;
+        let f = 6;

+        let g = 7;
+    }"#;
+
+        let options = EditPredictionExcerptOptions {
+            max_bytes: 120,
+            min_bytes: 10,
+            target_before_cursor_over_total_bytes: 0.6,
+            include_parent_signatures: false,
+        };
+
+        check_example(options, text, cx);
+    }
+}

crates/language/src/buffer.rs 🔗

@@ -3310,18 +3310,25 @@ impl BufferSnapshot {
 
     /// Iterates over every [`SyntaxLayer`] in the buffer.
     pub fn syntax_layers(&self) -> impl Iterator<Item = SyntaxLayer<'_>> + '_ {
-        self.syntax
-            .layers_for_range(0..self.len(), &self.text, true)
+        self.syntax_layers_for_range(0..self.len(), true)
     }
 
     pub fn syntax_layer_at<D: ToOffset>(&self, position: D) -> Option<SyntaxLayer<'_>> {
         let offset = position.to_offset(self);
-        self.syntax
-            .layers_for_range(offset..offset, &self.text, false)
+        self.syntax_layers_for_range(offset..offset, false)
             .filter(|l| l.node().end_byte() > offset)
             .last()
     }
 
+    pub fn syntax_layers_for_range<D: ToOffset>(
+        &self,
+        range: Range<D>,
+        include_hidden: bool,
+    ) -> impl Iterator<Item = SyntaxLayer<'_>> + '_ {
+        self.syntax
+            .layers_for_range(range, &self.text, include_hidden)
+    }
+
     pub fn smallest_syntax_layer_containing<D: ToOffset>(
         &self,
         range: Range<D>,
@@ -3859,9 +3866,12 @@ impl BufferSnapshot {
                 text: item.text,
                 highlight_ranges: item.highlight_ranges,
                 name_ranges: item.name_ranges,
-                body_range: item.body_range.map(|body_range| {
-                    self.anchor_after(body_range.start)..self.anchor_before(body_range.end)
-                }),
+                signature_range: item
+                    .signature_range
+                    .map(|r| self.anchor_after(r.start)..self.anchor_before(r.end)),
+                body_range: item
+                    .body_range
+                    .map(|r| self.anchor_after(r.start)..self.anchor_before(r.end)),
                 annotation_range: annotation_row_range.map(|annotation_range| {
                     self.anchor_after(Point::new(annotation_range.start, 0))
                         ..self.anchor_before(Point::new(
@@ -3901,38 +3911,51 @@ impl BufferSnapshot {
 
         let mut open_point = None;
         let mut close_point = None;
+
+        let mut signature_start = None;
+        let mut signature_end = None;
+        let mut extend_signature_range = |node: tree_sitter::Node| {
+            if signature_start.is_none() {
+                signature_start = Some(Point::from_ts_point(node.start_position()));
+            }
+            signature_end = Some(Point::from_ts_point(node.end_position()));
+        };
+
         let mut buffer_ranges = Vec::new();
+        let mut add_to_buffer_ranges = |node: tree_sitter::Node, node_is_name| {
+            let mut range = node.start_byte()..node.end_byte();
+            let start = node.start_position();
+            if 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));
+            }
+        };
+
         for capture in mat.captures {
-            let node_is_name;
             if capture.index == config.name_capture_ix {
-                node_is_name = true;
+                add_to_buffer_ranges(capture.node, true);
+                extend_signature_range(capture.node);
             } else if Some(capture.index) == config.context_capture_ix
                 || (Some(capture.index) == config.extra_context_capture_ix && include_extra_context)
             {
-                node_is_name = false;
+                add_to_buffer_ranges(capture.node, false);
+                extend_signature_range(capture.node);
             } 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();
@@ -3941,7 +3964,6 @@ impl BufferSnapshot {
             true,
         );
         let mut last_buffer_range_end = 0;
-
         for (buffer_range, is_name) in buffer_ranges {
             let space_added = !text.is_empty() && buffer_range.start > last_buffer_range_end;
             if space_added {
@@ -3983,12 +4005,17 @@ impl BufferSnapshot {
             last_buffer_range_end = buffer_range.end;
         }
 
+        let signature_range = signature_start
+            .zip(signature_end)
+            .map(|(start, end)| start..end);
+
         Some(OutlineItem {
             depth: 0, // We'll calculate the depth later
             range: item_point_range,
             text,
             highlight_ranges,
             name_ranges,
+            signature_range,
             body_range: open_point.zip(close_point).map(|(start, end)| start..end),
             annotation_range: None,
         })

crates/language/src/outline.rs 🔗

@@ -19,6 +19,7 @@ pub struct OutlineItem<T> {
     pub text: String,
     pub highlight_ranges: Vec<(Range<usize>, HighlightStyle)>,
     pub name_ranges: Vec<Range<usize>>,
+    pub signature_range: Option<Range<T>>,
     pub body_range: Option<Range<T>>,
     pub annotation_range: Option<Range<T>>,
 }
@@ -35,6 +36,10 @@ impl<T: ToPoint> OutlineItem<T> {
             text: self.text.clone(),
             highlight_ranges: self.highlight_ranges.clone(),
             name_ranges: self.name_ranges.clone(),
+            signature_range: self
+                .signature_range
+                .as_ref()
+                .map(|r| r.start.to_point(buffer)..r.end.to_point(buffer)),
             body_range: self
                 .body_range
                 .as_ref()
@@ -208,6 +213,7 @@ mod tests {
                 text: "class Foo".to_string(),
                 highlight_ranges: vec![],
                 name_ranges: vec![6..9],
+                signature_range: None,
                 body_range: None,
                 annotation_range: None,
             },
@@ -217,6 +223,7 @@ mod tests {
                 text: "private".to_string(),
                 highlight_ranges: vec![],
                 name_ranges: vec![],
+                signature_range: None,
                 body_range: None,
                 annotation_range: None,
             },
@@ -241,6 +248,7 @@ mod tests {
                 text: "fn process".to_string(),
                 highlight_ranges: vec![],
                 name_ranges: vec![3..10],
+                signature_range: None,
                 body_range: None,
                 annotation_range: None,
             },
@@ -250,6 +258,7 @@ mod tests {
                 text: "struct DataProcessor".to_string(),
                 highlight_ranges: vec![],
                 name_ranges: vec![7..20],
+                signature_range: None,
                 body_range: None,
                 annotation_range: None,
             },

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -6129,6 +6129,12 @@ impl MultiBufferSnapshot {
                         text: item.text,
                         highlight_ranges: item.highlight_ranges,
                         name_ranges: item.name_ranges,
+                        signature_range: item.signature_range.and_then(|signature_range| {
+                            Some(
+                                self.anchor_in_excerpt(*excerpt_id, signature_range.start)?
+                                    ..self.anchor_in_excerpt(*excerpt_id, signature_range.end)?,
+                            )
+                        }),
                         body_range: item.body_range.and_then(|body_range| {
                             Some(
                                 self.anchor_in_excerpt(*excerpt_id, body_range.start)?
@@ -6169,6 +6175,12 @@ impl MultiBufferSnapshot {
                         text: item.text,
                         highlight_ranges: item.highlight_ranges,
                         name_ranges: item.name_ranges,
+                        signature_range: item.signature_range.and_then(|signature_range| {
+                            Some(
+                                self.anchor_in_excerpt(excerpt_id, signature_range.start)?
+                                    ..self.anchor_in_excerpt(excerpt_id, signature_range.end)?,
+                            )
+                        }),
                         body_range: item.body_range.and_then(|body_range| {
                             Some(
                                 self.anchor_in_excerpt(excerpt_id, body_range.start)?

crates/outline_panel/src/outline_panel.rs 🔗

@@ -2481,6 +2481,7 @@ impl OutlinePanel {
             &OutlineItem {
                 depth,
                 annotation_range: None,
+                signature_range: None,
                 range: search_data.context_range.clone(),
                 text: search_data.context_text.clone(),
                 highlight_ranges: search_data