From 08881828ce61bd5ad16ed80ac8674d2ef6460574 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 29 May 2024 18:14:29 -0400 Subject: [PATCH] assistant: Add `/rustdoc` slash command (#12453) This PR adds a `/rustdoc` slash command for retrieving and inserting rustdoc docs into the Assistant. Right now the command accepts the crate name as an argument and will return the top-level docs from `docs.rs`. Release Notes: - N/A --- Cargo.lock | 1 + crates/assistant/Cargo.toml | 1 + crates/assistant/src/assistant_panel.rs | 3 +- crates/assistant/src/slash_command.rs | 1 + .../src/slash_command/rustdoc_command.rs | 137 ++++++++++++++++++ crates/rustdoc_to_markdown/examples/test.rs | 2 +- .../src/markdown_writer.rs | 22 +-- .../src/rustdoc_to_markdown.rs | 6 +- 8 files changed, 152 insertions(+), 21 deletions(-) create mode 100644 crates/assistant/src/slash_command/rustdoc_command.rs diff --git a/Cargo.lock b/Cargo.lock index 809a7f59c0..f91ce42760 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -366,6 +366,7 @@ dependencies = [ "rand 0.8.5", "regex", "rope", + "rustdoc_to_markdown", "schemars", "search", "semantic_index", diff --git a/crates/assistant/Cargo.toml b/crates/assistant/Cargo.toml index 6e41f514f9..939fd68273 100644 --- a/crates/assistant/Cargo.toml +++ b/crates/assistant/Cargo.toml @@ -39,6 +39,7 @@ parking_lot.workspace = true project.workspace = true regex.workspace = true rope.workspace = true +rustdoc_to_markdown.workspace = true schemars.workspace = true search.workspace = true semantic_index.workspace = true diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index 0cb202ecc6..591f4d8b11 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -1,5 +1,5 @@ use crate::prompts::{generate_content_prompt, PromptLibrary, PromptManager}; -use crate::slash_command::{search_command, tabs_command}; +use crate::slash_command::{rustdoc_command, search_command, tabs_command}; use crate::{ assistant_settings::{AssistantDockPosition, AssistantSettings, ZedDotDevModel}, codegen::{self, Codegen, CodegenKind}, @@ -210,6 +210,7 @@ impl AssistantPanel { slash_command_registry.register_command(tabs_command::TabsSlashCommand); slash_command_registry.register_command(project_command::ProjectSlashCommand); slash_command_registry.register_command(search_command::SearchSlashCommand); + slash_command_registry.register_command(rustdoc_command::RustdocSlashCommand); Self { workspace: workspace_handle, diff --git a/crates/assistant/src/slash_command.rs b/crates/assistant/src/slash_command.rs index 5f975a8964..ce99ca141a 100644 --- a/crates/assistant/src/slash_command.rs +++ b/crates/assistant/src/slash_command.rs @@ -20,6 +20,7 @@ pub mod active_command; pub mod file_command; pub mod project_command; pub mod prompt_command; +pub mod rustdoc_command; pub mod search_command; pub mod tabs_command; diff --git a/crates/assistant/src/slash_command/rustdoc_command.rs b/crates/assistant/src/slash_command/rustdoc_command.rs new file mode 100644 index 0000000000..c8e03bcbe4 --- /dev/null +++ b/crates/assistant/src/slash_command/rustdoc_command.rs @@ -0,0 +1,137 @@ +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use anyhow::{anyhow, bail, Context, Result}; +use assistant_slash_command::{SlashCommand, SlashCommandOutput, SlashCommandOutputSection}; +use futures::AsyncReadExt; +use gpui::{AppContext, Task, WeakView}; +use http::{AsyncBody, HttpClient, HttpClientWithUrl}; +use language::LspAdapterDelegate; +use rustdoc_to_markdown::convert_rustdoc_to_markdown; +use ui::{prelude::*, ButtonLike, ElevationIndex}; +use workspace::Workspace; + +pub(crate) struct RustdocSlashCommand; + +impl RustdocSlashCommand { + async fn build_message( + http_client: Arc, + crate_name: String, + ) -> Result { + let mut response = http_client + .get( + &format!("https://docs.rs/{crate_name}"), + AsyncBody::default(), + true, + ) + .await?; + + let mut body = Vec::new(); + response + .body_mut() + .read_to_end(&mut body) + .await + .context("error reading docs.rs response body")?; + + if response.status().is_client_error() { + let text = String::from_utf8_lossy(body.as_slice()); + bail!( + "status error {}, response: {text:?}", + response.status().as_u16() + ); + } + + convert_rustdoc_to_markdown(&body[..]) + } +} + +impl SlashCommand for RustdocSlashCommand { + fn name(&self) -> String { + "rustdoc".into() + } + + fn description(&self) -> String { + "insert the docs for a Rust crate".into() + } + + fn tooltip_text(&self) -> String { + "insert rustdoc".into() + } + + fn requires_argument(&self) -> bool { + true + } + + fn complete_argument( + &self, + _query: String, + _cancel: Arc, + _workspace: WeakView, + _cx: &mut AppContext, + ) -> Task>> { + Task::ready(Ok(Vec::new())) + } + + fn run( + self: Arc, + argument: Option<&str>, + workspace: WeakView, + _delegate: Arc, + cx: &mut WindowContext, + ) -> Task> { + let Some(argument) = argument else { + return Task::ready(Err(anyhow!("missing crate name"))); + }; + let Some(workspace) = workspace.upgrade() else { + return Task::ready(Err(anyhow!("workspace was dropped"))); + }; + + let http_client = workspace.read(cx).client().http_client(); + let crate_name = argument.to_string(); + + let text = cx.background_executor().spawn({ + let crate_name = crate_name.clone(); + async move { Self::build_message(http_client, crate_name).await } + }); + + let crate_name = SharedString::from(crate_name); + cx.foreground_executor().spawn(async move { + let text = text.await?; + let range = 0..text.len(); + Ok(SlashCommandOutput { + text, + sections: vec![SlashCommandOutputSection { + range, + render_placeholder: Arc::new(move |id, unfold, _cx| { + RustdocPlaceholder { + id, + unfold, + crate_name: crate_name.clone(), + } + .into_any_element() + }), + }], + }) + }) + } +} + +#[derive(IntoElement)] +struct RustdocPlaceholder { + pub id: ElementId, + pub unfold: Arc, + pub crate_name: SharedString, +} + +impl RenderOnce for RustdocPlaceholder { + fn render(self, _cx: &mut WindowContext) -> impl IntoElement { + let unfold = self.unfold; + + ButtonLike::new(self.id) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::ElevatedSurface) + .child(Icon::new(IconName::FileRust)) + .child(Label::new(format!("rustdoc: {}", self.crate_name))) + .on_click(move |_, cx| unfold(cx)) + } +} diff --git a/crates/rustdoc_to_markdown/examples/test.rs b/crates/rustdoc_to_markdown/examples/test.rs index 3df85b2b1f..38a85874df 100644 --- a/crates/rustdoc_to_markdown/examples/test.rs +++ b/crates/rustdoc_to_markdown/examples/test.rs @@ -23,7 +23,7 @@ pub fn main() { // ``` // let html = include_str!("/path/to/zed/target/doc/gpui/index.html"); // ``` - let markdown = convert_rustdoc_to_markdown(html).unwrap(); + let markdown = convert_rustdoc_to_markdown(html.as_bytes()).unwrap(); println!("{markdown}"); } diff --git a/crates/rustdoc_to_markdown/src/markdown_writer.rs b/crates/rustdoc_to_markdown/src/markdown_writer.rs index 59aa7e1b37..4b69594bc8 100644 --- a/crates/rustdoc_to_markdown/src/markdown_writer.rs +++ b/crates/rustdoc_to_markdown/src/markdown_writer.rs @@ -36,12 +36,6 @@ impl MarkdownWriter { .any(|parent_element| parent_element.tag == tag) } - fn is_inside_heading(&self) -> bool { - ["h1", "h2", "h3", "h4", "h5", "h6"] - .into_iter() - .any(|heading| self.is_inside(heading)) - } - /// Appends the given string slice onto the end of the Markdown output. fn push_str(&mut self, str: &str) { self.markdown.push_str(str); @@ -135,16 +129,14 @@ impl MarkdownWriter { } } "div" | "span" => { - if tag.attrs.borrow().iter().any(|attr| { - attr.name.local.to_string() == "class" - && attr.value.to_string() == "sidebar-elems" - }) { - return StartTagOutcome::Skip; - } + let classes_to_skip = ["nav-container", "sidebar-elems", "out-of-band"]; if tag.attrs.borrow().iter().any(|attr| { attr.name.local.to_string() == "class" - && attr.value.to_string() == "out-of-band" + && attr + .value + .split(' ') + .any(|class| classes_to_skip.contains(&class.trim())) }) { return StartTagOutcome::Skip; } @@ -189,10 +181,6 @@ impl MarkdownWriter { return Ok(()); } - if self.is_inside_heading() && self.is_inside("a") { - return Ok(()); - } - let trimmed_text = text.trim_matches(|char| char == '\n' || char == '\r' || char == 'ยง'); self.push_str(trimmed_text); diff --git a/crates/rustdoc_to_markdown/src/rustdoc_to_markdown.rs b/crates/rustdoc_to_markdown/src/rustdoc_to_markdown.rs index d3afe2a264..dbbafa2c5c 100644 --- a/crates/rustdoc_to_markdown/src/rustdoc_to_markdown.rs +++ b/crates/rustdoc_to_markdown/src/rustdoc_to_markdown.rs @@ -4,6 +4,8 @@ mod markdown_writer; +use std::io::Read; + use anyhow::{Context, Result}; use html5ever::driver::ParseOpts; use html5ever::parse_document; @@ -14,7 +16,7 @@ use markup5ever_rcdom::RcDom; use crate::markdown_writer::MarkdownWriter; /// Converts the provided rustdoc HTML to Markdown. -pub fn convert_rustdoc_to_markdown(html: &str) -> Result { +pub fn convert_rustdoc_to_markdown(mut html: impl Read) -> Result { let parse_options = ParseOpts { tree_builder: TreeBuilderOpts { drop_doctype: true, @@ -24,7 +26,7 @@ pub fn convert_rustdoc_to_markdown(html: &str) -> Result { }; let dom = parse_document(RcDom::default(), parse_options) .from_utf8() - .read_from(&mut html.as_bytes()) + .read_from(&mut html) .context("failed to parse rustdoc HTML")?; let markdown_writer = MarkdownWriter::new();