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 text = 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 match args {
210 DocsSlashCommandArgs::NoProvider => bail!("no docs provider specified"),
211 DocsSlashCommandArgs::SearchPackageDocs {
212 provider, package, ..
213 } => {
214 let store = store?;
215 let item_docs = store.load(package.clone()).await?;
216
217 anyhow::Ok((provider, package, item_docs.to_string()))
218 }
219 DocsSlashCommandArgs::SearchItemDocs {
220 provider,
221 item_path,
222 ..
223 } => {
224 let store = store?;
225 let item_docs = store.load(item_path.clone()).await?;
226
227 anyhow::Ok((provider, item_path, item_docs.to_string()))
228 }
229 }
230 }
231 });
232
233 cx.foreground_executor().spawn(async move {
234 let (provider, path, text) = text.await?;
235 let range = 0..text.len();
236 Ok(SlashCommandOutput {
237 text,
238 sections: vec![SlashCommandOutputSection {
239 range,
240 icon: IconName::FileDoc,
241 label: format!("docs ({provider}): {path}",).into(),
242 }],
243 run_commands_in_text: false,
244 })
245 })
246 }
247}
248
249fn is_item_path_delimiter(char: char) -> bool {
250 !char.is_alphanumeric() && char != '-' && char != '_'
251}
252
253#[derive(Debug, PartialEq)]
254pub(crate) enum DocsSlashCommandArgs {
255 NoProvider,
256 SearchPackageDocs {
257 provider: ProviderId,
258 package: String,
259 index: bool,
260 },
261 SearchItemDocs {
262 provider: ProviderId,
263 package: String,
264 item_path: String,
265 },
266}
267
268impl DocsSlashCommandArgs {
269 pub fn parse(argument: &str) -> Self {
270 let Some((provider, argument)) = argument.split_once(' ') else {
271 return Self::NoProvider;
272 };
273
274 let provider = ProviderId(provider.into());
275
276 if let Some((package, rest)) = argument.split_once(is_item_path_delimiter) {
277 if rest.trim().is_empty() {
278 Self::SearchPackageDocs {
279 provider,
280 package: package.to_owned(),
281 index: true,
282 }
283 } else {
284 Self::SearchItemDocs {
285 provider,
286 package: package.to_owned(),
287 item_path: argument.to_owned(),
288 }
289 }
290 } else {
291 Self::SearchPackageDocs {
292 provider,
293 package: argument.to_owned(),
294 index: false,
295 }
296 }
297 }
298
299 pub fn provider(&self) -> Option<ProviderId> {
300 match self {
301 Self::NoProvider => None,
302 Self::SearchPackageDocs { provider, .. } | Self::SearchItemDocs { provider, .. } => {
303 Some(provider.clone())
304 }
305 }
306 }
307
308 pub fn package(&self) -> Option<PackageName> {
309 match self {
310 Self::NoProvider => None,
311 Self::SearchPackageDocs { package, .. } | Self::SearchItemDocs { package, .. } => {
312 Some(package.as_str().into())
313 }
314 }
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn test_parse_docs_slash_command_args() {
324 assert_eq!(
325 DocsSlashCommandArgs::parse(""),
326 DocsSlashCommandArgs::NoProvider
327 );
328 assert_eq!(
329 DocsSlashCommandArgs::parse("rustdoc"),
330 DocsSlashCommandArgs::NoProvider
331 );
332
333 assert_eq!(
334 DocsSlashCommandArgs::parse("rustdoc "),
335 DocsSlashCommandArgs::SearchPackageDocs {
336 provider: ProviderId("rustdoc".into()),
337 package: "".into(),
338 index: false
339 }
340 );
341 assert_eq!(
342 DocsSlashCommandArgs::parse("gleam "),
343 DocsSlashCommandArgs::SearchPackageDocs {
344 provider: ProviderId("gleam".into()),
345 package: "".into(),
346 index: false
347 }
348 );
349
350 assert_eq!(
351 DocsSlashCommandArgs::parse("rustdoc gpui"),
352 DocsSlashCommandArgs::SearchPackageDocs {
353 provider: ProviderId("rustdoc".into()),
354 package: "gpui".into(),
355 index: false,
356 }
357 );
358 assert_eq!(
359 DocsSlashCommandArgs::parse("gleam gleam_stdlib"),
360 DocsSlashCommandArgs::SearchPackageDocs {
361 provider: ProviderId("gleam".into()),
362 package: "gleam_stdlib".into(),
363 index: false
364 }
365 );
366
367 // Adding an item path delimiter indicates we can start indexing.
368 assert_eq!(
369 DocsSlashCommandArgs::parse("rustdoc gpui:"),
370 DocsSlashCommandArgs::SearchPackageDocs {
371 provider: ProviderId("rustdoc".into()),
372 package: "gpui".into(),
373 index: true,
374 }
375 );
376 assert_eq!(
377 DocsSlashCommandArgs::parse("gleam gleam_stdlib/"),
378 DocsSlashCommandArgs::SearchPackageDocs {
379 provider: ProviderId("gleam".into()),
380 package: "gleam_stdlib".into(),
381 index: true
382 }
383 );
384
385 assert_eq!(
386 DocsSlashCommandArgs::parse("rustdoc gpui::foo::bar::Baz"),
387 DocsSlashCommandArgs::SearchItemDocs {
388 provider: ProviderId("rustdoc".into()),
389 package: "gpui".into(),
390 item_path: "gpui::foo::bar::Baz".into()
391 }
392 );
393 assert_eq!(
394 DocsSlashCommandArgs::parse("gleam gleam_stdlib/gleam/int"),
395 DocsSlashCommandArgs::SearchItemDocs {
396 provider: ProviderId("gleam".into()),
397 package: "gleam_stdlib".into(),
398 item_path: "gleam_stdlib/gleam/int".into()
399 }
400 );
401 }
402}