docs_command.rs

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