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        })
129    }
130
131    fn run(
132        self: Arc<Self>,
133        argument: Option<&str>,
134        workspace: WeakView<Workspace>,
135        _delegate: Arc<dyn LspAdapterDelegate>,
136        cx: &mut WindowContext,
137    ) -> Task<Result<SlashCommandOutput>> {
138        let Some(argument) = argument else {
139            return Task::ready(Err(anyhow!("missing crate name")));
140        };
141        let Some(workspace) = workspace.upgrade() else {
142            return Task::ready(Err(anyhow!("workspace was dropped")));
143        };
144
145        let project = workspace.read(cx).project().clone();
146        let fs = project.read(cx).fs().clone();
147        let http_client = workspace.read(cx).client().http_client();
148        let path_to_cargo_toml = Self::path_to_cargo_toml(project, cx);
149
150        let mut item_path = String::new();
151        let mut crate_name_to_index = None;
152
153        let mut args = argument.split(' ').map(|word| word.trim());
154        while let Some(arg) = args.next() {
155            if arg == "--index" {
156                let Some(crate_name) = args.next() else {
157                    return Task::ready(Err(anyhow!("no crate name provided to --index")));
158                };
159                crate_name_to_index = Some(crate_name.to_string());
160                continue;
161            }
162
163            item_path.push_str(arg);
164        }
165
166        if let Some(crate_name_to_index) = crate_name_to_index {
167            let index_task = cx.background_executor().spawn({
168                let rustdoc_store = RustdocStore::global(cx);
169                let fs = fs.clone();
170                let crate_name_to_index = crate_name_to_index.clone();
171                async move {
172                    let cargo_workspace_root = path_to_cargo_toml
173                        .and_then(|path| path.parent().map(|path| path.to_path_buf()))
174                        .ok_or_else(|| anyhow!("no Cargo workspace root found"))?;
175
176                    let provider = Box::new(LocalProvider::new(fs, cargo_workspace_root));
177
178                    rustdoc_store
179                        .index(crate_name_to_index.clone(), provider)
180                        .await?;
181
182                    anyhow::Ok(format!("Indexed {crate_name_to_index}"))
183                }
184            });
185
186            return cx.foreground_executor().spawn(async move {
187                let text = index_task.await?;
188                let range = 0..text.len();
189                Ok(SlashCommandOutput {
190                    text,
191                    sections: vec![SlashCommandOutputSection {
192                        range,
193                        render_placeholder: Arc::new(move |id, unfold, _cx| {
194                            RustdocIndexPlaceholder {
195                                id,
196                                unfold,
197                                source: RustdocSource::Local,
198                                crate_name: SharedString::from(crate_name_to_index.clone()),
199                            }
200                            .into_any_element()
201                        }),
202                    }],
203                    run_commands_in_text: false,
204                })
205            });
206        }
207
208        let mut path_components = item_path.split("::");
209        let crate_name = match path_components
210            .next()
211            .ok_or_else(|| anyhow!("missing crate name"))
212        {
213            Ok(crate_name) => crate_name.to_string(),
214            Err(err) => return Task::ready(Err(err)),
215        };
216        let item_path = path_components.map(ToString::to_string).collect::<Vec<_>>();
217
218        let text = cx.background_executor().spawn({
219            let rustdoc_store = RustdocStore::global(cx);
220            let crate_name = crate_name.clone();
221            let item_path = item_path.clone();
222            async move {
223                let item_docs = rustdoc_store
224                    .load(crate_name.clone(), Some(item_path.join("::")))
225                    .await;
226
227                if let Ok(item_docs) = item_docs {
228                    anyhow::Ok((RustdocSource::Local, item_docs.docs().to_owned()))
229                } else {
230                    Self::build_message(
231                        fs,
232                        http_client,
233                        crate_name,
234                        item_path,
235                        path_to_cargo_toml.as_deref(),
236                    )
237                    .await
238                }
239            }
240        });
241
242        let crate_name = SharedString::from(crate_name);
243        let module_path = if item_path.is_empty() {
244            None
245        } else {
246            Some(SharedString::from(item_path.join("::")))
247        };
248        cx.foreground_executor().spawn(async move {
249            let (source, text) = text.await?;
250            let range = 0..text.len();
251            Ok(SlashCommandOutput {
252                text,
253                sections: vec![SlashCommandOutputSection {
254                    range,
255                    render_placeholder: Arc::new(move |id, unfold, _cx| {
256                        RustdocPlaceholder {
257                            id,
258                            unfold,
259                            source,
260                            crate_name: crate_name.clone(),
261                            module_path: module_path.clone(),
262                        }
263                        .into_any_element()
264                    }),
265                }],
266                run_commands_in_text: false,
267            })
268        })
269    }
270}
271
272#[derive(IntoElement)]
273struct RustdocPlaceholder {
274    pub id: ElementId,
275    pub unfold: Arc<dyn Fn(&mut WindowContext)>,
276    pub source: RustdocSource,
277    pub crate_name: SharedString,
278    pub module_path: Option<SharedString>,
279}
280
281impl RenderOnce for RustdocPlaceholder {
282    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
283        let unfold = self.unfold;
284
285        let crate_path = self
286            .module_path
287            .map(|module_path| format!("{crate_name}::{module_path}", crate_name = self.crate_name))
288            .unwrap_or(self.crate_name.to_string());
289
290        ButtonLike::new(self.id)
291            .style(ButtonStyle::Filled)
292            .layer(ElevationIndex::ElevatedSurface)
293            .child(Icon::new(IconName::FileRust))
294            .child(Label::new(format!(
295                "rustdoc ({source}): {crate_path}",
296                source = match self.source {
297                    RustdocSource::Local => "local",
298                    RustdocSource::DocsDotRs => "docs.rs",
299                }
300            )))
301            .on_click(move |_, cx| unfold(cx))
302    }
303}
304
305#[derive(IntoElement)]
306struct RustdocIndexPlaceholder {
307    pub id: ElementId,
308    pub unfold: Arc<dyn Fn(&mut WindowContext)>,
309    pub source: RustdocSource,
310    pub crate_name: SharedString,
311}
312
313impl RenderOnce for RustdocIndexPlaceholder {
314    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
315        let unfold = self.unfold;
316
317        ButtonLike::new(self.id)
318            .style(ButtonStyle::Filled)
319            .layer(ElevationIndex::ElevatedSurface)
320            .child(Icon::new(IconName::FileRust))
321            .child(Label::new(format!(
322                "rustdoc index ({source}): {crate_name}",
323                crate_name = self.crate_name,
324                source = match self.source {
325                    RustdocSource::Local => "local",
326                    RustdocSource::DocsDotRs => "docs.rs",
327                }
328            )))
329            .on_click(move |_, cx| unfold(cx))
330    }
331}