docs_command.rs

  1use std::path::Path;
  2use std::sync::atomic::AtomicBool;
  3use std::sync::Arc;
  4use std::time::Duration;
  5
  6use anyhow::{anyhow, bail, Result};
  7use assistant_slash_command::{
  8    ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
  9};
 10use feature_flags::FeatureFlag;
 11use gpui::{AppContext, BackgroundExecutor, Model, Task, WeakView};
 12use indexed_docs::{
 13    DocsDotRsProvider, IndexedDocsRegistry, IndexedDocsStore, LocalRustdocProvider, PackageName,
 14    ProviderId,
 15};
 16use language::LspAdapterDelegate;
 17use project::{Project, ProjectPath};
 18use ui::prelude::*;
 19use util::{maybe, ResultExt};
 20use workspace::Workspace;
 21
 22pub(crate) struct DocsSlashCommandFeatureFlag;
 23
 24impl FeatureFlag for DocsSlashCommandFeatureFlag {
 25    const NAME: &'static str = "docs-slash-command";
 26}
 27
 28pub(crate) struct DocsSlashCommand;
 29
 30impl DocsSlashCommand {
 31    pub const NAME: &'static str = "docs";
 32
 33    fn path_to_cargo_toml(project: Model<Project>, cx: &mut AppContext) -> Option<Arc<Path>> {
 34        let worktree = project.read(cx).worktrees(cx).next()?;
 35        let worktree = worktree.read(cx);
 36        let entry = worktree.entry_for_path("Cargo.toml")?;
 37        let path = ProjectPath {
 38            worktree_id: worktree.id(),
 39            path: entry.path.clone(),
 40        };
 41        Some(Arc::from(
 42            project.read(cx).absolute_path(&path, cx)?.as_path(),
 43        ))
 44    }
 45
 46    /// Ensures that the indexed doc providers for Rust are registered.
 47    ///
 48    /// Ideally we would do this sooner, but we need to wait until we're able to
 49    /// access the workspace so we can read the project.
 50    fn ensure_rust_doc_providers_are_registered(
 51        &self,
 52        workspace: Option<WeakView<Workspace>>,
 53        cx: &mut AppContext,
 54    ) {
 55        let indexed_docs_registry = IndexedDocsRegistry::global(cx);
 56        if indexed_docs_registry
 57            .get_provider_store(LocalRustdocProvider::id())
 58            .is_none()
 59        {
 60            let index_provider_deps = maybe!({
 61                let workspace = workspace.clone().ok_or_else(|| anyhow!("no workspace"))?;
 62                let workspace = workspace
 63                    .upgrade()
 64                    .ok_or_else(|| anyhow!("workspace was dropped"))?;
 65                let project = workspace.read(cx).project().clone();
 66                let fs = project.read(cx).fs().clone();
 67                let cargo_workspace_root = Self::path_to_cargo_toml(project, cx)
 68                    .and_then(|path| path.parent().map(|path| path.to_path_buf()))
 69                    .ok_or_else(|| anyhow!("no Cargo workspace root found"))?;
 70
 71                anyhow::Ok((fs, cargo_workspace_root))
 72            });
 73
 74            if let Some((fs, cargo_workspace_root)) = index_provider_deps.log_err() {
 75                // List the workspace crates once to prime the cache.
 76                LocalRustdocProvider::list_workspace_crates().ok();
 77
 78                indexed_docs_registry.register_provider(Box::new(LocalRustdocProvider::new(
 79                    fs,
 80                    cargo_workspace_root,
 81                )));
 82            }
 83        }
 84
 85        if indexed_docs_registry
 86            .get_provider_store(DocsDotRsProvider::id())
 87            .is_none()
 88        {
 89            let http_client = maybe!({
 90                let workspace = workspace.ok_or_else(|| anyhow!("no workspace"))?;
 91                let workspace = workspace
 92                    .upgrade()
 93                    .ok_or_else(|| anyhow!("workspace was dropped"))?;
 94                let project = workspace.read(cx).project().clone();
 95                anyhow::Ok(project.read(cx).client().http_client().clone())
 96            });
 97
 98            if let Some(http_client) = http_client.log_err() {
 99                indexed_docs_registry
100                    .register_provider(Box::new(DocsDotRsProvider::new(http_client)));
101            }
102        }
103    }
104
105    /// Runs just-in-time indexing for a given package, in case the slash command
106    /// is run without any entries existing in the index.
107    fn run_just_in_time_indexing(
108        store: Arc<IndexedDocsStore>,
109        key: String,
110        package: PackageName,
111        executor: BackgroundExecutor,
112    ) -> Task<()> {
113        executor.clone().spawn(async move {
114            let (prefix, needs_full_index) = if let Some((prefix, _)) = key.split_once('*') {
115                // If we have a wildcard in the search, we want to wait until
116                // we've completely finished indexing so we get a full set of
117                // results for the wildcard.
118                (prefix.to_string(), true)
119            } else {
120                (key, false)
121            };
122
123            // If we already have some entries, we assume that we've indexed the package before
124            // and don't need to do it again.
125            let has_any_entries = store
126                .any_with_prefix(prefix.clone())
127                .await
128                .unwrap_or_default();
129            if has_any_entries {
130                return ();
131            };
132
133            let index_task = store.clone().index(package.clone());
134
135            if needs_full_index {
136                _ = index_task.await;
137            } else {
138                loop {
139                    executor.timer(Duration::from_millis(200)).await;
140
141                    if store
142                        .any_with_prefix(prefix.clone())
143                        .await
144                        .unwrap_or_default()
145                        || !store.is_indexing(&package)
146                    {
147                        break;
148                    }
149                }
150            }
151        })
152    }
153}
154
155impl SlashCommand for DocsSlashCommand {
156    fn name(&self) -> String {
157        Self::NAME.into()
158    }
159
160    fn description(&self) -> String {
161        "insert docs".into()
162    }
163
164    fn menu_text(&self) -> String {
165        "Insert Documentation".into()
166    }
167
168    fn requires_argument(&self) -> bool {
169        true
170    }
171
172    fn complete_argument(
173        self: Arc<Self>,
174        query: String,
175        _cancel: Arc<AtomicBool>,
176        workspace: Option<WeakView<Workspace>>,
177        cx: &mut WindowContext,
178    ) -> Task<Result<Vec<ArgumentCompletion>>> {
179        self.ensure_rust_doc_providers_are_registered(workspace, cx);
180
181        let indexed_docs_registry = IndexedDocsRegistry::global(cx);
182        let args = DocsSlashCommandArgs::parse(&query);
183        let store = args
184            .provider()
185            .ok_or_else(|| anyhow!("no docs provider specified"))
186            .and_then(|provider| IndexedDocsStore::try_global(provider, cx));
187        cx.background_executor().spawn(async move {
188            fn build_completions(
189                provider: ProviderId,
190                items: Vec<String>,
191            ) -> Vec<ArgumentCompletion> {
192                items
193                    .into_iter()
194                    .map(|item| ArgumentCompletion {
195                        label: item.clone(),
196                        new_text: format!("{provider} {item}"),
197                        run_command: true,
198                    })
199                    .collect()
200            }
201
202            match args {
203                DocsSlashCommandArgs::NoProvider => {
204                    let providers = indexed_docs_registry.list_providers();
205                    if providers.is_empty() {
206                        return Ok(vec![ArgumentCompletion {
207                            label: "No available docs providers.".to_string(),
208                            new_text: String::new(),
209                            run_command: false,
210                        }]);
211                    }
212
213                    Ok(providers
214                        .into_iter()
215                        .map(|provider| ArgumentCompletion {
216                            label: provider.to_string(),
217                            new_text: provider.to_string(),
218                            run_command: false,
219                        })
220                        .collect())
221                }
222                DocsSlashCommandArgs::SearchPackageDocs {
223                    provider,
224                    package,
225                    index,
226                } => {
227                    let store = store?;
228
229                    if index {
230                        // We don't need to hold onto this task, as the `IndexedDocsStore` will hold it
231                        // until it completes.
232                        drop(store.clone().index(package.as_str().into()));
233                    }
234
235                    let items = store.search(package).await;
236
237                    if provider == LocalRustdocProvider::id() {
238                        let items = build_completions(provider.clone(), items);
239                        let workspace_crates = LocalRustdocProvider::list_workspace_crates()?;
240
241                        let mut all_items = items;
242                        let workspace_crate_completions = workspace_crates
243                            .into_iter()
244                            .filter(|crate_name| {
245                                !all_items
246                                    .iter()
247                                    .any(|item| item.label.as_str() == crate_name.as_ref())
248                            })
249                            .map(|crate_name| ArgumentCompletion {
250                                label: format!("{crate_name} (unindexed)"),
251                                new_text: format!("{provider} {crate_name}"),
252                                run_command: true,
253                            })
254                            .collect::<Vec<_>>();
255                        all_items.extend(workspace_crate_completions);
256                        return Ok(all_items);
257                    }
258
259                    if items.is_empty() {
260                        if provider == DocsDotRsProvider::id() {
261                            return Ok(std::iter::once(ArgumentCompletion {
262                                label: format!(
263                                    "Enter a {package_term} name or try one of these:",
264                                    package_term = package_term(&provider)
265                                ),
266                                new_text: provider.to_string(),
267                                run_command: false,
268                            })
269                            .chain(DocsDotRsProvider::AUTO_SUGGESTED_CRATES.into_iter().map(
270                                |crate_name| ArgumentCompletion {
271                                    label: crate_name.to_string(),
272                                    new_text: format!("{provider} {crate_name}"),
273                                    run_command: true,
274                                },
275                            ))
276                            .collect());
277                        }
278
279                        return Ok(vec![ArgumentCompletion {
280                            label: format!(
281                                "Enter a {package_term} name.",
282                                package_term = package_term(&provider)
283                            ),
284                            new_text: provider.to_string(),
285                            run_command: false,
286                        }]);
287                    }
288
289                    Ok(build_completions(provider, items))
290                }
291                DocsSlashCommandArgs::SearchItemDocs {
292                    provider,
293                    item_path,
294                    ..
295                } => {
296                    let store = store?;
297                    let items = store.search(item_path).await;
298                    Ok(build_completions(provider, items))
299                }
300            }
301        })
302    }
303
304    fn run(
305        self: Arc<Self>,
306        argument: Option<&str>,
307        _workspace: WeakView<Workspace>,
308        _delegate: Option<Arc<dyn LspAdapterDelegate>>,
309        cx: &mut WindowContext,
310    ) -> Task<Result<SlashCommandOutput>> {
311        let Some(argument) = argument else {
312            return Task::ready(Err(anyhow!("missing argument")));
313        };
314
315        let args = DocsSlashCommandArgs::parse(argument);
316        let executor = cx.background_executor().clone();
317        let task = cx.background_executor().spawn({
318            let store = args
319                .provider()
320                .ok_or_else(|| anyhow!("no docs provider specified"))
321                .and_then(|provider| IndexedDocsStore::try_global(provider, cx));
322            async move {
323                let (provider, key) = match args.clone() {
324                    DocsSlashCommandArgs::NoProvider => bail!("no docs provider specified"),
325                    DocsSlashCommandArgs::SearchPackageDocs {
326                        provider, package, ..
327                    } => (provider, package),
328                    DocsSlashCommandArgs::SearchItemDocs {
329                        provider,
330                        item_path,
331                        ..
332                    } => (provider, item_path),
333                };
334
335                if key.trim().is_empty() {
336                    bail!(
337                        "no {package_term} name provided",
338                        package_term = package_term(&provider)
339                    );
340                }
341
342                let store = store?;
343
344                if let Some(package) = args.package() {
345                    Self::run_just_in_time_indexing(store.clone(), key.clone(), package, executor)
346                        .await;
347                }
348
349                let (text, ranges) = if let Some((prefix, _)) = key.split_once('*') {
350                    let docs = store.load_many_by_prefix(prefix.to_string()).await?;
351
352                    let mut text = String::new();
353                    let mut ranges = Vec::new();
354
355                    for (key, docs) in docs {
356                        let prev_len = text.len();
357
358                        text.push_str(&docs.0);
359                        text.push_str("\n");
360                        ranges.push((key, prev_len..text.len()));
361                        text.push_str("\n");
362                    }
363
364                    (text, ranges)
365                } else {
366                    let item_docs = store.load(key.clone()).await?;
367                    let text = item_docs.to_string();
368                    let range = 0..text.len();
369
370                    (text, vec![(key, range)])
371                };
372
373                anyhow::Ok((provider, text, ranges))
374            }
375        });
376
377        cx.foreground_executor().spawn(async move {
378            let (provider, text, ranges) = task.await?;
379            Ok(SlashCommandOutput {
380                text,
381                sections: ranges
382                    .into_iter()
383                    .map(|(key, range)| SlashCommandOutputSection {
384                        range,
385                        icon: IconName::FileDoc,
386                        label: format!("docs ({provider}): {key}",).into(),
387                    })
388                    .collect(),
389                run_commands_in_text: false,
390            })
391        })
392    }
393}
394
395fn is_item_path_delimiter(char: char) -> bool {
396    !char.is_alphanumeric() && char != '-' && char != '_'
397}
398
399#[derive(Debug, PartialEq, Clone)]
400pub(crate) enum DocsSlashCommandArgs {
401    NoProvider,
402    SearchPackageDocs {
403        provider: ProviderId,
404        package: String,
405        index: bool,
406    },
407    SearchItemDocs {
408        provider: ProviderId,
409        package: String,
410        item_path: String,
411    },
412}
413
414impl DocsSlashCommandArgs {
415    pub fn parse(argument: &str) -> Self {
416        let Some((provider, argument)) = argument.split_once(' ') else {
417            return Self::NoProvider;
418        };
419
420        let provider = ProviderId(provider.into());
421
422        if let Some((package, rest)) = argument.split_once(is_item_path_delimiter) {
423            if rest.trim().is_empty() {
424                Self::SearchPackageDocs {
425                    provider,
426                    package: package.to_owned(),
427                    index: true,
428                }
429            } else {
430                Self::SearchItemDocs {
431                    provider,
432                    package: package.to_owned(),
433                    item_path: argument.to_owned(),
434                }
435            }
436        } else {
437            Self::SearchPackageDocs {
438                provider,
439                package: argument.to_owned(),
440                index: false,
441            }
442        }
443    }
444
445    pub fn provider(&self) -> Option<ProviderId> {
446        match self {
447            Self::NoProvider => None,
448            Self::SearchPackageDocs { provider, .. } | Self::SearchItemDocs { provider, .. } => {
449                Some(provider.clone())
450            }
451        }
452    }
453
454    pub fn package(&self) -> Option<PackageName> {
455        match self {
456            Self::NoProvider => None,
457            Self::SearchPackageDocs { package, .. } | Self::SearchItemDocs { package, .. } => {
458                Some(package.as_str().into())
459            }
460        }
461    }
462}
463
464/// Returns the term used to refer to a package.
465fn package_term(provider: &ProviderId) -> &'static str {
466    if provider == &DocsDotRsProvider::id() || provider == &LocalRustdocProvider::id() {
467        return "crate";
468    }
469
470    "package"
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn test_parse_docs_slash_command_args() {
479        assert_eq!(
480            DocsSlashCommandArgs::parse(""),
481            DocsSlashCommandArgs::NoProvider
482        );
483        assert_eq!(
484            DocsSlashCommandArgs::parse("rustdoc"),
485            DocsSlashCommandArgs::NoProvider
486        );
487
488        assert_eq!(
489            DocsSlashCommandArgs::parse("rustdoc "),
490            DocsSlashCommandArgs::SearchPackageDocs {
491                provider: ProviderId("rustdoc".into()),
492                package: "".into(),
493                index: false
494            }
495        );
496        assert_eq!(
497            DocsSlashCommandArgs::parse("gleam "),
498            DocsSlashCommandArgs::SearchPackageDocs {
499                provider: ProviderId("gleam".into()),
500                package: "".into(),
501                index: false
502            }
503        );
504
505        assert_eq!(
506            DocsSlashCommandArgs::parse("rustdoc gpui"),
507            DocsSlashCommandArgs::SearchPackageDocs {
508                provider: ProviderId("rustdoc".into()),
509                package: "gpui".into(),
510                index: false,
511            }
512        );
513        assert_eq!(
514            DocsSlashCommandArgs::parse("gleam gleam_stdlib"),
515            DocsSlashCommandArgs::SearchPackageDocs {
516                provider: ProviderId("gleam".into()),
517                package: "gleam_stdlib".into(),
518                index: false
519            }
520        );
521
522        // Adding an item path delimiter indicates we can start indexing.
523        assert_eq!(
524            DocsSlashCommandArgs::parse("rustdoc gpui:"),
525            DocsSlashCommandArgs::SearchPackageDocs {
526                provider: ProviderId("rustdoc".into()),
527                package: "gpui".into(),
528                index: true,
529            }
530        );
531        assert_eq!(
532            DocsSlashCommandArgs::parse("gleam gleam_stdlib/"),
533            DocsSlashCommandArgs::SearchPackageDocs {
534                provider: ProviderId("gleam".into()),
535                package: "gleam_stdlib".into(),
536                index: true
537            }
538        );
539
540        assert_eq!(
541            DocsSlashCommandArgs::parse("rustdoc gpui::foo::bar::Baz"),
542            DocsSlashCommandArgs::SearchItemDocs {
543                provider: ProviderId("rustdoc".into()),
544                package: "gpui".into(),
545                item_path: "gpui::foo::bar::Baz".into()
546            }
547        );
548        assert_eq!(
549            DocsSlashCommandArgs::parse("gleam gleam_stdlib/gleam/int"),
550            DocsSlashCommandArgs::SearchItemDocs {
551                provider: ProviderId("gleam".into()),
552                package: "gleam_stdlib".into(),
553                item_path: "gleam_stdlib/gleam/int".into()
554            }
555        );
556    }
557}