docs_command.rs

  1use std::path::Path;
  2use std::sync::atomic::AtomicBool;
  3use std::sync::Arc;
  4
  5use anyhow::{anyhow, bail, Result};
  6use assistant_slash_command::{SlashCommand, SlashCommandOutput, SlashCommandOutputSection};
  7use gpui::{AppContext, Model, Task, WeakView};
  8use indexed_docs::{
  9    IndexedDocsRegistry, IndexedDocsStore, LocalProvider, PackageName, ProviderId, RustdocIndexer,
 10};
 11use language::LspAdapterDelegate;
 12use project::{Project, ProjectPath};
 13use ui::prelude::*;
 14use util::{maybe, ResultExt};
 15use workspace::Workspace;
 16
 17pub(crate) struct DocsSlashCommand;
 18
 19impl DocsSlashCommand {
 20    pub const NAME: &'static str = "docs";
 21
 22    fn path_to_cargo_toml(project: Model<Project>, cx: &mut AppContext) -> Option<Arc<Path>> {
 23        let worktree = project.read(cx).worktrees().next()?;
 24        let worktree = worktree.read(cx);
 25        let entry = worktree.entry_for_path("Cargo.toml")?;
 26        let path = ProjectPath {
 27            worktree_id: worktree.id(),
 28            path: entry.path.clone(),
 29        };
 30        Some(Arc::from(
 31            project.read(cx).absolute_path(&path, cx)?.as_path(),
 32        ))
 33    }
 34
 35    /// Ensures that the rustdoc provider is registered.
 36    ///
 37    /// Ideally we would do this sooner, but we need to wait until we're able to
 38    /// access the workspace so we can read the project.
 39    fn ensure_rustdoc_provider_is_registered(
 40        &self,
 41        workspace: Option<WeakView<Workspace>>,
 42        cx: &mut AppContext,
 43    ) {
 44        let indexed_docs_registry = IndexedDocsRegistry::global(cx);
 45        if indexed_docs_registry
 46            .get_provider_store(ProviderId::rustdoc())
 47            .is_none()
 48        {
 49            let index_provider_deps = maybe!({
 50                let workspace = workspace.ok_or_else(|| anyhow!("no workspace"))?;
 51                let workspace = workspace
 52                    .upgrade()
 53                    .ok_or_else(|| anyhow!("workspace was dropped"))?;
 54                let project = workspace.read(cx).project().clone();
 55                let fs = project.read(cx).fs().clone();
 56                let cargo_workspace_root = Self::path_to_cargo_toml(project, cx)
 57                    .and_then(|path| path.parent().map(|path| path.to_path_buf()))
 58                    .ok_or_else(|| anyhow!("no Cargo workspace root found"))?;
 59
 60                anyhow::Ok((fs, cargo_workspace_root))
 61            });
 62
 63            if let Some((fs, cargo_workspace_root)) = index_provider_deps.log_err() {
 64                indexed_docs_registry.register_provider(Box::new(RustdocIndexer::new(Box::new(
 65                    LocalProvider::new(fs, cargo_workspace_root),
 66                ))));
 67            }
 68        }
 69    }
 70}
 71
 72impl SlashCommand for DocsSlashCommand {
 73    fn name(&self) -> String {
 74        Self::NAME.into()
 75    }
 76
 77    fn description(&self) -> String {
 78        "insert docs".into()
 79    }
 80
 81    fn menu_text(&self) -> String {
 82        "Insert Documentation".into()
 83    }
 84
 85    fn requires_argument(&self) -> bool {
 86        true
 87    }
 88
 89    fn complete_argument(
 90        self: Arc<Self>,
 91        query: String,
 92        _cancel: Arc<AtomicBool>,
 93        workspace: Option<WeakView<Workspace>>,
 94        cx: &mut AppContext,
 95    ) -> Task<Result<Vec<String>>> {
 96        self.ensure_rustdoc_provider_is_registered(workspace, cx);
 97
 98        let indexed_docs_registry = IndexedDocsRegistry::global(cx);
 99        let args = DocsSlashCommandArgs::parse(&query);
100        let store = args
101            .provider()
102            .ok_or_else(|| anyhow!("no docs provider specified"))
103            .and_then(|provider| IndexedDocsStore::try_global(provider, cx));
104        cx.background_executor().spawn(async move {
105            /// HACK: Prefixes the completions with the provider ID so that it doesn't get deleted
106            /// when a completion is accepted.
107            ///
108            /// We will likely want to extend `complete_argument` with support for replacing just
109            /// a particular range of the argument when a completion is accepted.
110            fn prefix_with_provider(provider: ProviderId, items: Vec<String>) -> Vec<String> {
111                items
112                    .into_iter()
113                    .map(|item| format!("{provider} {item}"))
114                    .collect()
115            }
116
117            match args {
118                DocsSlashCommandArgs::NoProvider => {
119                    let providers = indexed_docs_registry.list_providers();
120                    Ok(providers
121                        .into_iter()
122                        .map(|provider| provider.to_string())
123                        .collect())
124                }
125                DocsSlashCommandArgs::SearchPackageDocs {
126                    provider,
127                    package,
128                    index,
129                } => {
130                    let store = store?;
131
132                    if index {
133                        // We don't need to hold onto this task, as the `IndexedDocsStore` will hold it
134                        // until it completes.
135                        let _ = store.clone().index(package.as_str().into());
136                    }
137
138                    let items = store.search(package).await;
139                    Ok(prefix_with_provider(provider, items))
140                }
141                DocsSlashCommandArgs::SearchItemDocs {
142                    provider,
143                    item_path,
144                    ..
145                } => {
146                    let store = store?;
147                    let items = store.search(item_path).await;
148                    Ok(prefix_with_provider(provider, items))
149                }
150            }
151        })
152    }
153
154    fn run(
155        self: Arc<Self>,
156        argument: Option<&str>,
157        _workspace: WeakView<Workspace>,
158        _delegate: Arc<dyn LspAdapterDelegate>,
159        cx: &mut WindowContext,
160    ) -> Task<Result<SlashCommandOutput>> {
161        let Some(argument) = argument else {
162            return Task::ready(Err(anyhow!("missing argument")));
163        };
164
165        let args = DocsSlashCommandArgs::parse(argument);
166        let text = cx.background_executor().spawn({
167            let store = args
168                .provider()
169                .ok_or_else(|| anyhow!("no docs provider specified"))
170                .and_then(|provider| IndexedDocsStore::try_global(provider, cx));
171            async move {
172                match args {
173                    DocsSlashCommandArgs::NoProvider => bail!("no docs provider specified"),
174                    DocsSlashCommandArgs::SearchPackageDocs {
175                        provider, package, ..
176                    } => {
177                        let store = store?;
178                        let item_docs = store.load(package.clone()).await?;
179
180                        anyhow::Ok((provider, package, item_docs.to_string()))
181                    }
182                    DocsSlashCommandArgs::SearchItemDocs {
183                        provider,
184                        item_path,
185                        ..
186                    } => {
187                        let store = store?;
188                        let item_docs = store.load(item_path.clone()).await?;
189
190                        anyhow::Ok((provider, item_path, item_docs.to_string()))
191                    }
192                }
193            }
194        });
195
196        cx.foreground_executor().spawn(async move {
197            let (provider, path, text) = text.await?;
198            let range = 0..text.len();
199            Ok(SlashCommandOutput {
200                text,
201                sections: vec![SlashCommandOutputSection {
202                    range,
203                    icon: IconName::FileRust,
204                    label: format!("docs ({provider}): {path}",).into(),
205                }],
206                run_commands_in_text: false,
207            })
208        })
209    }
210}
211
212fn is_item_path_delimiter(char: char) -> bool {
213    !char.is_alphanumeric() && char != '-' && char != '_'
214}
215
216#[derive(Debug, PartialEq)]
217pub(crate) enum DocsSlashCommandArgs {
218    NoProvider,
219    SearchPackageDocs {
220        provider: ProviderId,
221        package: String,
222        index: bool,
223    },
224    SearchItemDocs {
225        provider: ProviderId,
226        package: String,
227        item_path: String,
228    },
229}
230
231impl DocsSlashCommandArgs {
232    pub fn parse(argument: &str) -> Self {
233        let Some((provider, argument)) = argument.split_once(' ') else {
234            return Self::NoProvider;
235        };
236
237        let provider = ProviderId(provider.into());
238
239        if let Some((package, rest)) = argument.split_once(is_item_path_delimiter) {
240            if rest.trim().is_empty() {
241                Self::SearchPackageDocs {
242                    provider,
243                    package: package.to_owned(),
244                    index: true,
245                }
246            } else {
247                Self::SearchItemDocs {
248                    provider,
249                    package: package.to_owned(),
250                    item_path: argument.to_owned(),
251                }
252            }
253        } else {
254            Self::SearchPackageDocs {
255                provider,
256                package: argument.to_owned(),
257                index: false,
258            }
259        }
260    }
261
262    pub fn provider(&self) -> Option<ProviderId> {
263        match self {
264            Self::NoProvider => None,
265            Self::SearchPackageDocs { provider, .. } | Self::SearchItemDocs { provider, .. } => {
266                Some(provider.clone())
267            }
268        }
269    }
270
271    pub fn package(&self) -> Option<PackageName> {
272        match self {
273            Self::NoProvider => None,
274            Self::SearchPackageDocs { package, .. } | Self::SearchItemDocs { package, .. } => {
275                Some(package.as_str().into())
276            }
277        }
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_parse_docs_slash_command_args() {
287        assert_eq!(
288            DocsSlashCommandArgs::parse(""),
289            DocsSlashCommandArgs::NoProvider
290        );
291        assert_eq!(
292            DocsSlashCommandArgs::parse("rustdoc"),
293            DocsSlashCommandArgs::NoProvider
294        );
295
296        assert_eq!(
297            DocsSlashCommandArgs::parse("rustdoc "),
298            DocsSlashCommandArgs::SearchPackageDocs {
299                provider: ProviderId("rustdoc".into()),
300                package: "".into(),
301                index: false
302            }
303        );
304        assert_eq!(
305            DocsSlashCommandArgs::parse("gleam "),
306            DocsSlashCommandArgs::SearchPackageDocs {
307                provider: ProviderId("gleam".into()),
308                package: "".into(),
309                index: false
310            }
311        );
312
313        assert_eq!(
314            DocsSlashCommandArgs::parse("rustdoc gpui"),
315            DocsSlashCommandArgs::SearchPackageDocs {
316                provider: ProviderId("rustdoc".into()),
317                package: "gpui".into(),
318                index: false,
319            }
320        );
321        assert_eq!(
322            DocsSlashCommandArgs::parse("gleam gleam_stdlib"),
323            DocsSlashCommandArgs::SearchPackageDocs {
324                provider: ProviderId("gleam".into()),
325                package: "gleam_stdlib".into(),
326                index: false
327            }
328        );
329
330        // Adding an item path delimiter indicates we can start indexing.
331        assert_eq!(
332            DocsSlashCommandArgs::parse("rustdoc gpui:"),
333            DocsSlashCommandArgs::SearchPackageDocs {
334                provider: ProviderId("rustdoc".into()),
335                package: "gpui".into(),
336                index: true,
337            }
338        );
339        assert_eq!(
340            DocsSlashCommandArgs::parse("gleam gleam_stdlib/"),
341            DocsSlashCommandArgs::SearchPackageDocs {
342                provider: ProviderId("gleam".into()),
343                package: "gleam_stdlib".into(),
344                index: true
345            }
346        );
347
348        assert_eq!(
349            DocsSlashCommandArgs::parse("rustdoc gpui::foo::bar::Baz"),
350            DocsSlashCommandArgs::SearchItemDocs {
351                provider: ProviderId("rustdoc".into()),
352                package: "gpui".into(),
353                item_path: "gpui::foo::bar::Baz".into()
354            }
355        );
356        assert_eq!(
357            DocsSlashCommandArgs::parse("gleam gleam_stdlib/gleam/int"),
358            DocsSlashCommandArgs::SearchItemDocs {
359                provider: ProviderId("gleam".into()),
360                package: "gleam_stdlib".into(),
361                item_path: "gleam_stdlib/gleam/int".into()
362            }
363        );
364    }
365}