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