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}