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