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}