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