docs_command.rs

  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}