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, CrateName, LocalProvider, RustdocSource, RustdocStore};
14use ui::prelude::*;
15use util::{maybe, ResultExt};
16use workspace::Workspace;
17
18pub(crate) struct RustdocSlashCommand;
19
20impl RustdocSlashCommand {
21 async fn build_message(
22 fs: Arc<dyn Fs>,
23 http_client: Arc<HttpClientWithUrl>,
24 crate_name: CrateName,
25 module_path: Vec<String>,
26 path_to_cargo_toml: Option<&Path>,
27 ) -> Result<(RustdocSource, String)> {
28 let cargo_workspace_root = path_to_cargo_toml.and_then(|path| path.parent());
29 if let Some(cargo_workspace_root) = cargo_workspace_root {
30 let mut local_cargo_doc_path = cargo_workspace_root.join("target/doc");
31 local_cargo_doc_path.push(crate_name.as_ref());
32 if !module_path.is_empty() {
33 local_cargo_doc_path.push(module_path.join("/"));
34 }
35 local_cargo_doc_path.push("index.html");
36
37 if let Ok(contents) = fs.load(&local_cargo_doc_path).await {
38 let (markdown, _items) = convert_rustdoc_to_markdown(contents.as_bytes())?;
39
40 return Ok((RustdocSource::Local, markdown));
41 }
42 }
43
44 let version = "latest";
45 let path = format!(
46 "{crate_name}/{version}/{crate_name}/{module_path}",
47 module_path = module_path.join("/")
48 );
49
50 let mut response = http_client
51 .get(
52 &format!("https://docs.rs/{path}"),
53 AsyncBody::default(),
54 true,
55 )
56 .await?;
57
58 let mut body = Vec::new();
59 response
60 .body_mut()
61 .read_to_end(&mut body)
62 .await
63 .context("error reading docs.rs response body")?;
64
65 if response.status().is_client_error() {
66 let text = String::from_utf8_lossy(body.as_slice());
67 bail!(
68 "status error {}, response: {text:?}",
69 response.status().as_u16()
70 );
71 }
72
73 let (markdown, _items) = convert_rustdoc_to_markdown(&body[..])?;
74
75 Ok((RustdocSource::DocsDotRs, markdown))
76 }
77
78 fn path_to_cargo_toml(project: Model<Project>, cx: &mut AppContext) -> Option<Arc<Path>> {
79 let worktree = project.read(cx).worktrees().next()?;
80 let worktree = worktree.read(cx);
81 let entry = worktree.entry_for_path("Cargo.toml")?;
82 let path = ProjectPath {
83 worktree_id: worktree.id(),
84 path: entry.path.clone(),
85 };
86 Some(Arc::from(
87 project.read(cx).absolute_path(&path, cx)?.as_path(),
88 ))
89 }
90}
91
92impl SlashCommand for RustdocSlashCommand {
93 fn name(&self) -> String {
94 "rustdoc".into()
95 }
96
97 fn description(&self) -> String {
98 "insert Rust docs".into()
99 }
100
101 fn menu_text(&self) -> String {
102 "Insert Rust Documentation".into()
103 }
104
105 fn requires_argument(&self) -> bool {
106 true
107 }
108
109 fn complete_argument(
110 &self,
111 query: String,
112 _cancel: Arc<AtomicBool>,
113 workspace: Option<WeakView<Workspace>>,
114 cx: &mut AppContext,
115 ) -> Task<Result<Vec<String>>> {
116 let index_provider_deps = maybe!({
117 let workspace = workspace.ok_or_else(|| anyhow!("no workspace"))?;
118 let workspace = workspace
119 .upgrade()
120 .ok_or_else(|| anyhow!("workspace was dropped"))?;
121 let project = workspace.read(cx).project().clone();
122 let fs = project.read(cx).fs().clone();
123 let cargo_workspace_root = Self::path_to_cargo_toml(project, cx)
124 .and_then(|path| path.parent().map(|path| path.to_path_buf()))
125 .ok_or_else(|| anyhow!("no Cargo workspace root found"))?;
126
127 anyhow::Ok((fs, cargo_workspace_root))
128 });
129
130 let store = RustdocStore::global(cx);
131 cx.background_executor().spawn(async move {
132 if let Some((crate_name, rest)) = query.split_once(':') {
133 if rest.is_empty() {
134 if let Some((fs, cargo_workspace_root)) = index_provider_deps.log_err() {
135 let provider = Box::new(LocalProvider::new(fs, cargo_workspace_root));
136 // We don't need to hold onto this task, as the `RustdocStore` will hold it
137 // until it completes.
138 let _ = store.clone().index(crate_name.into(), provider);
139 }
140 }
141 }
142
143 let items = store.search(query).await;
144 Ok(items)
145 })
146 }
147
148 fn run(
149 self: Arc<Self>,
150 argument: Option<&str>,
151 workspace: WeakView<Workspace>,
152 _delegate: Arc<dyn LspAdapterDelegate>,
153 cx: &mut WindowContext,
154 ) -> Task<Result<SlashCommandOutput>> {
155 let Some(argument) = argument else {
156 return Task::ready(Err(anyhow!("missing crate name")));
157 };
158 let Some(workspace) = workspace.upgrade() else {
159 return Task::ready(Err(anyhow!("workspace was dropped")));
160 };
161
162 let project = workspace.read(cx).project().clone();
163 let fs = project.read(cx).fs().clone();
164 let http_client = workspace.read(cx).client().http_client();
165 let path_to_cargo_toml = Self::path_to_cargo_toml(project, cx);
166
167 let mut path_components = argument.split("::");
168 let crate_name = match path_components
169 .next()
170 .ok_or_else(|| anyhow!("missing crate name"))
171 {
172 Ok(crate_name) => CrateName::from(crate_name),
173 Err(err) => return Task::ready(Err(err)),
174 };
175 let item_path = path_components.map(ToString::to_string).collect::<Vec<_>>();
176
177 let text = cx.background_executor().spawn({
178 let rustdoc_store = RustdocStore::global(cx);
179 let crate_name = crate_name.clone();
180 let item_path = item_path.clone();
181 async move {
182 let item_docs = rustdoc_store
183 .load(
184 crate_name.clone(),
185 if item_path.is_empty() {
186 None
187 } else {
188 Some(item_path.join("::"))
189 },
190 )
191 .await;
192
193 if let Ok(item_docs) = item_docs {
194 anyhow::Ok((RustdocSource::Index, item_docs.docs().to_owned()))
195 } else {
196 Self::build_message(
197 fs,
198 http_client,
199 crate_name,
200 item_path,
201 path_to_cargo_toml.as_deref(),
202 )
203 .await
204 }
205 }
206 });
207
208 let module_path = if item_path.is_empty() {
209 None
210 } else {
211 Some(SharedString::from(item_path.join("::")))
212 };
213 cx.foreground_executor().spawn(async move {
214 let (source, text) = text.await?;
215 let range = 0..text.len();
216 let crate_path = module_path
217 .map(|module_path| format!("{}::{}", crate_name, module_path))
218 .unwrap_or_else(|| crate_name.to_string());
219 Ok(SlashCommandOutput {
220 text,
221 sections: vec![SlashCommandOutputSection {
222 range,
223 icon: IconName::FileRust,
224 label: format!(
225 "rustdoc ({source}): {crate_path}",
226 source = match source {
227 RustdocSource::Index => "index",
228 RustdocSource::Local => "local",
229 RustdocSource::DocsDotRs => "docs.rs",
230 }
231 )
232 .into(),
233 }],
234 run_commands_in_text: false,
235 })
236 })
237 }
238}