1use crate::{
2 hir_ext, render,
3 utils::{Deployment, git_source_url, read_deployments},
4 vocs,
5};
6use eyre::Result;
7use foundry_compilers::{compilers::solc::SOLC_EXTENSIONS, utils::source_files_iter};
8use foundry_config::{DocConfig, filter::expand_globs};
9use rayon::prelude::*;
10use solar::{config::CompilerStage, sema::Compiler};
11use std::{
12 collections::{HashMap, HashSet},
13 fs,
14 path::{Component, PathBuf},
15 time::{Duration, Instant},
16};
17
18#[derive(Debug, Default, Clone)]
21pub struct BuildStats {
22 pub sources: usize,
24 pub pages: usize,
26 pub render_elapsed: Duration,
28 pub site_elapsed: Duration,
30}
31
32#[derive(Debug)]
34pub struct DocBuilder {
35 pub root: PathBuf,
37 pub sources: PathBuf,
39 pub libraries: Vec<PathBuf>,
41 pub include_libraries: bool,
43 pub commit: Option<String>,
45 pub branch: Option<String>,
48 pub deployments: Option<Option<PathBuf>>,
52 pub config: DocConfig,
54}
55
56impl DocBuilder {
57 pub fn new(
59 root: PathBuf,
60 sources: PathBuf,
61 libraries: Vec<PathBuf>,
62 include_libraries: bool,
63 ) -> Self {
64 Self {
65 root,
66 sources,
67 libraries,
68 include_libraries,
69 commit: None,
70 branch: None,
71 deployments: None,
72 config: DocConfig::default(),
73 }
74 }
75
76 fn out_dir(&self) -> PathBuf {
78 if self.config.out.is_absolute() {
79 self.config.out.clone()
80 } else {
81 self.root.join(&self.config.out)
82 }
83 }
84
85 pub fn build(self, compiler: &mut Compiler) -> Result<BuildStats> {
87 let out = self.out_dir();
88 let pages_dir = out.join("src").join("pages");
89 let render_started = Instant::now();
90
91 let ignored = expand_globs(&self.root, self.config.ignore.iter()).unwrap_or_else(|e| {
92 warn!("doc.ignore: failed to expand globs: {e}");
93 Default::default()
94 });
95
96 let mut sources: Vec<(PathBuf, bool)> = source_files_iter(&self.sources, SOLC_EXTENSIONS)
97 .filter(|p| !ignored.contains(p) && !ignored.contains(&self.root.join(p)))
98 .map(|p| (p, false))
99 .collect();
100
101 if self.include_libraries {
102 for lib_dir in &self.libraries {
103 let lib_sources = source_files_iter(lib_dir, SOLC_EXTENSIONS)
104 .filter(|p| !ignored.contains(p) && !ignored.contains(&self.root.join(p)))
105 .map(|p| (p, true));
106 sources.extend(lib_sources);
107 }
108 }
109
110 sources.sort_by(|(a, _), (b, _)| a.cmp(b));
111 let sources_count = sources.len();
112
113 let repo = self.config.repository.clone();
114 let commit = self.commit.clone();
115 let deployments_cfg = self.deployments.clone();
116 let root = self.root.clone();
117
118 let all_pages = compiler.enter_mut(|compiler| -> eyre::Result<Vec<PathBuf>> {
119 if compiler.gcx().stage() < Some(CompilerStage::Lowering)
120 && compiler.lower_asts().is_err()
121 {
122 eyre::bail!("forge doc: HIR lowering failed; see diagnostics above");
124 }
125
126 let gcx = compiler.gcx();
127
128 let allowed_sources: HashSet<PathBuf> = sources
130 .iter()
131 .map(|(p, _)| if p.is_absolute() { p.clone() } else { root.join(p) })
132 .collect();
133
134 let name_to_page = hir_ext::build_name_to_page(gcx, &root, &allowed_sources);
135
136 type RenderResult = (Option<Vec<(PathBuf, String)>>, Option<PathBuf>);
140 let results: Vec<RenderResult> = sources
141 .par_iter()
142 .map(|(path, from_library)| -> RenderResult {
143 let abs_path = if path.is_absolute() { path.clone() } else { root.join(path) };
144
145 let Some((_, ast_source)) = gcx.get_ast_source(&abs_path) else {
146 if !from_library {
147 warn!("AST source not found for {}", abs_path.display());
148 }
149 return (None, None);
150 };
151 let Some(ast) = &ast_source.ast else {
152 if !from_library {
153 warn!("AST missing for {}", abs_path.display());
154 }
155 return (None, None);
156 };
157
158 let rel_path = if let Ok(p) = abs_path.strip_prefix(&root) {
162 p.to_path_buf()
163 } else {
164 let comps: Vec<_> = abs_path.components().collect();
165 let start = comps.len().saturating_sub(3);
166 let tail: PathBuf = comps[start..].iter().collect();
167 PathBuf::from("lib").join(tail)
168 };
169
170 let git_url = if *from_library {
172 None
173 } else {
174 repo.as_deref().and_then(|r| {
175 git_source_url(r, commit.as_deref().unwrap_or("HEAD"), &root, &abs_path)
176 })
177 };
178
179 let deployments_map: HashMap<String, Vec<Deployment>> = match &deployments_cfg {
181 Some(dir_opt) => {
182 let entries = read_deployments(&root, dir_opt.as_deref(), &rel_path);
183 if entries.is_empty() {
186 HashMap::new()
187 } else if let Some(stem) = rel_path.file_stem().and_then(|s| s.to_str())
188 {
189 let mut m = HashMap::new();
190 m.insert(stem.to_string(), entries);
191 m
192 } else {
193 HashMap::new()
194 }
195 }
196 None => HashMap::new(),
197 };
198
199 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
200 render::source(
201 ast,
202 &ast_source.file,
203 gcx.sess.source_map(),
204 &rel_path,
205 &abs_path,
206 &root,
207 gcx,
208 &name_to_page,
209 git_url.as_deref(),
210 &deployments_map,
211 )
212 }));
213 match result {
214 Ok(pages) => (Some(pages), None),
215 Err(_) => {
216 if *from_library {
218 debug!("rendering failed for library file {}", abs_path.display());
219 (None, None)
220 } else {
221 error!("rendering panicked for {}", abs_path.display());
222 (None, Some(abs_path))
223 }
224 }
225 }
226 })
227 .collect();
228
229 let mut failed: Vec<PathBuf> = Vec::new();
231 let mut all_rel: Vec<PathBuf> = Vec::new();
232 for (pages, panicked) in results {
233 if let Some(p) = panicked {
234 failed.push(p);
235 }
236 if let Some(page_list) = pages {
237 for (rel_out_path, content) in page_list {
238 if rel_out_path.is_absolute()
240 || rel_out_path.components().any(|c| {
241 matches!(
242 c,
243 Component::ParentDir
244 | Component::RootDir
245 | Component::Prefix(_)
246 )
247 })
248 {
249 warn!("skipping unsafe output path: {}", rel_out_path.display());
250 continue;
251 }
252 let abs_out = pages_dir.join(&rel_out_path);
253 if let Some(parent) = abs_out.parent() {
254 fs::create_dir_all(parent)?;
255 }
256 fs::write(&abs_out, content)?;
257 info!("wrote {}", abs_out.display());
258 all_rel.push(rel_out_path);
259 }
260 }
261 }
262 all_rel.sort();
263
264 if !failed.is_empty() {
266 let list = failed
267 .iter()
268 .map(|p| format!(" - {}", p.display()))
269 .collect::<Vec<_>>()
270 .join("\n");
271 eyre::bail!(
272 "forge doc: rendering panicked for {} source file(s):\n{list}",
273 failed.len()
274 );
275 }
276
277 let manifest_path = pages_dir.join(".forge-doc-manifest");
282 let prev_generated: HashSet<PathBuf> = if manifest_path.exists() {
283 fs::read_to_string(&manifest_path)
284 .unwrap_or_default()
285 .lines()
286 .filter(|l| !l.is_empty())
287 .map(PathBuf::from)
288 .collect()
289 } else {
290 HashSet::new()
293 };
294 let new_generated: HashSet<PathBuf> = all_rel.iter().cloned().collect();
295 for stale in prev_generated.difference(&new_generated) {
296 let safe = !stale.is_absolute()
297 && !stale.components().any(|c| {
298 matches!(
299 c,
300 Component::ParentDir | Component::Prefix(_) | Component::RootDir
301 )
302 });
303 if !safe {
304 warn!("forge doc: ignoring unsafe manifest entry '{}'", stale.display());
305 continue;
306 }
307 let stale_abs = pages_dir.join(stale);
308 if stale_abs.is_file() {
309 debug!("pruning stale page {}", stale_abs.display());
310 let _ = fs::remove_file(&stale_abs);
311 }
312 }
313 {
315 let mut manifest_lines: Vec<String> =
316 all_rel.iter().map(|p| p.to_string_lossy().into_owned()).collect();
317 manifest_lines.sort();
318 fs::write(&manifest_path, manifest_lines.join("\n") + "\n")?;
319 }
320
321 Ok(all_rel)
322 })?;
323 let render_elapsed = render_started.elapsed();
324
325 let site_started = Instant::now();
327 vocs::write_site_files(
328 &out,
329 &self.config,
330 &all_pages,
331 &self.root,
332 &self.sources,
333 self.branch.as_deref(),
334 self.commit.as_deref(),
335 )?;
336 info!("wrote vocs site files to {}", out.display());
337 let site_elapsed = site_started.elapsed();
338
339 Ok(BuildStats {
340 sources: sources_count,
341 pages: all_pages.len(),
342 render_elapsed,
343 site_elapsed,
344 })
345 }
346}