assistant: Add basic glob support for expanding items in `/docs` (#14370)

Marshall Bowers created

This PR updates the `/docs` slash command with basic globbing support
for expanding docs.

A `*` can be added to the item path to signify the end of a prefix
match.

For example:

```
# This will match any documentation items starting with `auk::`.
# In this case, it will pull in the docs for each item in the crate.
/docs docs-rs auk::*

# This will match any documentation items starting with `auk::visitor::`,
# which will pull in docs for the `visitor` module.
/docs docs-rs auk::visitor::*
```


https://github.com/user-attachments/assets/5e1e21f1-241b-483f-9cd1-facc3aa76365

Release Notes:

- N/A

Change summary

crates/assistant/src/slash_command/docs_command.rs | 59 ++++++++++-----
crates/indexed_docs/src/store.rs                   | 31 ++++++++
2 files changed, 70 insertions(+), 20 deletions(-)

Detailed changes

crates/assistant/src/slash_command/docs_command.rs 🔗

@@ -200,46 +200,65 @@ impl SlashCommand for DocsSlashCommand {
         };
 
         let args = DocsSlashCommandArgs::parse(argument);
-        let text = cx.background_executor().spawn({
+        let task = cx.background_executor().spawn({
             let store = args
                 .provider()
                 .ok_or_else(|| anyhow!("no docs provider specified"))
                 .and_then(|provider| IndexedDocsStore::try_global(provider, cx));
             async move {
-                match args {
+                let (provider, key) = match args {
                     DocsSlashCommandArgs::NoProvider => bail!("no docs provider specified"),
                     DocsSlashCommandArgs::SearchPackageDocs {
                         provider, package, ..
-                    } => {
-                        let store = store?;
-                        let item_docs = store.load(package.clone()).await?;
-
-                        anyhow::Ok((provider, package, item_docs.to_string()))
-                    }
+                    } => (provider, package),
                     DocsSlashCommandArgs::SearchItemDocs {
                         provider,
                         item_path,
                         ..
-                    } => {
-                        let store = store?;
-                        let item_docs = store.load(item_path.clone()).await?;
+                    } => (provider, item_path),
+                };
+
+                let store = store?;
+                let (text, ranges) = if let Some((prefix, _)) = key.split_once('*') {
+                    let docs = store.load_many_by_prefix(prefix.to_string()).await?;
 
-                        anyhow::Ok((provider, item_path, item_docs.to_string()))
+                    let mut text = String::new();
+                    let mut ranges = Vec::new();
+
+                    for (key, docs) in docs {
+                        let prev_len = text.len();
+
+                        text.push_str(&docs.0);
+                        text.push_str("\n");
+                        ranges.push((key, prev_len..text.len()));
+                        text.push_str("\n");
                     }
-                }
+
+                    (text, ranges)
+                } else {
+                    let item_docs = store.load(key.clone()).await?;
+                    let text = item_docs.to_string();
+                    let range = 0..text.len();
+
+                    (text, vec![(key, range)])
+                };
+
+                anyhow::Ok((provider, text, ranges))
             }
         });
 
         cx.foreground_executor().spawn(async move {
-            let (provider, path, text) = text.await?;
-            let range = 0..text.len();
+            let (provider, text, ranges) = task.await?;
             Ok(SlashCommandOutput {
                 text,
-                sections: vec![SlashCommandOutputSection {
-                    range,
-                    icon: IconName::FileDoc,
-                    label: format!("docs ({provider}): {path}",).into(),
-                }],
+                sections: ranges
+                    .into_iter()
+                    .map(|(key, range)| SlashCommandOutputSection {
+                        range,
+                        icon: IconName::FileDoc,
+                        label: format!("docs ({provider}): {key}",).into(),
+                    })
+                    .collect(),
                 run_commands_in_text: false,
             })
         })

crates/indexed_docs/src/store.rs 🔗

@@ -103,6 +103,15 @@ impl IndexedDocsStore {
             .await
     }
 
+    pub async fn load_many_by_prefix(&self, prefix: String) -> Result<Vec<(String, MarkdownDocs)>> {
+        self.database_future
+            .clone()
+            .await
+            .map_err(|err| anyhow!(err))?
+            .load_many_by_prefix(prefix)
+            .await
+    }
+
     pub fn index(
         self: Arc<Self>,
         package: PackageName,
@@ -257,6 +266,28 @@ impl IndexedDocsDatabase {
         })
     }
 
+    pub fn load_many_by_prefix(&self, prefix: String) -> Task<Result<Vec<(String, MarkdownDocs)>>> {
+        let env = self.env.clone();
+        let entries = self.entries;
+
+        self.executor.spawn(async move {
+            let txn = env.read_txn()?;
+            let results = entries
+                .iter(&txn)?
+                .filter_map(|entry| {
+                    let (key, value) = entry.ok()?;
+                    if key.starts_with(&prefix) {
+                        Some((key, value))
+                    } else {
+                        None
+                    }
+                })
+                .collect::<Vec<_>>();
+
+            Ok(results)
+        })
+    }
+
     pub fn insert(&self, key: String, docs: String) -> Task<Result<()>> {
         let env = self.env.clone();
         let entries = self.entries;