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 })
361 }
362}
363
364fn is_item_path_delimiter(char: char) -> bool {
365 !char.is_alphanumeric() && char != '-' && char != '_'
366}
367
368#[derive(Debug, PartialEq, Clone)]
369pub(crate) enum DocsSlashCommandArgs {
370 NoProvider,
371 SearchPackageDocs {
372 provider: ProviderId,
373 package: String,
374 index: bool,
375 },
376 SearchItemDocs {
377 provider: ProviderId,
378 package: String,
379 item_path: String,
380 },
381}
382
383impl DocsSlashCommandArgs {
384 pub fn parse(arguments: &[String]) -> Self {
385 let Some(provider) = arguments
386 .get(0)
387 .cloned()
388 .filter(|arg| !arg.trim().is_empty())
389 else {
390 return Self::NoProvider;
391 };
392 let provider = ProviderId(provider.into());
393 let Some(argument) = arguments.get(1) else {
394 return Self::NoProvider;
395 };
396
397 if let Some((package, rest)) = argument.split_once(is_item_path_delimiter) {
398 if rest.trim().is_empty() {
399 Self::SearchPackageDocs {
400 provider,
401 package: package.to_owned(),
402 index: true,
403 }
404 } else {
405 Self::SearchItemDocs {
406 provider,
407 package: package.to_owned(),
408 item_path: argument.to_owned(),
409 }
410 }
411 } else {
412 Self::SearchPackageDocs {
413 provider,
414 package: argument.to_owned(),
415 index: false,
416 }
417 }
418 }
419
420 pub fn provider(&self) -> Option<ProviderId> {
421 match self {
422 Self::NoProvider => None,
423 Self::SearchPackageDocs { provider, .. } | Self::SearchItemDocs { provider, .. } => {
424 Some(provider.clone())
425 }
426 }
427 }
428
429 pub fn package(&self) -> Option<PackageName> {
430 match self {
431 Self::NoProvider => None,
432 Self::SearchPackageDocs { package, .. } | Self::SearchItemDocs { package, .. } => {
433 Some(package.as_str().into())
434 }
435 }
436 }
437}
438
439/// Returns the term used to refer to a package.
440fn package_term(provider: &ProviderId) -> &'static str {
441 if provider == &DocsDotRsProvider::id() || provider == &LocalRustdocProvider::id() {
442 return "crate";
443 }
444
445 "package"
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 #[test]
453 fn test_parse_docs_slash_command_args() {
454 assert_eq!(
455 DocsSlashCommandArgs::parse(&["".to_string()]),
456 DocsSlashCommandArgs::NoProvider
457 );
458 assert_eq!(
459 DocsSlashCommandArgs::parse(&["rustdoc".to_string()]),
460 DocsSlashCommandArgs::NoProvider
461 );
462
463 assert_eq!(
464 DocsSlashCommandArgs::parse(&["rustdoc".to_string(), "".to_string()]),
465 DocsSlashCommandArgs::SearchPackageDocs {
466 provider: ProviderId("rustdoc".into()),
467 package: "".into(),
468 index: false
469 }
470 );
471 assert_eq!(
472 DocsSlashCommandArgs::parse(&["gleam".to_string(), "".to_string()]),
473 DocsSlashCommandArgs::SearchPackageDocs {
474 provider: ProviderId("gleam".into()),
475 package: "".into(),
476 index: false
477 }
478 );
479
480 assert_eq!(
481 DocsSlashCommandArgs::parse(&["rustdoc".to_string(), "gpui".to_string()]),
482 DocsSlashCommandArgs::SearchPackageDocs {
483 provider: ProviderId("rustdoc".into()),
484 package: "gpui".into(),
485 index: false,
486 }
487 );
488 assert_eq!(
489 DocsSlashCommandArgs::parse(&["gleam".to_string(), "gleam_stdlib".to_string()]),
490 DocsSlashCommandArgs::SearchPackageDocs {
491 provider: ProviderId("gleam".into()),
492 package: "gleam_stdlib".into(),
493 index: false
494 }
495 );
496
497 // Adding an item path delimiter indicates we can start indexing.
498 assert_eq!(
499 DocsSlashCommandArgs::parse(&["rustdoc".to_string(), "gpui:".to_string()]),
500 DocsSlashCommandArgs::SearchPackageDocs {
501 provider: ProviderId("rustdoc".into()),
502 package: "gpui".into(),
503 index: true,
504 }
505 );
506 assert_eq!(
507 DocsSlashCommandArgs::parse(&["gleam".to_string(), "gleam_stdlib/".to_string()]),
508 DocsSlashCommandArgs::SearchPackageDocs {
509 provider: ProviderId("gleam".into()),
510 package: "gleam_stdlib".into(),
511 index: true
512 }
513 );
514
515 assert_eq!(
516 DocsSlashCommandArgs::parse(&[
517 "rustdoc".to_string(),
518 "gpui::foo::bar::Baz".to_string()
519 ]),
520 DocsSlashCommandArgs::SearchItemDocs {
521 provider: ProviderId("rustdoc".into()),
522 package: "gpui".into(),
523 item_path: "gpui::foo::bar::Baz".into()
524 }
525 );
526 assert_eq!(
527 DocsSlashCommandArgs::parse(&[
528 "gleam".to_string(),
529 "gleam_stdlib/gleam/int".to_string()
530 ]),
531 DocsSlashCommandArgs::SearchItemDocs {
532 provider: ProviderId("gleam".into()),
533 package: "gleam_stdlib".into(),
534 item_path: "gleam_stdlib/gleam/int".into()
535 }
536 );
537 }
538}