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, IndexedDocsStore, LocalProvider, PackageName, ProviderId,
13 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
95impl SlashCommand for RustdocSlashCommand {
96 fn name(&self) -> String {
97 "rustdoc".into()
98 }
99
100 fn description(&self) -> String {
101 "insert Rust docs".into()
102 }
103
104 fn menu_text(&self) -> String {
105 "Insert Rust Documentation".into()
106 }
107
108 fn requires_argument(&self) -> bool {
109 true
110 }
111
112 fn complete_argument(
113 self: Arc<Self>,
114 query: String,
115 _cancel: Arc<AtomicBool>,
116 workspace: Option<WeakView<Workspace>>,
117 cx: &mut AppContext,
118 ) -> Task<Result<Vec<String>>> {
119 let index_provider_deps = maybe!({
120 let workspace = workspace.ok_or_else(|| anyhow!("no workspace"))?;
121 let workspace = workspace
122 .upgrade()
123 .ok_or_else(|| anyhow!("workspace was dropped"))?;
124 let project = workspace.read(cx).project().clone();
125 let fs = project.read(cx).fs().clone();
126 let cargo_workspace_root = Self::path_to_cargo_toml(project, cx)
127 .and_then(|path| path.parent().map(|path| path.to_path_buf()))
128 .ok_or_else(|| anyhow!("no Cargo workspace root found"))?;
129
130 anyhow::Ok((fs, cargo_workspace_root))
131 });
132
133 let store = IndexedDocsStore::try_global(ProviderId::rustdoc(), cx);
134 cx.background_executor().spawn(async move {
135 let store = store?;
136
137 if let Some((crate_name, rest)) = query.split_once(':') {
138 if rest.is_empty() {
139 if let Some((fs, cargo_workspace_root)) = index_provider_deps.log_err() {
140 let provider = Box::new(LocalProvider::new(fs, cargo_workspace_root));
141 // We don't need to hold onto this task, as the `RustdocStore` will hold it
142 // until it completes.
143 let _ = store.clone().index(crate_name.into(), provider);
144 }
145 }
146 }
147
148 let items = store.search(query).await;
149 Ok(items)
150 })
151 }
152
153 fn run(
154 self: Arc<Self>,
155 argument: Option<&str>,
156 workspace: WeakView<Workspace>,
157 _delegate: Arc<dyn LspAdapterDelegate>,
158 cx: &mut WindowContext,
159 ) -> Task<Result<SlashCommandOutput>> {
160 let Some(argument) = argument else {
161 return Task::ready(Err(anyhow!("missing crate name")));
162 };
163 let Some(workspace) = workspace.upgrade() else {
164 return Task::ready(Err(anyhow!("workspace was dropped")));
165 };
166
167 let project = workspace.read(cx).project().clone();
168 let fs = project.read(cx).fs().clone();
169 let http_client = workspace.read(cx).client().http_client();
170 let path_to_cargo_toml = Self::path_to_cargo_toml(project, cx);
171
172 let mut path_components = argument.split("::");
173 let crate_name = match path_components
174 .next()
175 .ok_or_else(|| anyhow!("missing crate name"))
176 {
177 Ok(crate_name) => PackageName::from(crate_name),
178 Err(err) => return Task::ready(Err(err)),
179 };
180 let item_path = path_components.map(ToString::to_string).collect::<Vec<_>>();
181
182 let text = cx.background_executor().spawn({
183 let rustdoc_store = IndexedDocsStore::try_global(ProviderId::rustdoc(), cx);
184 let crate_name = crate_name.clone();
185 let item_path = item_path.clone();
186 async move {
187 let rustdoc_store = rustdoc_store?;
188 let item_docs = rustdoc_store
189 .load(
190 crate_name.clone(),
191 if item_path.is_empty() {
192 None
193 } else {
194 Some(item_path.join("::"))
195 },
196 )
197 .await;
198
199 if let Ok(item_docs) = item_docs {
200 anyhow::Ok((RustdocSource::Index, item_docs.to_string()))
201 } else {
202 Self::build_message(
203 fs,
204 http_client,
205 crate_name,
206 item_path,
207 path_to_cargo_toml.as_deref(),
208 )
209 .await
210 }
211 }
212 });
213
214 let module_path = if item_path.is_empty() {
215 None
216 } else {
217 Some(SharedString::from(item_path.join("::")))
218 };
219 cx.foreground_executor().spawn(async move {
220 let (source, text) = text.await?;
221 let range = 0..text.len();
222 let crate_path = module_path
223 .map(|module_path| format!("{}::{}", crate_name, module_path))
224 .unwrap_or_else(|| crate_name.to_string());
225 Ok(SlashCommandOutput {
226 text,
227 sections: vec![SlashCommandOutputSection {
228 range,
229 icon: IconName::FileRust,
230 label: format!(
231 "rustdoc ({source}): {crate_path}",
232 source = match source {
233 RustdocSource::Index => "index",
234 RustdocSource::Local => "local",
235 RustdocSource::DocsDotRs => "docs.rs",
236 }
237 )
238 .into(),
239 }],
240 run_commands_in_text: false,
241 })
242 })
243 }
244}