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