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::{
9 DocConfig,
10 filter::{expand_globs, is_ignored_path},
11};
12use rayon::prelude::*;
13use solar::{config::CompilerStage, sema::Compiler};
14use std::{
15 collections::{HashMap, HashSet},
16 fs,
17 path::{Component, 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.clone();
117 let commit = self.commit.clone();
118 let deployments_cfg = self.deployments.clone();
119 let root = self.root.clone();
120
121 let all_pages = compiler.enter_mut(|compiler| -> eyre::Result<Vec<PathBuf>> {
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 type RenderResult = (Option<Vec<(PathBuf, String)>>, Option<PathBuf>);
143 let results: Vec<RenderResult> = sources
144 .par_iter()
145 .map(|(path, from_library)| -> RenderResult {
146 let abs_path = if path.is_absolute() { path.clone() } else { root.join(path) };
147
148 let Some((_, ast_source)) = gcx.get_ast_source(&abs_path) else {
149 if !from_library {
150 warn!("AST source not found for {}", abs_path.display());
151 }
152 return (None, None);
153 };
154 let Some(ast) = &ast_source.ast else {
155 if !from_library {
156 warn!("AST missing for {}", abs_path.display());
157 }
158 return (None, None);
159 };
160
161 let rel_path = if let Ok(p) = abs_path.strip_prefix(&root) {
165 p.to_path_buf()
166 } else {
167 let comps: Vec<_> = abs_path.components().collect();
168 let start = comps.len().saturating_sub(3);
169 let tail: PathBuf = comps[start..].iter().collect();
170 PathBuf::from("lib").join(tail)
171 };
172
173 let git_url = if *from_library {
175 None
176 } else {
177 repo.as_deref().and_then(|r| {
178 git_source_url(r, commit.as_deref().unwrap_or("HEAD"), &root, &abs_path)
179 })
180 };
181
182 let deployments_map: HashMap<String, Vec<Deployment>> = match &deployments_cfg {
184 Some(dir_opt) => {
185 let entries = read_deployments(&root, dir_opt.as_deref(), &rel_path);
186 if entries.is_empty() {
189 HashMap::new()
190 } else if let Some(stem) = rel_path.file_stem().and_then(|s| s.to_str())
191 {
192 let mut m = HashMap::new();
193 m.insert(stem.to_string(), entries);
194 m
195 } else {
196 HashMap::new()
197 }
198 }
199 None => HashMap::new(),
200 };
201
202 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
203 render::source(
204 ast,
205 &ast_source.file,
206 gcx.sess.source_map(),
207 &rel_path,
208 &abs_path,
209 &root,
210 gcx,
211 &name_to_page,
212 git_url.as_deref(),
213 &deployments_map,
214 )
215 }));
216 match result {
217 Ok(pages) => (Some(pages), None),
218 Err(_) => {
219 if *from_library {
221 debug!("rendering failed for library file {}", abs_path.display());
222 (None, None)
223 } else {
224 error!("rendering panicked for {}", abs_path.display());
225 (None, Some(abs_path))
226 }
227 }
228 }
229 })
230 .collect();
231
232 let mut failed: Vec<PathBuf> = Vec::new();
234 let mut all_rel: Vec<PathBuf> = Vec::new();
235 for (pages, panicked) in results {
236 if let Some(p) = panicked {
237 failed.push(p);
238 }
239 if let Some(page_list) = pages {
240 for (rel_out_path, content) in page_list {
241 if rel_out_path.is_absolute()
243 || rel_out_path.components().any(|c| {
244 matches!(
245 c,
246 Component::ParentDir
247 | Component::RootDir
248 | Component::Prefix(_)
249 )
250 })
251 {
252 warn!("skipping unsafe output path: {}", rel_out_path.display());
253 continue;
254 }
255 let abs_out = pages_dir.join(&rel_out_path);
256 if let Some(parent) = abs_out.parent() {
257 fs::create_dir_all(parent)?;
258 }
259 fs::write(&abs_out, content)?;
260 info!("wrote {}", abs_out.display());
261 all_rel.push(rel_out_path);
262 }
263 }
264 }
265 all_rel.sort();
266
267 if !failed.is_empty() {
269 let list = failed
270 .iter()
271 .map(|p| format!(" - {}", p.display()))
272 .collect::<Vec<_>>()
273 .join("\n");
274 eyre::bail!(
275 "forge doc: rendering panicked for {} source file(s):\n{list}",
276 failed.len()
277 );
278 }
279
280 let manifest_path = pages_dir.join(".forge-doc-manifest");
285 let prev_generated: HashSet<PathBuf> = if manifest_path.exists() {
286 fs::read_to_string(&manifest_path)
287 .unwrap_or_default()
288 .lines()
289 .filter(|l| !l.is_empty())
290 .map(PathBuf::from)
291 .collect()
292 } else {
293 HashSet::new()
296 };
297 let new_generated: HashSet<PathBuf> = all_rel.iter().cloned().collect();
298 for stale in prev_generated.difference(&new_generated) {
299 let safe = !stale.is_absolute()
300 && !stale.components().any(|c| {
301 matches!(
302 c,
303 Component::ParentDir | Component::Prefix(_) | Component::RootDir
304 )
305 });
306 if !safe {
307 warn!("forge doc: ignoring unsafe manifest entry '{}'", stale.display());
308 continue;
309 }
310 let stale_abs = pages_dir.join(stale);
311 if stale_abs.is_file() {
312 debug!("pruning stale page {}", stale_abs.display());
313 let _ = fs::remove_file(&stale_abs);
314 }
315 }
316 {
318 let mut manifest_lines: Vec<String> =
319 all_rel.iter().map(|p| p.to_string_lossy().into_owned()).collect();
320 manifest_lines.sort();
321 fs::create_dir_all(&pages_dir)?;
322 fs::write(&manifest_path, manifest_lines.join("\n") + "\n")?;
323 }
324
325 Ok(all_rel)
326 })?;
327 let render_elapsed = render_started.elapsed();
328
329 let site_started = Instant::now();
331 vocs::write_site_files(
332 &out,
333 &self.config,
334 &all_pages,
335 &self.root,
336 &self.sources,
337 self.branch.as_deref(),
338 self.commit.as_deref(),
339 )?;
340 info!("wrote vocs site files to {}", out.display());
341 let site_elapsed = site_started.elapsed();
342
343 Ok(BuildStats {
344 sources: sources_count,
345 pages: all_pages.len(),
346 render_elapsed,
347 site_elapsed,
348 })
349 }
350}