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::{
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/// 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.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                // 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            // Each entry is `(rendered_pages, panicked_user_source)`. A panicked
141            // non-library source is recorded so we can fail the build at the end.
142            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                    // For sources outside the project root (e.g. library deps that live under a
162                    // different prefix), synthesise a safe relative path so that
163                    // `pages_dir.join(rel_out_path)` can never escape the docs tree.
164                    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                    // Git source link (skipped on library files).
174                    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                    // Deployments for this source's contracts.
183                    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                            // All deployments belong to the contract sharing the
187                            // file stem (legacy behaviour).
188                            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                            // Ignore failures from library files; surface user errors.
220                            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            // Split rendered pages from panicked user sources.
233            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                        // Reject any output path that would escape the docs tree.
242                        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            // Fail the build if any non-library source panicked during render.
268            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            // 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            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                // No manifest: do not prune. The manifest is the ownership boundary for
294                // generated pages; without it, user-authored pages are indistinguishable.
295                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            // Write new manifest.
317            {
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        // Generate vocs site scaffolding.
330        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}