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 fn build_completions(
108 provider: ProviderId,
109 items: Vec<String>,
110 ) -> Vec<ArgumentCompletion> {
111 items
112 .into_iter()
113 .map(|item| ArgumentCompletion {
114 label: item.clone(),
115 new_text: format!("{provider} {item}"),
116 run_command: true,
117 })
118 .collect()
119 }
120
121 match args {
122 DocsSlashCommandArgs::NoProvider => {
123 let providers = indexed_docs_registry.list_providers();
124 if providers.is_empty() {
125 return Ok(vec![ArgumentCompletion {
126 label: "No available docs providers.".to_string(),
127 new_text: String::new(),
128 run_command: false,
129 }]);
130 }
131
132 Ok(providers
133 .into_iter()
134 .map(|provider| ArgumentCompletion {
135 label: provider.to_string(),
136 new_text: provider.to_string(),
137 run_command: false,
138 })
139 .collect())
140 }
141 DocsSlashCommandArgs::SearchPackageDocs {
142 provider,
143 package,
144 index,
145 } => {
146 let store = store?;
147
148 if index {
149 // We don't need to hold onto this task, as the `IndexedDocsStore` will hold it
150 // until it completes.
151 let _ = store.clone().index(package.as_str().into());
152 }
153
154 let items = store.search(package).await;
155 Ok(build_completions(provider, items))
156 }
157 DocsSlashCommandArgs::SearchItemDocs {
158 provider,
159 item_path,
160 ..
161 } => {
162 let store = store?;
163 let items = store.search(item_path).await;
164 Ok(build_completions(provider, items))
165 }
166 }
167 })
168 }
169
170 fn run(
171 self: Arc<Self>,
172 argument: Option<&str>,
173 _workspace: WeakView<Workspace>,
174 _delegate: Arc<dyn LspAdapterDelegate>,
175 cx: &mut WindowContext,
176 ) -> Task<Result<SlashCommandOutput>> {
177 let Some(argument) = argument else {
178 return Task::ready(Err(anyhow!("missing argument")));
179 };
180
181 let args = DocsSlashCommandArgs::parse(argument);
182 let text = cx.background_executor().spawn({
183 let store = args
184 .provider()
185 .ok_or_else(|| anyhow!("no docs provider specified"))
186 .and_then(|provider| IndexedDocsStore::try_global(provider, cx));
187 async move {
188 match args {
189 DocsSlashCommandArgs::NoProvider => bail!("no docs provider specified"),
190 DocsSlashCommandArgs::SearchPackageDocs {
191 provider, package, ..
192 } => {
193 let store = store?;
194 let item_docs = store.load(package.clone()).await?;
195
196 anyhow::Ok((provider, package, item_docs.to_string()))
197 }
198 DocsSlashCommandArgs::SearchItemDocs {
199 provider,
200 item_path,
201 ..
202 } => {
203 let store = store?;
204 let item_docs = store.load(item_path.clone()).await?;
205
206 anyhow::Ok((provider, item_path, item_docs.to_string()))
207 }
208 }
209 }
210 });
211
212 cx.foreground_executor().spawn(async move {
213 let (provider, path, text) = text.await?;
214 let range = 0..text.len();
215 Ok(SlashCommandOutput {
216 text,
217 sections: vec![SlashCommandOutputSection {
218 range,
219 icon: IconName::FileDoc,
220 label: format!("docs ({provider}): {path}",).into(),
221 }],
222 run_commands_in_text: false,
223 })
224 })
225 }
226}
227
228fn is_item_path_delimiter(char: char) -> bool {
229 !char.is_alphanumeric() && char != '-' && char != '_'
230}
231
232#[derive(Debug, PartialEq)]
233pub(crate) enum DocsSlashCommandArgs {
234 NoProvider,
235 SearchPackageDocs {
236 provider: ProviderId,
237 package: String,
238 index: bool,
239 },
240 SearchItemDocs {
241 provider: ProviderId,
242 package: String,
243 item_path: String,
244 },
245}
246
247impl DocsSlashCommandArgs {
248 pub fn parse(argument: &str) -> Self {
249 let Some((provider, argument)) = argument.split_once(' ') else {
250 return Self::NoProvider;
251 };
252
253 let provider = ProviderId(provider.into());
254
255 if let Some((package, rest)) = argument.split_once(is_item_path_delimiter) {
256 if rest.trim().is_empty() {
257 Self::SearchPackageDocs {
258 provider,
259 package: package.to_owned(),
260 index: true,
261 }
262 } else {
263 Self::SearchItemDocs {
264 provider,
265 package: package.to_owned(),
266 item_path: argument.to_owned(),
267 }
268 }
269 } else {
270 Self::SearchPackageDocs {
271 provider,
272 package: argument.to_owned(),
273 index: false,
274 }
275 }
276 }
277
278 pub fn provider(&self) -> Option<ProviderId> {
279 match self {
280 Self::NoProvider => None,
281 Self::SearchPackageDocs { provider, .. } | Self::SearchItemDocs { provider, .. } => {
282 Some(provider.clone())
283 }
284 }
285 }
286
287 pub fn package(&self) -> Option<PackageName> {
288 match self {
289 Self::NoProvider => None,
290 Self::SearchPackageDocs { package, .. } | Self::SearchItemDocs { package, .. } => {
291 Some(package.as_str().into())
292 }
293 }
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn test_parse_docs_slash_command_args() {
303 assert_eq!(
304 DocsSlashCommandArgs::parse(""),
305 DocsSlashCommandArgs::NoProvider
306 );
307 assert_eq!(
308 DocsSlashCommandArgs::parse("rustdoc"),
309 DocsSlashCommandArgs::NoProvider
310 );
311
312 assert_eq!(
313 DocsSlashCommandArgs::parse("rustdoc "),
314 DocsSlashCommandArgs::SearchPackageDocs {
315 provider: ProviderId("rustdoc".into()),
316 package: "".into(),
317 index: false
318 }
319 );
320 assert_eq!(
321 DocsSlashCommandArgs::parse("gleam "),
322 DocsSlashCommandArgs::SearchPackageDocs {
323 provider: ProviderId("gleam".into()),
324 package: "".into(),
325 index: false
326 }
327 );
328
329 assert_eq!(
330 DocsSlashCommandArgs::parse("rustdoc gpui"),
331 DocsSlashCommandArgs::SearchPackageDocs {
332 provider: ProviderId("rustdoc".into()),
333 package: "gpui".into(),
334 index: false,
335 }
336 );
337 assert_eq!(
338 DocsSlashCommandArgs::parse("gleam gleam_stdlib"),
339 DocsSlashCommandArgs::SearchPackageDocs {
340 provider: ProviderId("gleam".into()),
341 package: "gleam_stdlib".into(),
342 index: false
343 }
344 );
345
346 // Adding an item path delimiter indicates we can start indexing.
347 assert_eq!(
348 DocsSlashCommandArgs::parse("rustdoc gpui:"),
349 DocsSlashCommandArgs::SearchPackageDocs {
350 provider: ProviderId("rustdoc".into()),
351 package: "gpui".into(),
352 index: true,
353 }
354 );
355 assert_eq!(
356 DocsSlashCommandArgs::parse("gleam gleam_stdlib/"),
357 DocsSlashCommandArgs::SearchPackageDocs {
358 provider: ProviderId("gleam".into()),
359 package: "gleam_stdlib".into(),
360 index: true
361 }
362 );
363
364 assert_eq!(
365 DocsSlashCommandArgs::parse("rustdoc gpui::foo::bar::Baz"),
366 DocsSlashCommandArgs::SearchItemDocs {
367 provider: ProviderId("rustdoc".into()),
368 package: "gpui".into(),
369 item_path: "gpui::foo::bar::Baz".into()
370 }
371 );
372 assert_eq!(
373 DocsSlashCommandArgs::parse("gleam gleam_stdlib/gleam/int"),
374 DocsSlashCommandArgs::SearchItemDocs {
375 provider: ProviderId("gleam".into()),
376 package: "gleam_stdlib".into(),
377 item_path: "gleam_stdlib/gleam/int".into()
378 }
379 );
380 }
381}