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