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