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