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 items = store.search(package).await;
233                    if items.is_empty() {
234                        if provider == DocsDotRsProvider::id() {
235                            return Ok(std::iter::once(ArgumentCompletion {
236                                label: format!(
237                                    "Enter a {package_term} name or try one of these:",
238                                    package_term = package_term(&provider)
239                                ),
240                                new_text: provider.to_string(),
241                                run_command: false,
242                            })
243                            .chain(DocsDotRsProvider::AUTO_SUGGESTED_CRATES.into_iter().map(
244                                |crate_name| ArgumentCompletion {
245                                    label: crate_name.to_string(),
246                                    new_text: format!("{provider} {crate_name}"),
247                                    run_command: true,
248                                },
249                            ))
250                            .collect());
251                        }
252
253                        return Ok(vec![ArgumentCompletion {
254                            label: format!(
255                                "Enter a {package_term} name.",
256                                package_term = package_term(&provider)
257                            ),
258                            new_text: provider.to_string(),
259                            run_command: false,
260                        }]);
261                    }
262
263                    Ok(build_completions(provider, items))
264                }
265                DocsSlashCommandArgs::SearchItemDocs {
266                    provider,
267                    item_path,
268                    ..
269                } => {
270                    let store = store?;
271                    let items = store.search(item_path).await;
272                    Ok(build_completions(provider, items))
273                }
274            }
275        })
276    }
277
278    fn run(
279        self: Arc<Self>,
280        argument: Option<&str>,
281        _workspace: WeakView<Workspace>,
282        _delegate: Option<Arc<dyn LspAdapterDelegate>>,
283        cx: &mut WindowContext,
284    ) -> Task<Result<SlashCommandOutput>> {
285        let Some(argument) = argument else {
286            return Task::ready(Err(anyhow!("missing argument")));
287        };
288
289        let args = DocsSlashCommandArgs::parse(argument);
290        let executor = cx.background_executor().clone();
291        let task = cx.background_executor().spawn({
292            let store = args
293                .provider()
294                .ok_or_else(|| anyhow!("no docs provider specified"))
295                .and_then(|provider| IndexedDocsStore::try_global(provider, cx));
296            async move {
297                let (provider, key) = match args.clone() {
298                    DocsSlashCommandArgs::NoProvider => bail!("no docs provider specified"),
299                    DocsSlashCommandArgs::SearchPackageDocs {
300                        provider, package, ..
301                    } => (provider, package),
302                    DocsSlashCommandArgs::SearchItemDocs {
303                        provider,
304                        item_path,
305                        ..
306                    } => (provider, item_path),
307                };
308
309                if key.trim().is_empty() {
310                    bail!(
311                        "no {package_term} name provided",
312                        package_term = package_term(&provider)
313                    );
314                }
315
316                let store = store?;
317
318                if let Some(package) = args.package() {
319                    Self::run_just_in_time_indexing(store.clone(), key.clone(), package, executor)
320                        .await;
321                }
322
323                let (text, ranges) = if let Some((prefix, _)) = key.split_once('*') {
324                    let docs = store.load_many_by_prefix(prefix.to_string()).await?;
325
326                    let mut text = String::new();
327                    let mut ranges = Vec::new();
328
329                    for (key, docs) in docs {
330                        let prev_len = text.len();
331
332                        text.push_str(&docs.0);
333                        text.push_str("\n");
334                        ranges.push((key, prev_len..text.len()));
335                        text.push_str("\n");
336                    }
337
338                    (text, ranges)
339                } else {
340                    let item_docs = store.load(key.clone()).await?;
341                    let text = item_docs.to_string();
342                    let range = 0..text.len();
343
344                    (text, vec![(key, range)])
345                };
346
347                anyhow::Ok((provider, text, ranges))
348            }
349        });
350
351        cx.foreground_executor().spawn(async move {
352            let (provider, text, ranges) = task.await?;
353            Ok(SlashCommandOutput {
354                text,
355                sections: ranges
356                    .into_iter()
357                    .map(|(key, range)| SlashCommandOutputSection {
358                        range,
359                        icon: IconName::FileDoc,
360                        label: format!("docs ({provider}): {key}",).into(),
361                    })
362                    .collect(),
363                run_commands_in_text: false,
364            })
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(crate) 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(argument: &str) -> Self {
390        let Some((provider, argument)) = argument.split_once(' ') else {
391            return Self::NoProvider;
392        };
393
394        let provider = ProviderId(provider.into());
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(""),
455            DocsSlashCommandArgs::NoProvider
456        );
457        assert_eq!(
458            DocsSlashCommandArgs::parse("rustdoc"),
459            DocsSlashCommandArgs::NoProvider
460        );
461
462        assert_eq!(
463            DocsSlashCommandArgs::parse("rustdoc "),
464            DocsSlashCommandArgs::SearchPackageDocs {
465                provider: ProviderId("rustdoc".into()),
466                package: "".into(),
467                index: false
468            }
469        );
470        assert_eq!(
471            DocsSlashCommandArgs::parse("gleam "),
472            DocsSlashCommandArgs::SearchPackageDocs {
473                provider: ProviderId("gleam".into()),
474                package: "".into(),
475                index: false
476            }
477        );
478
479        assert_eq!(
480            DocsSlashCommandArgs::parse("rustdoc gpui"),
481            DocsSlashCommandArgs::SearchPackageDocs {
482                provider: ProviderId("rustdoc".into()),
483                package: "gpui".into(),
484                index: false,
485            }
486        );
487        assert_eq!(
488            DocsSlashCommandArgs::parse("gleam gleam_stdlib"),
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 gpui:"),
499            DocsSlashCommandArgs::SearchPackageDocs {
500                provider: ProviderId("rustdoc".into()),
501                package: "gpui".into(),
502                index: true,
503            }
504        );
505        assert_eq!(
506            DocsSlashCommandArgs::parse("gleam gleam_stdlib/"),
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("rustdoc gpui::foo::bar::Baz"),
516            DocsSlashCommandArgs::SearchItemDocs {
517                provider: ProviderId("rustdoc".into()),
518                package: "gpui".into(),
519                item_path: "gpui::foo::bar::Baz".into()
520            }
521        );
522        assert_eq!(
523            DocsSlashCommandArgs::parse("gleam gleam_stdlib/gleam/int"),
524            DocsSlashCommandArgs::SearchItemDocs {
525                provider: ProviderId("gleam".into()),
526                package: "gleam_stdlib".into(),
527                item_path: "gleam_stdlib/gleam/int".into()
528            }
529        );
530    }
531}