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 gpui::{AppContext, BackgroundExecutor, Model, Task, WeakView};
11use indexed_docs::{
12 DocsDotRsProvider, IndexedDocsRegistry, IndexedDocsStore, LocalRustdocProvider, PackageName,
13 ProviderId,
14};
15use language::LspAdapterDelegate;
16use project::{Project, ProjectPath};
17use ui::prelude::*;
18use util::{maybe, ResultExt};
19use workspace::Workspace;
20
21pub(crate) struct DocsSlashCommand;
22
23impl DocsSlashCommand {
24 pub const NAME: &'static str = "docs";
25
26 fn path_to_cargo_toml(project: Model<Project>, cx: &mut AppContext) -> Option<Arc<Path>> {
27 let worktree = project.read(cx).worktrees(cx).next()?;
28 let worktree = worktree.read(cx);
29 let entry = worktree.entry_for_path("Cargo.toml")?;
30 let path = ProjectPath {
31 worktree_id: worktree.id(),
32 path: entry.path.clone(),
33 };
34 Some(Arc::from(
35 project.read(cx).absolute_path(&path, cx)?.as_path(),
36 ))
37 }
38
39 /// Ensures that the indexed doc providers for Rust are registered.
40 ///
41 /// Ideally we would do this sooner, but we need to wait until we're able to
42 /// access the workspace so we can read the project.
43 fn ensure_rust_doc_providers_are_registered(
44 &self,
45 workspace: Option<WeakView<Workspace>>,
46 cx: &mut AppContext,
47 ) {
48 let indexed_docs_registry = IndexedDocsRegistry::global(cx);
49 if indexed_docs_registry
50 .get_provider_store(LocalRustdocProvider::id())
51 .is_none()
52 {
53 let index_provider_deps = maybe!({
54 let workspace = workspace.clone().ok_or_else(|| anyhow!("no workspace"))?;
55 let workspace = workspace
56 .upgrade()
57 .ok_or_else(|| anyhow!("workspace was dropped"))?;
58 let project = workspace.read(cx).project().clone();
59 let fs = project.read(cx).fs().clone();
60 let cargo_workspace_root = Self::path_to_cargo_toml(project, cx)
61 .and_then(|path| path.parent().map(|path| path.to_path_buf()))
62 .ok_or_else(|| anyhow!("no Cargo workspace root found"))?;
63
64 anyhow::Ok((fs, cargo_workspace_root))
65 });
66
67 if let Some((fs, cargo_workspace_root)) = index_provider_deps.log_err() {
68 indexed_docs_registry.register_provider(Box::new(LocalRustdocProvider::new(
69 fs,
70 cargo_workspace_root,
71 )));
72 }
73 }
74
75 if indexed_docs_registry
76 .get_provider_store(DocsDotRsProvider::id())
77 .is_none()
78 {
79 let http_client = maybe!({
80 let workspace = workspace.ok_or_else(|| anyhow!("no workspace"))?;
81 let workspace = workspace
82 .upgrade()
83 .ok_or_else(|| anyhow!("workspace was dropped"))?;
84 let project = workspace.read(cx).project().clone();
85 anyhow::Ok(project.read(cx).client().http_client().clone())
86 });
87
88 if let Some(http_client) = http_client.log_err() {
89 indexed_docs_registry
90 .register_provider(Box::new(DocsDotRsProvider::new(http_client)));
91 }
92 }
93 }
94
95 /// Runs just-in-time indexing for a given package, in case the slash command
96 /// is run without any entries existing in the index.
97 fn run_just_in_time_indexing(
98 store: Arc<IndexedDocsStore>,
99 key: String,
100 package: PackageName,
101 executor: BackgroundExecutor,
102 ) -> Task<()> {
103 executor.clone().spawn(async move {
104 let (prefix, needs_full_index) = if let Some((prefix, _)) = key.split_once('*') {
105 // If we have a wildcard in the search, we want to wait until
106 // we've completely finished indexing so we get a full set of
107 // results for the wildcard.
108 (prefix.to_string(), true)
109 } else {
110 (key, false)
111 };
112
113 // If we already have some entries, we assume that we've indexed the package before
114 // and don't need to do it again.
115 let has_any_entries = store
116 .any_with_prefix(prefix.clone())
117 .await
118 .unwrap_or_default();
119 if has_any_entries {
120 return ();
121 };
122
123 let index_task = store.clone().index(package.clone());
124
125 if needs_full_index {
126 _ = index_task.await;
127 } else {
128 loop {
129 executor.timer(Duration::from_millis(200)).await;
130
131 if store
132 .any_with_prefix(prefix.clone())
133 .await
134 .unwrap_or_default()
135 || !store.is_indexing(&package)
136 {
137 break;
138 }
139 }
140 }
141 })
142 }
143}
144
145impl SlashCommand for DocsSlashCommand {
146 fn name(&self) -> String {
147 Self::NAME.into()
148 }
149
150 fn description(&self) -> String {
151 "insert docs".into()
152 }
153
154 fn menu_text(&self) -> String {
155 "Insert Documentation".into()
156 }
157
158 fn requires_argument(&self) -> bool {
159 true
160 }
161
162 fn complete_argument(
163 self: Arc<Self>,
164 arguments: &[String],
165 _cancel: Arc<AtomicBool>,
166 workspace: Option<WeakView<Workspace>>,
167 cx: &mut WindowContext,
168 ) -> Task<Result<Vec<ArgumentCompletion>>> {
169 self.ensure_rust_doc_providers_are_registered(workspace, cx);
170
171 let indexed_docs_registry = IndexedDocsRegistry::global(cx);
172 let args = DocsSlashCommandArgs::parse(arguments);
173 let store = args
174 .provider()
175 .ok_or_else(|| anyhow!("no docs provider specified"))
176 .and_then(|provider| IndexedDocsStore::try_global(provider, cx));
177 cx.background_executor().spawn(async move {
178 fn build_completions(items: Vec<String>) -> Vec<ArgumentCompletion> {
179 items
180 .into_iter()
181 .map(|item| ArgumentCompletion {
182 label: item.clone().into(),
183 new_text: item.to_string(),
184 run_command: true,
185 })
186 .collect()
187 }
188
189 match args {
190 DocsSlashCommandArgs::NoProvider => {
191 let providers = indexed_docs_registry.list_providers();
192 if providers.is_empty() {
193 return Ok(vec![ArgumentCompletion {
194 label: "No available docs providers.".into(),
195 new_text: String::new(),
196 run_command: false,
197 }]);
198 }
199
200 Ok(providers
201 .into_iter()
202 .map(|provider| ArgumentCompletion {
203 label: provider.to_string().into(),
204 new_text: provider.to_string(),
205 run_command: false,
206 })
207 .collect())
208 }
209 DocsSlashCommandArgs::SearchPackageDocs {
210 provider,
211 package,
212 index,
213 } => {
214 let store = store?;
215
216 if index {
217 // We don't need to hold onto this task, as the `IndexedDocsStore` will hold it
218 // until it completes.
219 drop(store.clone().index(package.as_str().into()));
220 }
221
222 let suggested_packages = store.clone().suggest_packages().await?;
223 let search_results = store.search(package).await;
224
225 let mut items = build_completions(search_results);
226 let workspace_crate_completions = suggested_packages
227 .into_iter()
228 .filter(|package_name| {
229 !items
230 .iter()
231 .any(|item| item.label.text() == package_name.as_ref())
232 })
233 .map(|package_name| ArgumentCompletion {
234 label: format!("{package_name} (unindexed)").into(),
235 new_text: format!("{package_name}"),
236 run_command: true,
237 })
238 .collect::<Vec<_>>();
239 items.extend(workspace_crate_completions);
240
241 if items.is_empty() {
242 return Ok(vec![ArgumentCompletion {
243 label: format!(
244 "Enter a {package_term} name.",
245 package_term = package_term(&provider)
246 )
247 .into(),
248 new_text: provider.to_string(),
249 run_command: false,
250 }]);
251 }
252
253 Ok(items)
254 }
255 DocsSlashCommandArgs::SearchItemDocs { item_path, .. } => {
256 let store = store?;
257 let items = store.search(item_path).await;
258 Ok(build_completions(items))
259 }
260 }
261 })
262 }
263
264 fn run(
265 self: Arc<Self>,
266 arguments: &[String],
267 _workspace: WeakView<Workspace>,
268 _delegate: Option<Arc<dyn LspAdapterDelegate>>,
269 cx: &mut WindowContext,
270 ) -> Task<Result<SlashCommandOutput>> {
271 if arguments.is_empty() {
272 return Task::ready(Err(anyhow!("missing an argument")));
273 };
274
275 let args = DocsSlashCommandArgs::parse(arguments);
276 let executor = cx.background_executor().clone();
277 let task = cx.background_executor().spawn({
278 let store = args
279 .provider()
280 .ok_or_else(|| anyhow!("no docs provider specified"))
281 .and_then(|provider| IndexedDocsStore::try_global(provider, cx));
282 async move {
283 let (provider, key) = match args.clone() {
284 DocsSlashCommandArgs::NoProvider => bail!("no docs provider specified"),
285 DocsSlashCommandArgs::SearchPackageDocs {
286 provider, package, ..
287 } => (provider, package),
288 DocsSlashCommandArgs::SearchItemDocs {
289 provider,
290 item_path,
291 ..
292 } => (provider, item_path),
293 };
294
295 if key.trim().is_empty() {
296 bail!(
297 "no {package_term} name provided",
298 package_term = package_term(&provider)
299 );
300 }
301
302 let store = store?;
303
304 if let Some(package) = args.package() {
305 Self::run_just_in_time_indexing(store.clone(), key.clone(), package, executor)
306 .await;
307 }
308
309 let (text, ranges) = if let Some((prefix, _)) = key.split_once('*') {
310 let docs = store.load_many_by_prefix(prefix.to_string()).await?;
311
312 let mut text = String::new();
313 let mut ranges = Vec::new();
314
315 for (key, docs) in docs {
316 let prev_len = text.len();
317
318 text.push_str(&docs.0);
319 text.push_str("\n");
320 ranges.push((key, prev_len..text.len()));
321 text.push_str("\n");
322 }
323
324 (text, ranges)
325 } else {
326 let item_docs = store.load(key.clone()).await?;
327 let text = item_docs.to_string();
328 let range = 0..text.len();
329
330 (text, vec![(key, range)])
331 };
332
333 anyhow::Ok((provider, text, ranges))
334 }
335 });
336
337 cx.foreground_executor().spawn(async move {
338 let (provider, text, ranges) = task.await?;
339 Ok(SlashCommandOutput {
340 text,
341 sections: ranges
342 .into_iter()
343 .map(|(key, range)| SlashCommandOutputSection {
344 range,
345 icon: IconName::FileDoc,
346 label: format!("docs ({provider}): {key}",).into(),
347 })
348 .collect(),
349 run_commands_in_text: false,
350 })
351 })
352 }
353}
354
355fn is_item_path_delimiter(char: char) -> bool {
356 !char.is_alphanumeric() && char != '-' && char != '_'
357}
358
359#[derive(Debug, PartialEq, Clone)]
360pub(crate) enum DocsSlashCommandArgs {
361 NoProvider,
362 SearchPackageDocs {
363 provider: ProviderId,
364 package: String,
365 index: bool,
366 },
367 SearchItemDocs {
368 provider: ProviderId,
369 package: String,
370 item_path: String,
371 },
372}
373
374impl DocsSlashCommandArgs {
375 pub fn parse(arguments: &[String]) -> Self {
376 let Some(provider) = arguments
377 .get(0)
378 .cloned()
379 .filter(|arg| !arg.trim().is_empty())
380 else {
381 return Self::NoProvider;
382 };
383 let provider = ProviderId(provider.into());
384 let Some(argument) = arguments.get(1) else {
385 return Self::NoProvider;
386 };
387
388 if let Some((package, rest)) = argument.split_once(is_item_path_delimiter) {
389 if rest.trim().is_empty() {
390 Self::SearchPackageDocs {
391 provider,
392 package: package.to_owned(),
393 index: true,
394 }
395 } else {
396 Self::SearchItemDocs {
397 provider,
398 package: package.to_owned(),
399 item_path: argument.to_owned(),
400 }
401 }
402 } else {
403 Self::SearchPackageDocs {
404 provider,
405 package: argument.to_owned(),
406 index: false,
407 }
408 }
409 }
410
411 pub fn provider(&self) -> Option<ProviderId> {
412 match self {
413 Self::NoProvider => None,
414 Self::SearchPackageDocs { provider, .. } | Self::SearchItemDocs { provider, .. } => {
415 Some(provider.clone())
416 }
417 }
418 }
419
420 pub fn package(&self) -> Option<PackageName> {
421 match self {
422 Self::NoProvider => None,
423 Self::SearchPackageDocs { package, .. } | Self::SearchItemDocs { package, .. } => {
424 Some(package.as_str().into())
425 }
426 }
427 }
428}
429
430/// Returns the term used to refer to a package.
431fn package_term(provider: &ProviderId) -> &'static str {
432 if provider == &DocsDotRsProvider::id() || provider == &LocalRustdocProvider::id() {
433 return "crate";
434 }
435
436 "package"
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442
443 #[test]
444 fn test_parse_docs_slash_command_args() {
445 assert_eq!(
446 DocsSlashCommandArgs::parse(&["".to_string()]),
447 DocsSlashCommandArgs::NoProvider
448 );
449 assert_eq!(
450 DocsSlashCommandArgs::parse(&["rustdoc".to_string()]),
451 DocsSlashCommandArgs::NoProvider
452 );
453
454 assert_eq!(
455 DocsSlashCommandArgs::parse(&["rustdoc".to_string(), "".to_string()]),
456 DocsSlashCommandArgs::SearchPackageDocs {
457 provider: ProviderId("rustdoc".into()),
458 package: "".into(),
459 index: false
460 }
461 );
462 assert_eq!(
463 DocsSlashCommandArgs::parse(&["gleam".to_string(), "".to_string()]),
464 DocsSlashCommandArgs::SearchPackageDocs {
465 provider: ProviderId("gleam".into()),
466 package: "".into(),
467 index: false
468 }
469 );
470
471 assert_eq!(
472 DocsSlashCommandArgs::parse(&["rustdoc".to_string(), "gpui".to_string()]),
473 DocsSlashCommandArgs::SearchPackageDocs {
474 provider: ProviderId("rustdoc".into()),
475 package: "gpui".into(),
476 index: false,
477 }
478 );
479 assert_eq!(
480 DocsSlashCommandArgs::parse(&["gleam".to_string(), "gleam_stdlib".to_string()]),
481 DocsSlashCommandArgs::SearchPackageDocs {
482 provider: ProviderId("gleam".into()),
483 package: "gleam_stdlib".into(),
484 index: false
485 }
486 );
487
488 // Adding an item path delimiter indicates we can start indexing.
489 assert_eq!(
490 DocsSlashCommandArgs::parse(&["rustdoc".to_string(), "gpui:".to_string()]),
491 DocsSlashCommandArgs::SearchPackageDocs {
492 provider: ProviderId("rustdoc".into()),
493 package: "gpui".into(),
494 index: true,
495 }
496 );
497 assert_eq!(
498 DocsSlashCommandArgs::parse(&["gleam".to_string(), "gleam_stdlib/".to_string()]),
499 DocsSlashCommandArgs::SearchPackageDocs {
500 provider: ProviderId("gleam".into()),
501 package: "gleam_stdlib".into(),
502 index: true
503 }
504 );
505
506 assert_eq!(
507 DocsSlashCommandArgs::parse(&[
508 "rustdoc".to_string(),
509 "gpui::foo::bar::Baz".to_string()
510 ]),
511 DocsSlashCommandArgs::SearchItemDocs {
512 provider: ProviderId("rustdoc".into()),
513 package: "gpui".into(),
514 item_path: "gpui::foo::bar::Baz".into()
515 }
516 );
517 assert_eq!(
518 DocsSlashCommandArgs::parse(&[
519 "gleam".to_string(),
520 "gleam_stdlib/gleam/int".to_string()
521 ]),
522 DocsSlashCommandArgs::SearchItemDocs {
523 provider: ProviderId("gleam".into()),
524 package: "gleam_stdlib".into(),
525 item_path: "gleam_stdlib/gleam/int".into()
526 }
527 );
528 }
529}