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_to_markdown::convert_rustdoc_to_markdown;
14use ui::{prelude::*, ButtonLike, ElevationIndex};
15use workspace::Workspace;
16
17#[derive(Debug, Clone, Copy)]
18enum RustdocSource {
19 /// The docs were sourced from local `cargo doc` output.
20 Local,
21 /// The docs were sourced from `docs.rs`.
22 DocsDotRs,
23}
24
25pub(crate) struct RustdocSlashCommand;
26
27impl RustdocSlashCommand {
28 async fn build_message(
29 fs: Arc<dyn Fs>,
30 http_client: Arc<HttpClientWithUrl>,
31 crate_name: String,
32 module_path: Vec<String>,
33 path_to_cargo_toml: Option<&Path>,
34 ) -> Result<(RustdocSource, String)> {
35 let cargo_workspace_root = path_to_cargo_toml.and_then(|path| path.parent());
36 if let Some(cargo_workspace_root) = cargo_workspace_root {
37 let mut local_cargo_doc_path = cargo_workspace_root.join("target/doc");
38 local_cargo_doc_path.push(&crate_name);
39 if !module_path.is_empty() {
40 local_cargo_doc_path.push(module_path.join("/"));
41 }
42 local_cargo_doc_path.push("index.html");
43
44 if let Ok(contents) = fs.load(&local_cargo_doc_path).await {
45 return Ok((
46 RustdocSource::Local,
47 convert_rustdoc_to_markdown(contents.as_bytes())?,
48 ));
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 Ok((
82 RustdocSource::DocsDotRs,
83 convert_rustdoc_to_markdown(&body[..])?,
84 ))
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: WeakView<Workspace>,
123 _cx: &mut AppContext,
124 ) -> Task<Result<Vec<String>>> {
125 Task::ready(Ok(Vec::new()))
126 }
127
128 fn run(
129 self: Arc<Self>,
130 argument: Option<&str>,
131 workspace: WeakView<Workspace>,
132 _delegate: Arc<dyn LspAdapterDelegate>,
133 cx: &mut WindowContext,
134 ) -> Task<Result<SlashCommandOutput>> {
135 let Some(argument) = argument else {
136 return Task::ready(Err(anyhow!("missing crate name")));
137 };
138 let Some(workspace) = workspace.upgrade() else {
139 return Task::ready(Err(anyhow!("workspace was dropped")));
140 };
141
142 let project = workspace.read(cx).project().clone();
143 let fs = project.read(cx).fs().clone();
144 let http_client = workspace.read(cx).client().http_client();
145 let mut path_components = argument.split("::");
146 let crate_name = match path_components
147 .next()
148 .ok_or_else(|| anyhow!("missing crate name"))
149 {
150 Ok(crate_name) => crate_name.to_string(),
151 Err(err) => return Task::ready(Err(err)),
152 };
153 let module_path = path_components.map(ToString::to_string).collect::<Vec<_>>();
154 let path_to_cargo_toml = Self::path_to_cargo_toml(project, cx);
155
156 let text = cx.background_executor().spawn({
157 let crate_name = crate_name.clone();
158 let module_path = module_path.clone();
159 async move {
160 Self::build_message(
161 fs,
162 http_client,
163 crate_name,
164 module_path,
165 path_to_cargo_toml.as_deref(),
166 )
167 .await
168 }
169 });
170
171 let crate_name = SharedString::from(crate_name);
172 let module_path = if module_path.is_empty() {
173 None
174 } else {
175 Some(SharedString::from(module_path.join("::")))
176 };
177 cx.foreground_executor().spawn(async move {
178 let (source, text) = text.await?;
179 let range = 0..text.len();
180 Ok(SlashCommandOutput {
181 text,
182 sections: vec![SlashCommandOutputSection {
183 range,
184 render_placeholder: Arc::new(move |id, unfold, _cx| {
185 RustdocPlaceholder {
186 id,
187 unfold,
188 source,
189 crate_name: crate_name.clone(),
190 module_path: module_path.clone(),
191 }
192 .into_any_element()
193 }),
194 }],
195 run_commands_in_text: false,
196 })
197 })
198 }
199}
200
201#[derive(IntoElement)]
202struct RustdocPlaceholder {
203 pub id: ElementId,
204 pub unfold: Arc<dyn Fn(&mut WindowContext)>,
205 pub source: RustdocSource,
206 pub crate_name: SharedString,
207 pub module_path: Option<SharedString>,
208}
209
210impl RenderOnce for RustdocPlaceholder {
211 fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
212 let unfold = self.unfold;
213
214 let crate_path = self
215 .module_path
216 .map(|module_path| format!("{crate_name}::{module_path}", crate_name = self.crate_name))
217 .unwrap_or(self.crate_name.to_string());
218
219 ButtonLike::new(self.id)
220 .style(ButtonStyle::Filled)
221 .layer(ElevationIndex::ElevatedSurface)
222 .child(Icon::new(IconName::FileRust))
223 .child(Label::new(format!(
224 "rustdoc ({source}): {crate_path}",
225 source = match self.source {
226 RustdocSource::Local => "local",
227 RustdocSource::DocsDotRs => "docs.rs",
228 }
229 )))
230 .on_click(move |_, cx| unfold(cx))
231 }
232}