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().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 query: String,
165 _cancel: Arc<AtomicBool>,
166 workspace: Option<WeakView<Workspace>>,
167 cx: &mut AppContext,
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(&query);
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(
179 provider: ProviderId,
180 items: Vec<String>,
181 ) -> Vec<ArgumentCompletion> {
182 items
183 .into_iter()
184 .map(|item| ArgumentCompletion {
185 label: item.clone(),
186 new_text: format!("{provider} {item}"),
187 run_command: true,
188 })
189 .collect()
190 }
191
192 match args {
193 DocsSlashCommandArgs::NoProvider => {
194 let providers = indexed_docs_registry.list_providers();
195 if providers.is_empty() {
196 return Ok(vec![ArgumentCompletion {
197 label: "No available docs providers.".to_string(),
198 new_text: String::new(),
199 run_command: false,
200 }]);
201 }
202
203 Ok(providers
204 .into_iter()
205 .map(|provider| ArgumentCompletion {
206 label: provider.to_string(),
207 new_text: provider.to_string(),
208 run_command: false,
209 })
210 .collect())
211 }
212 DocsSlashCommandArgs::SearchPackageDocs {
213 provider,
214 package,
215 index,
216 } => {
217 let store = store?;
218
219 if index {
220 // We don't need to hold onto this task, as the `IndexedDocsStore` will hold it
221 // until it completes.
222 let _ = store.clone().index(package.as_str().into());
223 }
224
225 let items = store.search(package).await;
226 Ok(build_completions(provider, items))
227 }
228 DocsSlashCommandArgs::SearchItemDocs {
229 provider,
230 item_path,
231 ..
232 } => {
233 let store = store?;
234 let items = store.search(item_path).await;
235 Ok(build_completions(provider, items))
236 }
237 }
238 })
239 }
240
241 fn run(
242 self: Arc<Self>,
243 argument: Option<&str>,
244 _workspace: WeakView<Workspace>,
245 _delegate: Arc<dyn LspAdapterDelegate>,
246 cx: &mut WindowContext,
247 ) -> Task<Result<SlashCommandOutput>> {
248 let Some(argument) = argument else {
249 return Task::ready(Err(anyhow!("missing argument")));
250 };
251
252 let args = DocsSlashCommandArgs::parse(argument);
253 let executor = cx.background_executor().clone();
254 let task = cx.background_executor().spawn({
255 let store = args
256 .provider()
257 .ok_or_else(|| anyhow!("no docs provider specified"))
258 .and_then(|provider| IndexedDocsStore::try_global(provider, cx));
259 async move {
260 let (provider, key) = match args.clone() {
261 DocsSlashCommandArgs::NoProvider => bail!("no docs provider specified"),
262 DocsSlashCommandArgs::SearchPackageDocs {
263 provider, package, ..
264 } => (provider, package),
265 DocsSlashCommandArgs::SearchItemDocs {
266 provider,
267 item_path,
268 ..
269 } => (provider, item_path),
270 };
271
272 let store = store?;
273
274 if let Some(package) = args.package() {
275 Self::run_just_in_time_indexing(store.clone(), key.clone(), package, executor)
276 .await;
277 }
278
279 let (text, ranges) = if let Some((prefix, _)) = key.split_once('*') {
280 let docs = store.load_many_by_prefix(prefix.to_string()).await?;
281
282 let mut text = String::new();
283 let mut ranges = Vec::new();
284
285 for (key, docs) in docs {
286 let prev_len = text.len();
287
288 text.push_str(&docs.0);
289 text.push_str("\n");
290 ranges.push((key, prev_len..text.len()));
291 text.push_str("\n");
292 }
293
294 (text, ranges)
295 } else {
296 let item_docs = store.load(key.clone()).await?;
297 let text = item_docs.to_string();
298 let range = 0..text.len();
299
300 (text, vec![(key, range)])
301 };
302
303 anyhow::Ok((provider, text, ranges))
304 }
305 });
306
307 cx.foreground_executor().spawn(async move {
308 let (provider, text, ranges) = task.await?;
309 Ok(SlashCommandOutput {
310 text,
311 sections: ranges
312 .into_iter()
313 .map(|(key, range)| SlashCommandOutputSection {
314 range,
315 icon: IconName::FileDoc,
316 label: format!("docs ({provider}): {key}",).into(),
317 })
318 .collect(),
319 run_commands_in_text: false,
320 })
321 })
322 }
323}
324
325fn is_item_path_delimiter(char: char) -> bool {
326 !char.is_alphanumeric() && char != '-' && char != '_'
327}
328
329#[derive(Debug, PartialEq, Clone)]
330pub(crate) enum DocsSlashCommandArgs {
331 NoProvider,
332 SearchPackageDocs {
333 provider: ProviderId,
334 package: String,
335 index: bool,
336 },
337 SearchItemDocs {
338 provider: ProviderId,
339 package: String,
340 item_path: String,
341 },
342}
343
344impl DocsSlashCommandArgs {
345 pub fn parse(argument: &str) -> Self {
346 let Some((provider, argument)) = argument.split_once(' ') else {
347 return Self::NoProvider;
348 };
349
350 let provider = ProviderId(provider.into());
351
352 if let Some((package, rest)) = argument.split_once(is_item_path_delimiter) {
353 if rest.trim().is_empty() {
354 Self::SearchPackageDocs {
355 provider,
356 package: package.to_owned(),
357 index: true,
358 }
359 } else {
360 Self::SearchItemDocs {
361 provider,
362 package: package.to_owned(),
363 item_path: argument.to_owned(),
364 }
365 }
366 } else {
367 Self::SearchPackageDocs {
368 provider,
369 package: argument.to_owned(),
370 index: false,
371 }
372 }
373 }
374
375 pub fn provider(&self) -> Option<ProviderId> {
376 match self {
377 Self::NoProvider => None,
378 Self::SearchPackageDocs { provider, .. } | Self::SearchItemDocs { provider, .. } => {
379 Some(provider.clone())
380 }
381 }
382 }
383
384 pub fn package(&self) -> Option<PackageName> {
385 match self {
386 Self::NoProvider => None,
387 Self::SearchPackageDocs { package, .. } | Self::SearchItemDocs { package, .. } => {
388 Some(package.as_str().into())
389 }
390 }
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397
398 #[test]
399 fn test_parse_docs_slash_command_args() {
400 assert_eq!(
401 DocsSlashCommandArgs::parse(""),
402 DocsSlashCommandArgs::NoProvider
403 );
404 assert_eq!(
405 DocsSlashCommandArgs::parse("rustdoc"),
406 DocsSlashCommandArgs::NoProvider
407 );
408
409 assert_eq!(
410 DocsSlashCommandArgs::parse("rustdoc "),
411 DocsSlashCommandArgs::SearchPackageDocs {
412 provider: ProviderId("rustdoc".into()),
413 package: "".into(),
414 index: false
415 }
416 );
417 assert_eq!(
418 DocsSlashCommandArgs::parse("gleam "),
419 DocsSlashCommandArgs::SearchPackageDocs {
420 provider: ProviderId("gleam".into()),
421 package: "".into(),
422 index: false
423 }
424 );
425
426 assert_eq!(
427 DocsSlashCommandArgs::parse("rustdoc gpui"),
428 DocsSlashCommandArgs::SearchPackageDocs {
429 provider: ProviderId("rustdoc".into()),
430 package: "gpui".into(),
431 index: false,
432 }
433 );
434 assert_eq!(
435 DocsSlashCommandArgs::parse("gleam gleam_stdlib"),
436 DocsSlashCommandArgs::SearchPackageDocs {
437 provider: ProviderId("gleam".into()),
438 package: "gleam_stdlib".into(),
439 index: false
440 }
441 );
442
443 // Adding an item path delimiter indicates we can start indexing.
444 assert_eq!(
445 DocsSlashCommandArgs::parse("rustdoc gpui:"),
446 DocsSlashCommandArgs::SearchPackageDocs {
447 provider: ProviderId("rustdoc".into()),
448 package: "gpui".into(),
449 index: true,
450 }
451 );
452 assert_eq!(
453 DocsSlashCommandArgs::parse("gleam gleam_stdlib/"),
454 DocsSlashCommandArgs::SearchPackageDocs {
455 provider: ProviderId("gleam".into()),
456 package: "gleam_stdlib".into(),
457 index: true
458 }
459 );
460
461 assert_eq!(
462 DocsSlashCommandArgs::parse("rustdoc gpui::foo::bar::Baz"),
463 DocsSlashCommandArgs::SearchItemDocs {
464 provider: ProviderId("rustdoc".into()),
465 package: "gpui".into(),
466 item_path: "gpui::foo::bar::Baz".into()
467 }
468 );
469 assert_eq!(
470 DocsSlashCommandArgs::parse("gleam gleam_stdlib/gleam/int"),
471 DocsSlashCommandArgs::SearchItemDocs {
472 provider: ProviderId("gleam".into()),
473 package: "gleam_stdlib".into(),
474 item_path: "gleam_stdlib/gleam/int".into()
475 }
476 );
477 }
478}