1use std::path::Path;
2use std::sync::atomic::AtomicBool;
3use std::sync::Arc;
4
5use anyhow::{anyhow, bail, Result};
6use assistant_slash_command::{
7 ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
8};
9use gpui::{AppContext, Model, Task, WeakView};
10use indexed_docs::{
11 DocsDotRsProvider, IndexedDocsRegistry, IndexedDocsStore, LocalRustdocProvider, PackageName,
12 ProviderId,
13};
14use language::LspAdapterDelegate;
15use project::{Project, ProjectPath};
16use ui::prelude::*;
17use util::{maybe, ResultExt};
18use workspace::Workspace;
19
20pub(crate) struct DocsSlashCommand;
21
22impl DocsSlashCommand {
23 pub const NAME: &'static str = "docs";
24
25 fn path_to_cargo_toml(project: Model<Project>, cx: &mut AppContext) -> Option<Arc<Path>> {
26 let worktree = project.read(cx).worktrees().next()?;
27 let worktree = worktree.read(cx);
28 let entry = worktree.entry_for_path("Cargo.toml")?;
29 let path = ProjectPath {
30 worktree_id: worktree.id(),
31 path: entry.path.clone(),
32 };
33 Some(Arc::from(
34 project.read(cx).absolute_path(&path, cx)?.as_path(),
35 ))
36 }
37
38 /// Ensures that the indexed doc providers for Rust are registered.
39 ///
40 /// Ideally we would do this sooner, but we need to wait until we're able to
41 /// access the workspace so we can read the project.
42 fn ensure_rust_doc_providers_are_registered(
43 &self,
44 workspace: Option<WeakView<Workspace>>,
45 cx: &mut AppContext,
46 ) {
47 let indexed_docs_registry = IndexedDocsRegistry::global(cx);
48 if indexed_docs_registry
49 .get_provider_store(LocalRustdocProvider::id())
50 .is_none()
51 {
52 let index_provider_deps = maybe!({
53 let workspace = workspace.clone().ok_or_else(|| anyhow!("no workspace"))?;
54 let workspace = workspace
55 .upgrade()
56 .ok_or_else(|| anyhow!("workspace was dropped"))?;
57 let project = workspace.read(cx).project().clone();
58 let fs = project.read(cx).fs().clone();
59 let cargo_workspace_root = Self::path_to_cargo_toml(project, cx)
60 .and_then(|path| path.parent().map(|path| path.to_path_buf()))
61 .ok_or_else(|| anyhow!("no Cargo workspace root found"))?;
62
63 anyhow::Ok((fs, cargo_workspace_root))
64 });
65
66 if let Some((fs, cargo_workspace_root)) = index_provider_deps.log_err() {
67 indexed_docs_registry.register_provider(Box::new(LocalRustdocProvider::new(
68 fs,
69 cargo_workspace_root,
70 )));
71 }
72 }
73
74 if indexed_docs_registry
75 .get_provider_store(DocsDotRsProvider::id())
76 .is_none()
77 {
78 let http_client = maybe!({
79 let workspace = workspace.ok_or_else(|| anyhow!("no workspace"))?;
80 let workspace = workspace
81 .upgrade()
82 .ok_or_else(|| anyhow!("workspace was dropped"))?;
83 let project = workspace.read(cx).project().clone();
84 anyhow::Ok(project.read(cx).client().http_client().clone())
85 });
86
87 if let Some(http_client) = http_client.log_err() {
88 indexed_docs_registry
89 .register_provider(Box::new(DocsDotRsProvider::new(http_client)));
90 }
91 }
92 }
93}
94
95impl SlashCommand for DocsSlashCommand {
96 fn name(&self) -> String {
97 Self::NAME.into()
98 }
99
100 fn description(&self) -> String {
101 "insert docs".into()
102 }
103
104 fn menu_text(&self) -> String {
105 "Insert Documentation".into()
106 }
107
108 fn requires_argument(&self) -> bool {
109 true
110 }
111
112 fn complete_argument(
113 self: Arc<Self>,
114 query: String,
115 _cancel: Arc<AtomicBool>,
116 workspace: Option<WeakView<Workspace>>,
117 cx: &mut AppContext,
118 ) -> Task<Result<Vec<ArgumentCompletion>>> {
119 self.ensure_rust_doc_providers_are_registered(workspace, cx);
120
121 let indexed_docs_registry = IndexedDocsRegistry::global(cx);
122 let args = DocsSlashCommandArgs::parse(&query);
123 let store = args
124 .provider()
125 .ok_or_else(|| anyhow!("no docs provider specified"))
126 .and_then(|provider| IndexedDocsStore::try_global(provider, cx));
127 cx.background_executor().spawn(async move {
128 fn build_completions(
129 provider: ProviderId,
130 items: Vec<String>,
131 ) -> Vec<ArgumentCompletion> {
132 items
133 .into_iter()
134 .map(|item| ArgumentCompletion {
135 label: item.clone(),
136 new_text: format!("{provider} {item}"),
137 run_command: true,
138 })
139 .collect()
140 }
141
142 match args {
143 DocsSlashCommandArgs::NoProvider => {
144 let providers = indexed_docs_registry.list_providers();
145 if providers.is_empty() {
146 return Ok(vec![ArgumentCompletion {
147 label: "No available docs providers.".to_string(),
148 new_text: String::new(),
149 run_command: false,
150 }]);
151 }
152
153 Ok(providers
154 .into_iter()
155 .map(|provider| ArgumentCompletion {
156 label: provider.to_string(),
157 new_text: provider.to_string(),
158 run_command: false,
159 })
160 .collect())
161 }
162 DocsSlashCommandArgs::SearchPackageDocs {
163 provider,
164 package,
165 index,
166 } => {
167 let store = store?;
168
169 if index {
170 // We don't need to hold onto this task, as the `IndexedDocsStore` will hold it
171 // until it completes.
172 let _ = store.clone().index(package.as_str().into());
173 }
174
175 let items = store.search(package).await;
176 Ok(build_completions(provider, items))
177 }
178 DocsSlashCommandArgs::SearchItemDocs {
179 provider,
180 item_path,
181 ..
182 } => {
183 let store = store?;
184 let items = store.search(item_path).await;
185 Ok(build_completions(provider, items))
186 }
187 }
188 })
189 }
190
191 fn run(
192 self: Arc<Self>,
193 argument: Option<&str>,
194 _workspace: WeakView<Workspace>,
195 _delegate: Arc<dyn LspAdapterDelegate>,
196 cx: &mut WindowContext,
197 ) -> Task<Result<SlashCommandOutput>> {
198 let Some(argument) = argument else {
199 return Task::ready(Err(anyhow!("missing argument")));
200 };
201
202 let args = DocsSlashCommandArgs::parse(argument);
203 let task = cx.background_executor().spawn({
204 let store = args
205 .provider()
206 .ok_or_else(|| anyhow!("no docs provider specified"))
207 .and_then(|provider| IndexedDocsStore::try_global(provider, cx));
208 async move {
209 let (provider, key) = match args {
210 DocsSlashCommandArgs::NoProvider => bail!("no docs provider specified"),
211 DocsSlashCommandArgs::SearchPackageDocs {
212 provider, package, ..
213 } => (provider, package),
214 DocsSlashCommandArgs::SearchItemDocs {
215 provider,
216 item_path,
217 ..
218 } => (provider, item_path),
219 };
220
221 let store = store?;
222 let (text, ranges) = if let Some((prefix, _)) = key.split_once('*') {
223 let docs = store.load_many_by_prefix(prefix.to_string()).await?;
224
225 let mut text = String::new();
226 let mut ranges = Vec::new();
227
228 for (key, docs) in docs {
229 let prev_len = text.len();
230
231 text.push_str(&docs.0);
232 text.push_str("\n");
233 ranges.push((key, prev_len..text.len()));
234 text.push_str("\n");
235 }
236
237 (text, ranges)
238 } else {
239 let item_docs = store.load(key.clone()).await?;
240 let text = item_docs.to_string();
241 let range = 0..text.len();
242
243 (text, vec![(key, range)])
244 };
245
246 anyhow::Ok((provider, text, ranges))
247 }
248 });
249
250 cx.foreground_executor().spawn(async move {
251 let (provider, text, ranges) = task.await?;
252 Ok(SlashCommandOutput {
253 text,
254 sections: ranges
255 .into_iter()
256 .map(|(key, range)| SlashCommandOutputSection {
257 range,
258 icon: IconName::FileDoc,
259 label: format!("docs ({provider}): {key}",).into(),
260 })
261 .collect(),
262 run_commands_in_text: false,
263 })
264 })
265 }
266}
267
268fn is_item_path_delimiter(char: char) -> bool {
269 !char.is_alphanumeric() && char != '-' && char != '_'
270}
271
272#[derive(Debug, PartialEq)]
273pub(crate) enum DocsSlashCommandArgs {
274 NoProvider,
275 SearchPackageDocs {
276 provider: ProviderId,
277 package: String,
278 index: bool,
279 },
280 SearchItemDocs {
281 provider: ProviderId,
282 package: String,
283 item_path: String,
284 },
285}
286
287impl DocsSlashCommandArgs {
288 pub fn parse(argument: &str) -> Self {
289 let Some((provider, argument)) = argument.split_once(' ') else {
290 return Self::NoProvider;
291 };
292
293 let provider = ProviderId(provider.into());
294
295 if let Some((package, rest)) = argument.split_once(is_item_path_delimiter) {
296 if rest.trim().is_empty() {
297 Self::SearchPackageDocs {
298 provider,
299 package: package.to_owned(),
300 index: true,
301 }
302 } else {
303 Self::SearchItemDocs {
304 provider,
305 package: package.to_owned(),
306 item_path: argument.to_owned(),
307 }
308 }
309 } else {
310 Self::SearchPackageDocs {
311 provider,
312 package: argument.to_owned(),
313 index: false,
314 }
315 }
316 }
317
318 pub fn provider(&self) -> Option<ProviderId> {
319 match self {
320 Self::NoProvider => None,
321 Self::SearchPackageDocs { provider, .. } | Self::SearchItemDocs { provider, .. } => {
322 Some(provider.clone())
323 }
324 }
325 }
326
327 pub fn package(&self) -> Option<PackageName> {
328 match self {
329 Self::NoProvider => None,
330 Self::SearchPackageDocs { package, .. } | Self::SearchItemDocs { package, .. } => {
331 Some(package.as_str().into())
332 }
333 }
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 #[test]
342 fn test_parse_docs_slash_command_args() {
343 assert_eq!(
344 DocsSlashCommandArgs::parse(""),
345 DocsSlashCommandArgs::NoProvider
346 );
347 assert_eq!(
348 DocsSlashCommandArgs::parse("rustdoc"),
349 DocsSlashCommandArgs::NoProvider
350 );
351
352 assert_eq!(
353 DocsSlashCommandArgs::parse("rustdoc "),
354 DocsSlashCommandArgs::SearchPackageDocs {
355 provider: ProviderId("rustdoc".into()),
356 package: "".into(),
357 index: false
358 }
359 );
360 assert_eq!(
361 DocsSlashCommandArgs::parse("gleam "),
362 DocsSlashCommandArgs::SearchPackageDocs {
363 provider: ProviderId("gleam".into()),
364 package: "".into(),
365 index: false
366 }
367 );
368
369 assert_eq!(
370 DocsSlashCommandArgs::parse("rustdoc gpui"),
371 DocsSlashCommandArgs::SearchPackageDocs {
372 provider: ProviderId("rustdoc".into()),
373 package: "gpui".into(),
374 index: false,
375 }
376 );
377 assert_eq!(
378 DocsSlashCommandArgs::parse("gleam gleam_stdlib"),
379 DocsSlashCommandArgs::SearchPackageDocs {
380 provider: ProviderId("gleam".into()),
381 package: "gleam_stdlib".into(),
382 index: false
383 }
384 );
385
386 // Adding an item path delimiter indicates we can start indexing.
387 assert_eq!(
388 DocsSlashCommandArgs::parse("rustdoc gpui:"),
389 DocsSlashCommandArgs::SearchPackageDocs {
390 provider: ProviderId("rustdoc".into()),
391 package: "gpui".into(),
392 index: true,
393 }
394 );
395 assert_eq!(
396 DocsSlashCommandArgs::parse("gleam gleam_stdlib/"),
397 DocsSlashCommandArgs::SearchPackageDocs {
398 provider: ProviderId("gleam".into()),
399 package: "gleam_stdlib".into(),
400 index: true
401 }
402 );
403
404 assert_eq!(
405 DocsSlashCommandArgs::parse("rustdoc gpui::foo::bar::Baz"),
406 DocsSlashCommandArgs::SearchItemDocs {
407 provider: ProviderId("rustdoc".into()),
408 package: "gpui".into(),
409 item_path: "gpui::foo::bar::Baz".into()
410 }
411 );
412 assert_eq!(
413 DocsSlashCommandArgs::parse("gleam gleam_stdlib/gleam/int"),
414 DocsSlashCommandArgs::SearchItemDocs {
415 provider: ProviderId("gleam".into()),
416 package: "gleam_stdlib".into(),
417 item_path: "gleam_stdlib/gleam/int".into()
418 }
419 );
420 }
421}