Skip to main content

forge_doc/
builder.rs

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/// Summary stats produced by [`DocBuilder::build`], surfaced to the user as
22/// progress feedback by `forge doc`.
23#[derive(Debug, Default, Clone)]
24pub struct BuildStats {
25    /// Number of Solidity sources considered for rendering.
26    pub sources: usize,
27    /// Number of MDX pages written to disk.
28    pub pages: usize,
29    /// Total time spent generating MDX pages and pruning stale ones.
30    pub render_elapsed: Duration,
31    /// Total time spent writing the vocs site scaffold.
32    pub site_elapsed: Duration,
33}
34
35/// Build Solidity documentation for a project from natspec comments using [`solar`].
36#[derive(Debug)]
37pub struct DocBuilder {
38    /// Project root.
39    pub root: PathBuf,
40    /// Path to Solidity source files.
41    pub sources: PathBuf,
42    /// Paths to external libraries.
43    pub libraries: Vec<PathBuf>,
44    /// Whether to also document files coming from external libraries.
45    pub include_libraries: bool,
46    /// Optional Git commit, tag, or branch used when building Git Source links.
47    pub commit: Option<String>,
48    /// Optional current branch name; used as the `<branch>` segment of vocs
49    /// editLink URLs (which require an actual branch, not a commit/`HEAD`).
50    pub branch: Option<String>,
51    /// Optional path to the deployments directory (relative to `root`).
52    /// `Some(None)` enables the preprocessor with the default `deployments`
53    /// path; `Some(Some(p))` overrides; `None` disables it entirely.
54    pub deployments: Option<Option<PathBuf>>,
55    /// Documentation configuration.
56    pub config: DocConfig,
57}
58
59impl DocBuilder {
60    /// Create a new builder.
61    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    /// Resolve the absolute output directory.
80    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    /// Run the documentation pipeline.
89    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                // Diagnostics are already emitted via the solar session.
126                eyre::bail!("forge doc: HIR lowering failed; see diagnostics above");
127            }
128
129            let gcx = compiler.gcx();
130
131            // Restrict cross-reference resolution to files we'll actually emit pages for.
132            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            // Render each source in parallel.
140            // Record panicked user sources so we can fail after writing successful pages.
141            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                    // For sources outside the project root (e.g. library deps that live under a
160                    // different prefix), synthesise a safe relative path so that
161                    // `pages_dir.join(rel_out_path)` can never escape the docs tree.
162                    let rel_path = relative_source_path(root, &abs_path);
163
164                    // Git source link (skipped on library files).
165                    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                    // Deployments for this source's contracts.
174                    // The legacy lookup is per file stem, so this source has one deployment list.
175                    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                            // Ignore failures from library files; surface user errors.
195                            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        // Generate vocs site scaffolding.
213        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
235/// Successful pages or the path of a user source whose renderer panicked.
236type RenderResult = std::result::Result<Vec<(PathBuf, String)>, PathBuf>;
237
238/// Write successful pages, but do not change ownership or prune if a user source failed.
239fn write_pages(results: Vec<RenderResult>, pages_dir: &Path) -> Result<Vec<PathBuf>> {
240    // Split rendered pages from panicked user sources.
241    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            // Reject any output path that would escape the docs tree.
253            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    // Fail the build if any non-library source panicked during render.
269    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
278/// The manifest is the ownership boundary: only previously generated pages can be pruned.
279fn update_manifest(pages_dir: &Path, all_rel: &[PathBuf]) -> Result<()> {
280    // Prune stale `.mdx` pages using a manifest of previously generated
281    // files. This covers every generated subtree (including library pages
282    // outside `src/`), while never touching user-authored pages that were
283    // never listed in the manifest.
284    let manifest_path = pages_dir.join(".forge-doc-manifest");
285    // Missing or unreadable manifests grant no ownership over existing pages.
286    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    // Write new manifest.
305    {
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}