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::{convert_rustdoc_to_markdown, RustdocStore};
14use rustdoc::{CrateName, LocalProvider};
15use ui::{prelude::*, ButtonLike, ElevationIndex};
16use util::{maybe, ResultExt};
17use workspace::Workspace;
18
19#[derive(Debug, Clone, Copy)]
20enum RustdocSource {
21 /// The docs were sourced from local `cargo doc` output.
22 Local,
23 /// The docs were sourced from `docs.rs`.
24 DocsDotRs,
25}
26
27pub(crate) struct RustdocSlashCommand;
28
29impl RustdocSlashCommand {
30 async fn build_message(
31 fs: Arc<dyn Fs>,
32 http_client: Arc<HttpClientWithUrl>,
33 crate_name: CrateName,
34 module_path: Vec<String>,
35 path_to_cargo_toml: Option<&Path>,
36 ) -> Result<(RustdocSource, String)> {
37 let cargo_workspace_root = path_to_cargo_toml.and_then(|path| path.parent());
38 if let Some(cargo_workspace_root) = cargo_workspace_root {
39 let mut local_cargo_doc_path = cargo_workspace_root.join("target/doc");
40 local_cargo_doc_path.push(crate_name.as_ref());
41 if !module_path.is_empty() {
42 local_cargo_doc_path.push(module_path.join("/"));
43 }
44 local_cargo_doc_path.push("index.html");
45
46 if let Ok(contents) = fs.load(&local_cargo_doc_path).await {
47 let (markdown, _items) = convert_rustdoc_to_markdown(contents.as_bytes())?;
48
49 return Ok((RustdocSource::Local, markdown));
50 }
51 }
52
53 let version = "latest";
54 let path = format!(
55 "{crate_name}/{version}/{crate_name}/{module_path}",
56 module_path = module_path.join("/")
57 );
58
59 let mut response = http_client
60 .get(
61 &format!("https://docs.rs/{path}"),
62 AsyncBody::default(),
63 true,
64 )
65 .await?;
66
67 let mut body = Vec::new();
68 response
69 .body_mut()
70 .read_to_end(&mut body)
71 .await
72 .context("error reading docs.rs response body")?;
73
74 if response.status().is_client_error() {
75 let text = String::from_utf8_lossy(body.as_slice());
76 bail!(
77 "status error {}, response: {text:?}",
78 response.status().as_u16()
79 );
80 }
81
82 let (markdown, _items) = convert_rustdoc_to_markdown(&body[..])?;
83
84 Ok((RustdocSource::DocsDotRs, markdown))
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: Option<WeakView<Workspace>>,
123 cx: &mut AppContext,
124 ) -> Task<Result<Vec<String>>> {
125 let index_provider_deps = maybe!({
126 let workspace = workspace.ok_or_else(|| anyhow!("no workspace"))?;
127 let workspace = workspace
128 .upgrade()
129 .ok_or_else(|| anyhow!("workspace was dropped"))?;
130 let project = workspace.read(cx).project().clone();
131 let fs = project.read(cx).fs().clone();
132 let cargo_workspace_root = Self::path_to_cargo_toml(project, cx)
133 .and_then(|path| path.parent().map(|path| path.to_path_buf()))
134 .ok_or_else(|| anyhow!("no Cargo workspace root found"))?;
135
136 anyhow::Ok((fs, cargo_workspace_root))
137 });
138
139 let store = RustdocStore::global(cx);
140 cx.background_executor().spawn(async move {
141 if let Some((crate_name, rest)) = query.split_once(':') {
142 if rest.is_empty() {
143 if let Some((fs, cargo_workspace_root)) = index_provider_deps.log_err() {
144 let provider = Box::new(LocalProvider::new(fs, cargo_workspace_root));
145 // We don't need to hold onto this task, as the `RustdocStore` will hold it
146 // until it completes.
147 let _ = store.clone().index(crate_name.into(), provider);
148 }
149 }
150 }
151
152 let items = store.search(query).await;
153 Ok(items)
154 })
155 }
156
157 fn run(
158 self: Arc<Self>,
159 argument: Option<&str>,
160 workspace: WeakView<Workspace>,
161 _delegate: Arc<dyn LspAdapterDelegate>,
162 cx: &mut WindowContext,
163 ) -> Task<Result<SlashCommandOutput>> {
164 let Some(argument) = argument else {
165 return Task::ready(Err(anyhow!("missing crate name")));
166 };
167 let Some(workspace) = workspace.upgrade() else {
168 return Task::ready(Err(anyhow!("workspace was dropped")));
169 };
170
171 let project = workspace.read(cx).project().clone();
172 let fs = project.read(cx).fs().clone();
173 let http_client = workspace.read(cx).client().http_client();
174 let path_to_cargo_toml = Self::path_to_cargo_toml(project, cx);
175
176 let mut path_components = argument.split("::");
177 let crate_name = match path_components
178 .next()
179 .ok_or_else(|| anyhow!("missing crate name"))
180 {
181 Ok(crate_name) => CrateName::from(crate_name),
182 Err(err) => return Task::ready(Err(err)),
183 };
184 let item_path = path_components.map(ToString::to_string).collect::<Vec<_>>();
185
186 let text = cx.background_executor().spawn({
187 let rustdoc_store = RustdocStore::global(cx);
188 let crate_name = crate_name.clone();
189 let item_path = item_path.clone();
190 async move {
191 let item_docs = rustdoc_store
192 .load(crate_name.clone(), Some(item_path.join("::")))
193 .await;
194
195 if let Ok(item_docs) = item_docs {
196 anyhow::Ok((RustdocSource::Local, item_docs.docs().to_owned()))
197 } else {
198 Self::build_message(
199 fs,
200 http_client,
201 crate_name,
202 item_path,
203 path_to_cargo_toml.as_deref(),
204 )
205 .await
206 }
207 }
208 });
209
210 let module_path = if item_path.is_empty() {
211 None
212 } else {
213 Some(SharedString::from(item_path.join("::")))
214 };
215 cx.foreground_executor().spawn(async move {
216 let (source, text) = text.await?;
217 let range = 0..text.len();
218 Ok(SlashCommandOutput {
219 text,
220 sections: vec![SlashCommandOutputSection {
221 range,
222 render_placeholder: Arc::new(move |id, unfold, _cx| {
223 RustdocPlaceholder {
224 id,
225 unfold,
226 source,
227 crate_name: crate_name.clone(),
228 module_path: module_path.clone(),
229 }
230 .into_any_element()
231 }),
232 }],
233 run_commands_in_text: false,
234 })
235 })
236 }
237}
238
239#[derive(IntoElement)]
240struct RustdocPlaceholder {
241 pub id: ElementId,
242 pub unfold: Arc<dyn Fn(&mut WindowContext)>,
243 pub source: RustdocSource,
244 pub crate_name: CrateName,
245 pub module_path: Option<SharedString>,
246}
247
248impl RenderOnce for RustdocPlaceholder {
249 fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
250 let unfold = self.unfold;
251
252 let crate_path = self
253 .module_path
254 .map(|module_path| format!("{crate_name}::{module_path}", crate_name = self.crate_name))
255 .unwrap_or(self.crate_name.to_string());
256
257 ButtonLike::new(self.id)
258 .style(ButtonStyle::Filled)
259 .layer(ElevationIndex::ElevatedSurface)
260 .child(Icon::new(IconName::FileRust))
261 .child(Label::new(format!(
262 "rustdoc ({source}): {crate_path}",
263 source = match self.source {
264 RustdocSource::Local => "local",
265 RustdocSource::DocsDotRs => "docs.rs",
266 }
267 )))
268 .on_click(move |_, cx| unfold(cx))
269 }
270}