Skip to main content

forge_doc/
builder.rs

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/// Summary stats produced by [`DocBuilder::build`], surfaced to the user as
19/// progress feedback by `forge doc`.
20#[derive(Debug, Default, Clone)]
21pub struct BuildStats {
22    /// Number of Solidity sources considered for rendering.
23    pub sources: usize,
24    /// Number of MDX pages written to disk.
25    pub pages: usize,
26    /// Total time spent generating MDX pages and pruning stale ones.
27    pub render_elapsed: Duration,
28    /// Total time spent writing the vocs site scaffold.
29    pub site_elapsed: Duration,
30}
31
32/// Build Solidity documentation for a project from natspec comments using [`solar`].
33#[derive(Debug)]
34pub struct DocBuilder {
35    /// Project root.
36    pub root: PathBuf,
37    /// Path to Solidity source files.
38    pub sources: PathBuf,
39    /// Paths to external libraries.
40    pub libraries: Vec<PathBuf>,
41    /// Whether to also document files coming from external libraries.
42    pub include_libraries: bool,
43    /// Optional commit hash (HEAD) used when building Git Source links.
44    pub commit: Option<String>,
45    /// Optional current branch name; used as the `<branch>` segment of vocs
46    /// editLink URLs (which require an actual branch, not a commit/`HEAD`).
47    pub branch: Option<String>,
48    /// Optional path to the deployments directory (relative to `root`).
49    /// `Some(None)` enables the preprocessor with the default `deployments`
50    /// path; `Some(Some(p))` overrides; `None` disables it entirely.
51    pub deployments: Option<Option<PathBuf>>,
52    /// Documentation configuration.
53    pub config: DocConfig,
54}
55
56impl DocBuilder {
57    /// Create a new builder.
58    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    /// Resolve the absolute output directory.
77    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    /// Run the documentation pipeline.
86    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                // Diagnostics are already emitted via the solar session.
123                eyre::bail!("forge doc: HIR lowering failed; see diagnostics above");
124            }
125
126            let gcx = compiler.gcx();
127
128            // Restrict cross-reference resolution to files we'll actually emit pages for.
129            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            // Render each source in parallel.
137            // Each entry is `(rendered_pages, panicked_user_source)`. A panicked
138            // non-library source is recorded so we can fail the build at the end.
139            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                    // For sources outside the project root (e.g. library deps that live under a
159                    // different prefix), synthesise a safe relative path so that
160                    // `pages_dir.join(rel_out_path)` can never escape the docs tree.
161                    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                    // Git source link (skipped on library files).
171                    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                    // Deployments for this source's contracts.
180                    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                            // All deployments belong to the contract sharing the
184                            // file stem (legacy behaviour).
185                            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                            // Ignore failures from library files; surface user errors.
217                            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            // Split rendered pages from panicked user sources.
230            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                        // Reject any output path that would escape the docs tree.
239                        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            // Fail the build if any non-library source panicked during render.
265            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            // Prune stale `.mdx` pages using a manifest of previously generated
278            // files. This covers every generated subtree (including library pages
279            // outside `src/`), while never touching user-authored pages that were
280            // never listed in the manifest.
281            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                // No manifest: do not prune. The manifest is the ownership boundary for
291                // generated pages; without it, user-authored pages are indistinguishable.
292                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            // Write new manifest.
314            {
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        // Generate vocs site scaffolding.
326        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}