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