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}