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