1use std::path::Path;
2use std::sync::Arc;
3use std::sync::atomic::AtomicBool;
4use std::time::Duration;
5
6use anyhow::{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.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())
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<WeakEntity<Workspace>>,
168 _: &mut Window,
169 cx: &mut App,
170 ) -> Task<Result<Vec<ArgumentCompletion>>> {
171 self.ensure_rust_doc_providers_are_registered(workspace, cx);
172
173 let indexed_docs_registry = IndexedDocsRegistry::global(cx);
174 let args = DocsSlashCommandArgs::parse(arguments);
175 let store = args
176 .provider()
177 .ok_or_else(|| anyhow!("no docs provider specified"))
178 .and_then(|provider| IndexedDocsStore::try_global(provider, cx));
179 cx.background_spawn(async move {
180 fn build_completions(items: Vec<String>) -> Vec<ArgumentCompletion> {
181 items
182 .into_iter()
183 .map(|item| ArgumentCompletion {
184 label: item.clone().into(),
185 new_text: item.to_string(),
186 after_completion: assistant_slash_command::AfterCompletion::Run,
187 replace_previous_arguments: false,
188 })
189 .collect()
190 }
191
192 match args {
193 DocsSlashCommandArgs::NoProvider => {
194 let providers = indexed_docs_registry.list_providers();
195 if providers.is_empty() {
196 return Ok(vec![ArgumentCompletion {
197 label: "No available docs providers.".into(),
198 new_text: String::new(),
199 after_completion: false.into(),
200 replace_previous_arguments: false,
201 }]);
202 }
203
204 Ok(providers
205 .into_iter()
206 .map(|provider| ArgumentCompletion {
207 label: provider.to_string().into(),
208 new_text: provider.to_string(),
209 after_completion: false.into(),
210 replace_previous_arguments: false,
211 })
212 .collect())
213 }
214 DocsSlashCommandArgs::SearchPackageDocs {
215 provider,
216 package,
217 index,
218 } => {
219 let store = store?;
220
221 if index {
222 // We don't need to hold onto this task, as the `IndexedDocsStore` will hold it
223 // until it completes.
224 drop(store.clone().index(package.as_str().into()));
225 }
226
227 let suggested_packages = store.clone().suggest_packages().await?;
228 let search_results = store.search(package).await;
229
230 let mut items = build_completions(search_results);
231 let workspace_crate_completions = suggested_packages
232 .into_iter()
233 .filter(|package_name| {
234 !items
235 .iter()
236 .any(|item| item.label.text() == package_name.as_ref())
237 })
238 .map(|package_name| ArgumentCompletion {
239 label: format!("{package_name} (unindexed)").into(),
240 new_text: format!("{package_name}"),
241 after_completion: true.into(),
242 replace_previous_arguments: false,
243 })
244 .collect::<Vec<_>>();
245 items.extend(workspace_crate_completions);
246
247 if items.is_empty() {
248 return Ok(vec![ArgumentCompletion {
249 label: format!(
250 "Enter a {package_term} name.",
251 package_term = package_term(&provider)
252 )
253 .into(),
254 new_text: provider.to_string(),
255 after_completion: false.into(),
256 replace_previous_arguments: false,
257 }]);
258 }
259
260 Ok(items)
261 }
262 DocsSlashCommandArgs::SearchItemDocs { item_path, .. } => {
263 let store = store?;
264 let items = store.search(item_path).await;
265 Ok(build_completions(items))
266 }
267 }
268 })
269 }
270
271 fn run(
272 self: Arc<Self>,
273 arguments: &[String],
274 _context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
275 _context_buffer: BufferSnapshot,
276 _workspace: WeakEntity<Workspace>,
277 _delegate: Option<Arc<dyn LspAdapterDelegate>>,
278 _: &mut Window,
279 cx: &mut App,
280 ) -> Task<SlashCommandResult> {
281 if arguments.is_empty() {
282 return Task::ready(Err(anyhow!("missing an argument")));
283 };
284
285 let args = DocsSlashCommandArgs::parse(arguments);
286 let executor = cx.background_executor().clone();
287 let task = cx.background_spawn({
288 let store = args
289 .provider()
290 .ok_or_else(|| anyhow!("no docs provider specified"))
291 .and_then(|provider| IndexedDocsStore::try_global(provider, cx));
292 async move {
293 let (provider, key) = match args.clone() {
294 DocsSlashCommandArgs::NoProvider => bail!("no docs provider specified"),
295 DocsSlashCommandArgs::SearchPackageDocs {
296 provider, package, ..
297 } => (provider, package),
298 DocsSlashCommandArgs::SearchItemDocs {
299 provider,
300 item_path,
301 ..
302 } => (provider, item_path),
303 };
304
305 if key.trim().is_empty() {
306 bail!(
307 "no {package_term} name provided",
308 package_term = package_term(&provider)
309 );
310 }
311
312 let store = store?;
313
314 if let Some(package) = args.package() {
315 Self::run_just_in_time_indexing(store.clone(), key.clone(), package, executor)
316 .await;
317 }
318
319 let (text, ranges) = if let Some((prefix, _)) = key.split_once('*') {
320 let docs = store.load_many_by_prefix(prefix.to_string()).await?;
321
322 let mut text = String::new();
323 let mut ranges = Vec::new();
324
325 for (key, docs) in docs {
326 let prev_len = text.len();
327
328 text.push_str(&docs.0);
329 text.push_str("\n");
330 ranges.push((key, prev_len..text.len()));
331 text.push_str("\n");
332 }
333
334 (text, ranges)
335 } else {
336 let item_docs = store.load(key.clone()).await?;
337 let text = item_docs.to_string();
338 let range = 0..text.len();
339
340 (text, vec![(key, range)])
341 };
342
343 anyhow::Ok((provider, text, ranges))
344 }
345 });
346
347 cx.foreground_executor().spawn(async move {
348 let (provider, text, ranges) = task.await?;
349 Ok(SlashCommandOutput {
350 text,
351 sections: ranges
352 .into_iter()
353 .map(|(key, range)| SlashCommandOutputSection {
354 range,
355 icon: IconName::FileDoc,
356 label: format!("docs ({provider}): {key}",).into(),
357 metadata: None,
358 })
359 .collect(),
360 run_commands_in_text: false,
361 }
362 .to_event_stream())
363 })
364 }
365}
366
367fn is_item_path_delimiter(char: char) -> bool {
368 !char.is_alphanumeric() && char != '-' && char != '_'
369}
370
371#[derive(Debug, PartialEq, Clone)]
372pub enum DocsSlashCommandArgs {
373 NoProvider,
374 SearchPackageDocs {
375 provider: ProviderId,
376 package: String,
377 index: bool,
378 },
379 SearchItemDocs {
380 provider: ProviderId,
381 package: String,
382 item_path: String,
383 },
384}
385
386impl DocsSlashCommandArgs {
387 pub fn parse(arguments: &[String]) -> Self {
388 let Some(provider) = arguments
389 .get(0)
390 .cloned()
391 .filter(|arg| !arg.trim().is_empty())
392 else {
393 return Self::NoProvider;
394 };
395 let provider = ProviderId(provider.into());
396 let Some(argument) = arguments.get(1) else {
397 return Self::NoProvider;
398 };
399
400 if let Some((package, rest)) = argument.split_once(is_item_path_delimiter) {
401 if rest.trim().is_empty() {
402 Self::SearchPackageDocs {
403 provider,
404 package: package.to_owned(),
405 index: true,
406 }
407 } else {
408 Self::SearchItemDocs {
409 provider,
410 package: package.to_owned(),
411 item_path: argument.to_owned(),
412 }
413 }
414 } else {
415 Self::SearchPackageDocs {
416 provider,
417 package: argument.to_owned(),
418 index: false,
419 }
420 }
421 }
422
423 pub fn provider(&self) -> Option<ProviderId> {
424 match self {
425 Self::NoProvider => None,
426 Self::SearchPackageDocs { provider, .. } | Self::SearchItemDocs { provider, .. } => {
427 Some(provider.clone())
428 }
429 }
430 }
431
432 pub fn package(&self) -> Option<PackageName> {
433 match self {
434 Self::NoProvider => None,
435 Self::SearchPackageDocs { package, .. } | Self::SearchItemDocs { package, .. } => {
436 Some(package.as_str().into())
437 }
438 }
439 }
440}
441
442/// Returns the term used to refer to a package.
443fn package_term(provider: &ProviderId) -> &'static str {
444 if provider == &DocsDotRsProvider::id() || provider == &LocalRustdocProvider::id() {
445 return "crate";
446 }
447
448 "package"
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 #[test]
456 fn test_parse_docs_slash_command_args() {
457 assert_eq!(
458 DocsSlashCommandArgs::parse(&["".to_string()]),
459 DocsSlashCommandArgs::NoProvider
460 );
461 assert_eq!(
462 DocsSlashCommandArgs::parse(&["rustdoc".to_string()]),
463 DocsSlashCommandArgs::NoProvider
464 );
465
466 assert_eq!(
467 DocsSlashCommandArgs::parse(&["rustdoc".to_string(), "".to_string()]),
468 DocsSlashCommandArgs::SearchPackageDocs {
469 provider: ProviderId("rustdoc".into()),
470 package: "".into(),
471 index: false
472 }
473 );
474 assert_eq!(
475 DocsSlashCommandArgs::parse(&["gleam".to_string(), "".to_string()]),
476 DocsSlashCommandArgs::SearchPackageDocs {
477 provider: ProviderId("gleam".into()),
478 package: "".into(),
479 index: false
480 }
481 );
482
483 assert_eq!(
484 DocsSlashCommandArgs::parse(&["rustdoc".to_string(), "gpui".to_string()]),
485 DocsSlashCommandArgs::SearchPackageDocs {
486 provider: ProviderId("rustdoc".into()),
487 package: "gpui".into(),
488 index: false,
489 }
490 );
491 assert_eq!(
492 DocsSlashCommandArgs::parse(&["gleam".to_string(), "gleam_stdlib".to_string()]),
493 DocsSlashCommandArgs::SearchPackageDocs {
494 provider: ProviderId("gleam".into()),
495 package: "gleam_stdlib".into(),
496 index: false
497 }
498 );
499
500 // Adding an item path delimiter indicates we can start indexing.
501 assert_eq!(
502 DocsSlashCommandArgs::parse(&["rustdoc".to_string(), "gpui:".to_string()]),
503 DocsSlashCommandArgs::SearchPackageDocs {
504 provider: ProviderId("rustdoc".into()),
505 package: "gpui".into(),
506 index: true,
507 }
508 );
509 assert_eq!(
510 DocsSlashCommandArgs::parse(&["gleam".to_string(), "gleam_stdlib/".to_string()]),
511 DocsSlashCommandArgs::SearchPackageDocs {
512 provider: ProviderId("gleam".into()),
513 package: "gleam_stdlib".into(),
514 index: true
515 }
516 );
517
518 assert_eq!(
519 DocsSlashCommandArgs::parse(&[
520 "rustdoc".to_string(),
521 "gpui::foo::bar::Baz".to_string()
522 ]),
523 DocsSlashCommandArgs::SearchItemDocs {
524 provider: ProviderId("rustdoc".into()),
525 package: "gpui".into(),
526 item_path: "gpui::foo::bar::Baz".into()
527 }
528 );
529 assert_eq!(
530 DocsSlashCommandArgs::parse(&[
531 "gleam".to_string(),
532 "gleam_stdlib/gleam/int".to_string()
533 ]),
534 DocsSlashCommandArgs::SearchItemDocs {
535 provider: ProviderId("gleam".into()),
536 package: "gleam_stdlib".into(),
537 item_path: "gleam_stdlib/gleam/int".into()
538 }
539 );
540 }
541}