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