1use crate::{
2 hir_ext, render,
3 utils::{git_source_url, is_safe_output_path, read_deployments, relative_source_path},
4 vocs,
5};
6use eyre::Result;
7use foundry_compilers::{compilers::solc::SOLC_EXTENSIONS, utils::source_files_iter};
8use foundry_config::{
9 DocConfig,
10 filter::{expand_globs, is_ignored_path},
11};
12use rayon::prelude::*;
13use solar::{config::CompilerStage, sema::Compiler};
14use std::{
15 collections::HashSet,
16 fs,
17 path::{Path, PathBuf},
18 time::{Duration, Instant},
19};
20
21#[derive(Debug, Default, Clone)]
24pub struct BuildStats {
25 pub sources: usize,
27 pub pages: usize,
29 pub render_elapsed: Duration,
31 pub site_elapsed: Duration,
33}
34
35#[derive(Debug)]
37pub struct DocBuilder {
38 pub root: PathBuf,
40 pub sources: PathBuf,
42 pub libraries: Vec<PathBuf>,
44 pub include_libraries: bool,
46 pub commit: Option<String>,
48 pub branch: Option<String>,
51 pub deployments: Option<Option<PathBuf>>,
55 pub config: DocConfig,
57}
58
59impl DocBuilder {
60 pub fn new(
62 root: PathBuf,
63 sources: PathBuf,
64 libraries: Vec<PathBuf>,
65 include_libraries: bool,
66 ) -> Self {
67 Self {
68 root,
69 sources,
70 libraries,
71 include_libraries,
72 commit: None,
73 branch: None,
74 deployments: None,
75 config: DocConfig::default(),
76 }
77 }
78
79 fn out_dir(&self) -> PathBuf {
81 if self.config.out.is_absolute() {
82 self.config.out.clone()
83 } else {
84 self.root.join(&self.config.out)
85 }
86 }
87
88 pub fn build(self, compiler: &mut Compiler) -> Result<BuildStats> {
90 let out = self.out_dir();
91 let pages_dir = out.join("src").join("pages");
92 let render_started = Instant::now();
93
94 let ignored = expand_globs(&self.root, self.config.ignore.iter()).unwrap_or_else(|e| {
95 warn!("doc.ignore: failed to expand globs: {e}");
96 Default::default()
97 });
98
99 let mut sources: Vec<(PathBuf, bool)> = source_files_iter(&self.sources, SOLC_EXTENSIONS)
100 .filter(|p| !is_ignored_path(p, &ignored, &self.root))
101 .map(|p| (p, false))
102 .collect();
103
104 if self.include_libraries {
105 for lib_dir in &self.libraries {
106 let lib_sources = source_files_iter(lib_dir, SOLC_EXTENSIONS)
107 .filter(|p| !is_ignored_path(p, &ignored, &self.root))
108 .map(|p| (p, true));
109 sources.extend(lib_sources);
110 }
111 }
112
113 sources.sort_by(|(a, _), (b, _)| a.cmp(b));
114 let sources_count = sources.len();
115
116 let repo = self.config.repository.as_deref();
117 let commit = self.commit.as_deref();
118 let deployments_cfg = &self.deployments;
119 let root = &self.root;
120
121 let results = compiler.enter_mut(|compiler| -> Result<Vec<RenderResult>> {
122 if compiler.gcx().stage() < Some(CompilerStage::Lowering)
123 && compiler.lower_asts().is_err()
124 {
125 eyre::bail!("forge doc: HIR lowering failed; see diagnostics above");
127 }
128
129 let gcx = compiler.gcx();
130
131 let allowed_sources: HashSet<PathBuf> = sources
133 .iter()
134 .map(|(p, _)| if p.is_absolute() { p.clone() } else { root.join(p) })
135 .collect();
136
137 let name_to_page = hir_ext::build_name_to_page(gcx, root, &allowed_sources);
138
139 let results: Vec<RenderResult> = sources
142 .par_iter()
143 .map(|(path, from_library)| -> RenderResult {
144 let abs_path = if path.is_absolute() { path.clone() } else { root.join(path) };
145
146 let Some((_, ast_source)) = gcx.get_ast_source(&abs_path) else {
147 if !from_library {
148 warn!("AST source not found for {}", abs_path.display());
149 }
150 return Ok(Vec::new());
151 };
152 let Some(ast) = &ast_source.ast else {
153 if !from_library {
154 warn!("AST missing for {}", abs_path.display());
155 }
156 return Ok(Vec::new());
157 };
158
159 let rel_path = relative_source_path(root, &abs_path);
163
164 let git_url = if *from_library {
166 None
167 } else {
168 repo.and_then(|r| {
169 git_source_url(r, commit.unwrap_or("HEAD"), root, &abs_path)
170 })
171 };
172
173 let deployments = deployments_cfg.as_ref().map_or_else(Vec::new, |dir| {
176 read_deployments(root, dir.as_deref(), &rel_path)
177 });
178
179 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
180 render::source(
181 ast,
182 &ast_source.file,
183 &rel_path,
184 &abs_path,
185 gcx,
186 &name_to_page,
187 git_url.as_deref(),
188 &deployments,
189 )
190 }));
191 match result {
192 Ok(pages) => Ok(pages),
193 Err(_) => {
194 if *from_library {
196 debug!("rendering failed for library file {}", abs_path.display());
197 Ok(Vec::new())
198 } else {
199 error!("rendering panicked for {}", abs_path.display());
200 Err(abs_path)
201 }
202 }
203 }
204 })
205 .collect();
206
207 Ok(results)
208 })?;
209 let all_pages = write_pages(results, &pages_dir)?;
210 let render_elapsed = render_started.elapsed();
211
212 let site_started = Instant::now();
214 vocs::write_site_files(
215 &out,
216 &self.config,
217 &all_pages,
218 &self.root,
219 &self.sources,
220 self.branch.as_deref(),
221 self.commit.as_deref(),
222 )?;
223 info!("wrote vocs site files to {}", out.display());
224 let site_elapsed = site_started.elapsed();
225
226 Ok(BuildStats {
227 sources: sources_count,
228 pages: all_pages.len(),
229 render_elapsed,
230 site_elapsed,
231 })
232 }
233}
234
235type RenderResult = std::result::Result<Vec<(PathBuf, String)>, PathBuf>;
237
238fn write_pages(results: Vec<RenderResult>, pages_dir: &Path) -> Result<Vec<PathBuf>> {
240 let mut failed: Vec<PathBuf> = Vec::new();
242 let mut all_rel: Vec<PathBuf> = Vec::new();
243 for result in results {
244 let page_list = match result {
245 Ok(pages) => pages,
246 Err(path) => {
247 failed.push(path);
248 continue;
249 }
250 };
251 for (rel_out_path, content) in page_list {
252 if !is_safe_output_path(&rel_out_path) {
254 warn!("skipping unsafe output path: {}", rel_out_path.display());
255 continue;
256 }
257 let abs_out = pages_dir.join(&rel_out_path);
258 if let Some(parent) = abs_out.parent() {
259 fs::create_dir_all(parent)?;
260 }
261 fs::write(&abs_out, content)?;
262 info!("wrote {}", abs_out.display());
263 all_rel.push(rel_out_path);
264 }
265 }
266 all_rel.sort();
267
268 if !failed.is_empty() {
270 let list =
271 failed.iter().map(|p| format!(" - {}", p.display())).collect::<Vec<_>>().join("\n");
272 eyre::bail!("forge doc: rendering panicked for {} source file(s):\n{list}", failed.len());
273 }
274 update_manifest(pages_dir, &all_rel)?;
275 Ok(all_rel)
276}
277
278fn update_manifest(pages_dir: &Path, all_rel: &[PathBuf]) -> Result<()> {
280 let manifest_path = pages_dir.join(".forge-doc-manifest");
285 let prev_generated = fs::read_to_string(&manifest_path)
287 .unwrap_or_default()
288 .lines()
289 .filter(|line| !line.is_empty())
290 .map(PathBuf::from)
291 .collect::<HashSet<_>>();
292 let new_generated: HashSet<PathBuf> = all_rel.iter().cloned().collect();
293 for stale in prev_generated.difference(&new_generated) {
294 if !is_safe_output_path(stale) {
295 warn!("forge doc: ignoring unsafe manifest entry '{}'", stale.display());
296 continue;
297 }
298 let stale_abs = pages_dir.join(stale);
299 if stale_abs.is_file() {
300 debug!("pruning stale page {}", stale_abs.display());
301 let _ = fs::remove_file(&stale_abs);
302 }
303 }
304 {
306 let mut manifest_lines: Vec<String> =
307 all_rel.iter().map(|p| p.to_string_lossy().into_owned()).collect();
308 manifest_lines.sort();
309 fs::create_dir_all(pages_dir)?;
310 fs::write(&manifest_path, manifest_lines.join("\n") + "\n")?;
311 }
312 Ok(())
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 #[test]
320 fn rendering_failure_keeps_previous_ownership() {
321 let dir = tempfile::tempdir().unwrap();
322 let pages = dir.path();
323 let manifest = pages.join(".forge-doc-manifest");
324 fs::write(&manifest, "old.mdx\n").unwrap();
325 fs::write(pages.join("old.mdx"), "old").unwrap();
326 let results = vec![
327 Ok(vec![(PathBuf::from("new.mdx"), "new".to_string())]),
328 Err(PathBuf::from("Broken.sol")),
329 Ok(vec![(PathBuf::from("after.mdx"), "after".to_string())]),
330 ];
331
332 let error = write_pages(results, pages).unwrap_err();
333 assert_eq!(
334 error.to_string(),
335 "forge doc: rendering panicked for 1 source file(s):\n - Broken.sol"
336 );
337 assert_eq!(fs::read_to_string(pages.join("new.mdx")).unwrap(), "new");
338 assert_eq!(fs::read_to_string(pages.join("after.mdx")).unwrap(), "after");
339 assert_eq!(fs::read_to_string(pages.join("old.mdx")).unwrap(), "old");
340 assert_eq!(fs::read_to_string(manifest).unwrap(), "old.mdx\n");
341 }
342}