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