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