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