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