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