Allow multiple disjoint nodes to be captured as matcheable in the outline query

Max Brunsfeld created

Change summary

Cargo.lock                            |   1 
crates/editor/src/multi_buffer.rs     |   3 
crates/language/src/buffer.rs         |  60 +++++-------
crates/language/src/outline.rs        |  37 +++++--
crates/language/src/tests.rs          | 131 +++++++++++++++++++++++++++-
crates/outline/Cargo.toml             |   1 
crates/outline/src/outline.rs         |   4 
crates/zed/languages/rust/outline.scm |   2 
8 files changed, 181 insertions(+), 58 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -3132,6 +3132,7 @@ dependencies = [
  "language",
  "ordered-float",
  "postage",
+ "smol",
  "text",
  "workspace",
 ]

crates/editor/src/multi_buffer.rs 🔗

@@ -1707,12 +1707,11 @@ impl MultiBufferSnapshot {
                 .items
                 .into_iter()
                 .map(|item| OutlineItem {
-                    id: item.id,
                     depth: item.depth,
                     range: self.anchor_in_excerpt(excerpt_id.clone(), item.range.start)
                         ..self.anchor_in_excerpt(excerpt_id.clone(), item.range.end),
                     text: item.text,
-                    name_range_in_text: item.name_range_in_text,
+                    name_ranges: item.name_ranges,
                 })
                 .collect(),
         ))

crates/language/src/buffer.rs 🔗

@@ -1850,52 +1850,45 @@ impl BufferSnapshot {
         );
 
         let item_capture_ix = grammar.outline_query.capture_index_for_name("item")?;
-        let context_capture_ix = grammar.outline_query.capture_index_for_name("context")?;
         let name_capture_ix = grammar.outline_query.capture_index_for_name("name")?;
+        let context_capture_ix = grammar
+            .outline_query
+            .capture_index_for_name("context")
+            .unwrap_or(u32::MAX);
 
-        let mut stack: Vec<Range<usize>> = Default::default();
-        let mut id = 0;
+        let mut stack = Vec::<Range<usize>>::new();
         let items = matches
             .filter_map(|mat| {
                 let item_node = mat.nodes_for_capture_index(item_capture_ix).next()?;
-                let mut name_node = Some(mat.nodes_for_capture_index(name_capture_ix).next()?);
-                let mut context_nodes = mat.nodes_for_capture_index(context_capture_ix).peekable();
-
-                let id = post_inc(&mut id);
                 let range = item_node.start_byte()..item_node.end_byte();
-
                 let mut text = String::new();
-                let mut name_range_in_text = 0..0;
-                loop {
-                    let node;
+                let mut name_ranges = Vec::new();
+
+                for capture in mat.captures {
                     let node_is_name;
-                    match (context_nodes.peek(), name_node.as_ref()) {
-                        (None, None) => break,
-                        (None, Some(_)) => {
-                            node = name_node.take().unwrap();
-                            node_is_name = true;
-                        }
-                        (Some(_), None) => {
-                            node = context_nodes.next().unwrap();
-                            node_is_name = false;
-                        }
-                        (Some(context_node), Some(name)) => {
-                            if context_node.start_byte() < name.start_byte() {
-                                node = context_nodes.next().unwrap();
-                                node_is_name = false;
-                            } else {
-                                node = name_node.take().unwrap();
-                                node_is_name = true;
-                            }
-                        }
+                    if capture.index == name_capture_ix {
+                        node_is_name = true;
+                    } else if capture.index == context_capture_ix {
+                        node_is_name = false;
+                    } else {
+                        continue;
                     }
 
+                    let range = capture.node.start_byte()..capture.node.end_byte();
                     if !text.is_empty() {
                         text.push(' ');
                     }
-                    let range = node.start_byte()..node.end_byte();
                     if node_is_name {
-                        name_range_in_text = text.len()..(text.len() + range.len())
+                        let mut start = text.len() as u32;
+                        let end = start + range.len() as u32;
+
+                        // 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);
                     }
                     text.extend(self.text_for_range(range));
                 }
@@ -1908,11 +1901,10 @@ impl BufferSnapshot {
                 stack.push(range.clone());
 
                 Some(OutlineItem {
-                    id,
                     depth: stack.len() - 1,
                     range: self.anchor_after(range.start)..self.anchor_before(range.end),
                     text,
-                    name_range_in_text,
+                    name_ranges: name_ranges.into_boxed_slice(),
                 })
             })
             .collect::<Vec<_>>();

crates/language/src/outline.rs 🔗

@@ -1,7 +1,6 @@
-use std::ops::Range;
-
 use fuzzy::{StringMatch, StringMatchCandidate};
-use gpui::AppContext;
+use gpui::executor::Background;
+use std::{ops::Range, sync::Arc};
 
 #[derive(Debug)]
 pub struct Outline<T> {
@@ -11,11 +10,10 @@ pub struct Outline<T> {
 
 #[derive(Clone, Debug)]
 pub struct OutlineItem<T> {
-    pub id: usize,
     pub depth: usize,
     pub range: Range<T>,
     pub text: String,
-    pub name_range_in_text: Range<usize>,
+    pub name_ranges: Box<[Range<u32>]>,
 }
 
 impl<T> Outline<T> {
@@ -24,10 +22,14 @@ impl<T> Outline<T> {
             candidates: items
                 .iter()
                 .map(|item| {
-                    let text = &item.text[item.name_range_in_text.clone()];
+                    let text = item
+                        .name_ranges
+                        .iter()
+                        .map(|range| &item.text[range.start as usize..range.end as usize])
+                        .collect::<String>();
                     StringMatchCandidate {
-                        string: text.to_string(),
-                        char_bag: text.into(),
+                        char_bag: text.as_str().into(),
+                        string: text,
                     }
                 })
                 .collect(),
@@ -35,15 +37,16 @@ impl<T> Outline<T> {
         }
     }
 
-    pub fn search(&self, query: &str, cx: &AppContext) -> Vec<StringMatch> {
-        let mut matches = smol::block_on(fuzzy::match_strings(
+    pub async fn search(&self, query: &str, executor: Arc<Background>) -> Vec<StringMatch> {
+        let mut matches = fuzzy::match_strings(
             &self.candidates,
             query,
             true,
             100,
             &Default::default(),
-            cx.background().clone(),
-        ));
+            executor,
+        )
+        .await;
         matches.sort_unstable_by_key(|m| m.candidate_index);
 
         let mut tree_matches = Vec::new();
@@ -51,8 +54,16 @@ impl<T> Outline<T> {
         let mut prev_item_ix = 0;
         for mut string_match in matches {
             let outline_match = &self.items[string_match.candidate_index];
+
+            let mut name_ranges = outline_match.name_ranges.iter();
+            let mut name_range = name_ranges.next().unwrap();
+            let mut preceding_ranges_len = 0;
             for position in &mut string_match.positions {
-                *position += outline_match.name_range_in_text.start;
+                while *position >= preceding_ranges_len + name_range.len() as usize {
+                    preceding_ranges_len += name_range.len();
+                    name_range = name_ranges.next().unwrap();
+                }
+                *position = name_range.start as usize + (*position - preceding_ranges_len);
             }
 
             let mut cur_depth = outline_match.depth;

crates/language/src/tests.rs 🔗

@@ -278,6 +278,121 @@ async fn test_reparse(mut cx: gpui::TestAppContext) {
     }
 }
 
+#[gpui::test]
+async fn test_outline(mut cx: gpui::TestAppContext) {
+    let language = Some(Arc::new(
+        rust_lang()
+            .with_outline_query(
+                r#"
+                (struct_item
+                    "struct" @context
+                    name: (_) @name) @item
+                (enum_item
+                    "enum" @context
+                    name: (_) @name) @item
+                (enum_variant
+                    name: (_) @name) @item
+                (field_declaration
+                    name: (_) @name) @item
+                (impl_item
+                    "impl" @context
+                    trait: (_) @name
+                    "for" @context
+                    type: (_) @name) @item
+                (function_item
+                    "fn" @context
+                    name: (_) @name) @item
+                "#,
+            )
+            .unwrap(),
+    ));
+
+    let text = r#"
+        struct Person {
+            name: String,
+            age: usize,
+        }
+
+        enum LoginState {
+            LoggedOut,
+            LoggingOn,
+            LoggedIn {
+                person: Person,
+                time: Instant,
+            }
+        }
+
+        impl Drop for Person {
+            fn drop(&mut self) {
+                println!("bye");
+            }
+        }
+    "#
+    .unindent();
+
+    let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(language, None, cx));
+    let outline = buffer
+        .read_with(&cx, |buffer, _| buffer.snapshot().outline())
+        .unwrap();
+
+    assert_eq!(
+        outline
+            .items
+            .iter()
+            .map(|item| (item.text.as_str(), item.name_ranges.as_ref(), item.depth))
+            .collect::<Vec<_>>(),
+        &[
+            ("struct Person", [7..13].as_slice(), 0),
+            ("name", &[0..4], 1),
+            ("age", &[0..3], 1),
+            ("enum LoginState", &[5..15], 0),
+            ("LoggedOut", &[0..9], 1),
+            ("LoggingOn", &[0..9], 1),
+            ("LoggedIn", &[0..8], 1),
+            ("person", &[0..6], 2),
+            ("time", &[0..4], 2),
+            ("impl Drop for Person", &[5..9, 13..20], 0),
+            ("fn drop", &[3..7], 1),
+        ]
+    );
+
+    assert_eq!(
+        search(&outline, "oon", &cx).await,
+        &[
+            ("enum LoginState", vec![]),  // included as the parent of a match
+            ("LoggingOn", vec![1, 7, 8]), // matches
+            ("impl Drop for Person", vec![7, 18, 19]), // matches in two disjoint names
+        ]
+    );
+    assert_eq!(
+        search(&outline, "dp p", &cx).await,
+        &[("impl Drop for Person", vec![5, 8, 13, 14])]
+    );
+    assert_eq!(
+        search(&outline, "dpn", &cx).await,
+        &[("impl Drop for Person", vec![5, 8, 19])]
+    );
+
+    async fn search<'a>(
+        outline: &'a Outline<Anchor>,
+        query: &str,
+        cx: &gpui::TestAppContext,
+    ) -> Vec<(&'a str, Vec<usize>)> {
+        let matches = cx
+            .read(|cx| outline.search(query, cx.background().clone()))
+            .await;
+        matches
+            .into_iter()
+            .map(|mat| {
+                (
+                    outline.items[mat.candidate_index].text.as_str(),
+                    mat.positions,
+                )
+            })
+            .collect::<Vec<_>>()
+    }
+}
+
 #[gpui::test]
 fn test_enclosing_bracket_ranges(cx: &mut MutableAppContext) {
     let buffer = cx.add_model(|cx| {
@@ -1017,14 +1132,18 @@ fn rust_lang() -> Language {
     )
     .with_indents_query(
         r#"
-                (call_expression) @indent
-                (field_expression) @indent
-                (_ "(" ")" @end) @indent
-                (_ "{" "}" @end) @indent
-            "#,
+        (call_expression) @indent
+        (field_expression) @indent
+        (_ "(" ")" @end) @indent
+        (_ "{" "}" @end) @indent
+        "#,
     )
     .unwrap()
-    .with_brackets_query(r#" ("{" @open "}" @close) "#)
+    .with_brackets_query(
+        r#"
+        ("{" @open "}" @close)
+        "#,
+    )
     .unwrap()
 }
 

crates/outline/Cargo.toml 🔗

@@ -15,3 +15,4 @@ text = { path = "../text" }
 workspace = { path = "../workspace" }
 ordered-float = "2.1.1"
 postage = { version = "0.4", features = ["futures-traits"] }
+smol = "1.2"

crates/outline/src/outline.rs 🔗

@@ -301,7 +301,7 @@ impl OutlineView {
                 .0;
             navigate_to_selected_index = false;
         } else {
-            self.matches = self.outline.search(&query, cx);
+            self.matches = smol::block_on(self.outline.search(&query, cx.background().clone()));
             selected_index = self
                 .matches
                 .iter()
@@ -309,7 +309,7 @@ impl OutlineView {
                 .max_by_key(|(_, m)| OrderedFloat(m.score))
                 .map(|(ix, _)| ix)
                 .unwrap_or(0);
-            navigate_to_selected_index = true;
+            navigate_to_selected_index = !self.matches.is_empty();
         }
         self.select(selected_index, navigate_to_selected_index, cx);
     }