rustdoc_command.rs

  1use std::path::Path;
  2use std::sync::atomic::AtomicBool;
  3use std::sync::Arc;
  4
  5use anyhow::{anyhow, bail, Context, Result};
  6use assistant_slash_command::{SlashCommand, SlashCommandOutput, SlashCommandOutputSection};
  7use fs::Fs;
  8use futures::AsyncReadExt;
  9use gpui::{AppContext, Model, Task, WeakView};
 10use http::{AsyncBody, HttpClient, HttpClientWithUrl};
 11use indexed_docs::{
 12    convert_rustdoc_to_markdown, IndexedDocsRegistry, IndexedDocsStore, LocalProvider, PackageName,
 13    ProviderId, RustdocIndexer, RustdocSource,
 14};
 15use language::LspAdapterDelegate;
 16use project::{Project, ProjectPath};
 17use ui::prelude::*;
 18use util::{maybe, ResultExt};
 19use workspace::Workspace;
 20
 21pub(crate) struct RustdocSlashCommand;
 22
 23impl RustdocSlashCommand {
 24    async fn build_message(
 25        fs: Arc<dyn Fs>,
 26        http_client: Arc<HttpClientWithUrl>,
 27        crate_name: PackageName,
 28        module_path: Vec<String>,
 29        path_to_cargo_toml: Option<&Path>,
 30    ) -> Result<(RustdocSource, String)> {
 31        let cargo_workspace_root = path_to_cargo_toml.and_then(|path| path.parent());
 32        if let Some(cargo_workspace_root) = cargo_workspace_root {
 33            let mut local_cargo_doc_path = cargo_workspace_root.join("target/doc");
 34            local_cargo_doc_path.push(crate_name.as_ref());
 35            if !module_path.is_empty() {
 36                local_cargo_doc_path.push(module_path.join("/"));
 37            }
 38            local_cargo_doc_path.push("index.html");
 39
 40            if let Ok(contents) = fs.load(&local_cargo_doc_path).await {
 41                let (markdown, _items) = convert_rustdoc_to_markdown(contents.as_bytes())?;
 42
 43                return Ok((RustdocSource::Local, markdown));
 44            }
 45        }
 46
 47        let version = "latest";
 48        let path = format!(
 49            "{crate_name}/{version}/{crate_name}/{module_path}",
 50            module_path = module_path.join("/")
 51        );
 52
 53        let mut response = http_client
 54            .get(
 55                &format!("https://docs.rs/{path}"),
 56                AsyncBody::default(),
 57                true,
 58            )
 59            .await?;
 60
 61        let mut body = Vec::new();
 62        response
 63            .body_mut()
 64            .read_to_end(&mut body)
 65            .await
 66            .context("error reading docs.rs response body")?;
 67
 68        if response.status().is_client_error() {
 69            let text = String::from_utf8_lossy(body.as_slice());
 70            bail!(
 71                "status error {}, response: {text:?}",
 72                response.status().as_u16()
 73            );
 74        }
 75
 76        let (markdown, _items) = convert_rustdoc_to_markdown(&body[..])?;
 77
 78        Ok((RustdocSource::DocsDotRs, markdown))
 79    }
 80
 81    fn path_to_cargo_toml(project: Model<Project>, cx: &mut AppContext) -> Option<Arc<Path>> {
 82        let worktree = project.read(cx).worktrees().next()?;
 83        let worktree = worktree.read(cx);
 84        let entry = worktree.entry_for_path("Cargo.toml")?;
 85        let path = ProjectPath {
 86            worktree_id: worktree.id(),
 87            path: entry.path.clone(),
 88        };
 89        Some(Arc::from(
 90            project.read(cx).absolute_path(&path, cx)?.as_path(),
 91        ))
 92    }
 93
 94    /// Ensures that the rustdoc provider is registered.
 95    ///
 96    /// Ideally we would do this sooner, but we need to wait until we're able to
 97    /// access the workspace so we can read the project.
 98    fn ensure_rustdoc_provider_is_registered(
 99        &self,
100        workspace: Option<WeakView<Workspace>>,
101        cx: &mut AppContext,
102    ) {
103        let indexed_docs_registry = IndexedDocsRegistry::global(cx);
104        if indexed_docs_registry
105            .get_provider_store(ProviderId::rustdoc())
106            .is_none()
107        {
108            let index_provider_deps = maybe!({
109                let workspace = workspace.ok_or_else(|| anyhow!("no workspace"))?;
110                let workspace = workspace
111                    .upgrade()
112                    .ok_or_else(|| anyhow!("workspace was dropped"))?;
113                let project = workspace.read(cx).project().clone();
114                let fs = project.read(cx).fs().clone();
115                let cargo_workspace_root = Self::path_to_cargo_toml(project, cx)
116                    .and_then(|path| path.parent().map(|path| path.to_path_buf()))
117                    .ok_or_else(|| anyhow!("no Cargo workspace root found"))?;
118
119                anyhow::Ok((fs, cargo_workspace_root))
120            });
121
122            if let Some((fs, cargo_workspace_root)) = index_provider_deps.log_err() {
123                indexed_docs_registry.register_provider(Box::new(RustdocIndexer::new(Box::new(
124                    LocalProvider::new(fs, cargo_workspace_root),
125                ))));
126            }
127        }
128    }
129}
130
131impl SlashCommand for RustdocSlashCommand {
132    fn name(&self) -> String {
133        "rustdoc".into()
134    }
135
136    fn description(&self) -> String {
137        "insert Rust docs".into()
138    }
139
140    fn menu_text(&self) -> String {
141        "Insert Rust Documentation".into()
142    }
143
144    fn requires_argument(&self) -> bool {
145        true
146    }
147
148    fn complete_argument(
149        self: Arc<Self>,
150        query: String,
151        _cancel: Arc<AtomicBool>,
152        workspace: Option<WeakView<Workspace>>,
153        cx: &mut AppContext,
154    ) -> Task<Result<Vec<String>>> {
155        self.ensure_rustdoc_provider_is_registered(workspace, cx);
156
157        let store = IndexedDocsStore::try_global(ProviderId::rustdoc(), cx);
158        cx.background_executor().spawn(async move {
159            let store = store?;
160
161            if let Some((crate_name, rest)) = query.split_once(':') {
162                if rest.is_empty() {
163                    // We don't need to hold onto this task, as the `IndexedDocsStore` will hold it
164                    // until it completes.
165                    let _ = store.clone().index(crate_name.into());
166                }
167            }
168
169            let items = store.search(query).await;
170            Ok(items)
171        })
172    }
173
174    fn run(
175        self: Arc<Self>,
176        argument: Option<&str>,
177        workspace: WeakView<Workspace>,
178        _delegate: Arc<dyn LspAdapterDelegate>,
179        cx: &mut WindowContext,
180    ) -> Task<Result<SlashCommandOutput>> {
181        let Some(argument) = argument else {
182            return Task::ready(Err(anyhow!("missing crate name")));
183        };
184        let Some(workspace) = workspace.upgrade() else {
185            return Task::ready(Err(anyhow!("workspace was dropped")));
186        };
187
188        let project = workspace.read(cx).project().clone();
189        let fs = project.read(cx).fs().clone();
190        let http_client = workspace.read(cx).client().http_client();
191        let path_to_cargo_toml = Self::path_to_cargo_toml(project, cx);
192
193        let mut path_components = argument.split("::");
194        let crate_name = match path_components
195            .next()
196            .ok_or_else(|| anyhow!("missing crate name"))
197        {
198            Ok(crate_name) => PackageName::from(crate_name),
199            Err(err) => return Task::ready(Err(err)),
200        };
201        let item_path = path_components.map(ToString::to_string).collect::<Vec<_>>();
202
203        let text = cx.background_executor().spawn({
204            let rustdoc_store = IndexedDocsStore::try_global(ProviderId::rustdoc(), cx);
205            let crate_name = crate_name.clone();
206            let item_path = item_path.clone();
207            async move {
208                let rustdoc_store = rustdoc_store?;
209                let item_docs = rustdoc_store
210                    .load(
211                        crate_name.clone(),
212                        if item_path.is_empty() {
213                            None
214                        } else {
215                            Some(item_path.join("::"))
216                        },
217                    )
218                    .await;
219
220                if let Ok(item_docs) = item_docs {
221                    anyhow::Ok((RustdocSource::Index, item_docs.to_string()))
222                } else {
223                    Self::build_message(
224                        fs,
225                        http_client,
226                        crate_name,
227                        item_path,
228                        path_to_cargo_toml.as_deref(),
229                    )
230                    .await
231                }
232            }
233        });
234
235        let module_path = if item_path.is_empty() {
236            None
237        } else {
238            Some(SharedString::from(item_path.join("::")))
239        };
240        cx.foreground_executor().spawn(async move {
241            let (source, text) = text.await?;
242            let range = 0..text.len();
243            let crate_path = module_path
244                .map(|module_path| format!("{}::{}", crate_name, module_path))
245                .unwrap_or_else(|| crate_name.to_string());
246            Ok(SlashCommandOutput {
247                text,
248                sections: vec![SlashCommandOutputSection {
249                    range,
250                    icon: IconName::FileRust,
251                    label: format!(
252                        "rustdoc ({source}): {crate_path}",
253                        source = match source {
254                            RustdocSource::Index => "index",
255                            RustdocSource::Local => "local",
256                            RustdocSource::DocsDotRs => "docs.rs",
257                        }
258                    )
259                    .into(),
260                }],
261                run_commands_in_text: false,
262            })
263        })
264    }
265}