forge_doc/
builder.rs

1use crate::{
2    document::DocumentContent, helpers::merge_toml_table, AsDoc, BufWriter, Document, ParseItem,
3    ParseSource, Parser, Preprocessor,
4};
5use alloy_primitives::map::HashMap;
6use forge_fmt::{FormatterConfig, Visitable};
7use foundry_compilers::{compilers::solc::SOLC_EXTENSIONS, utils::source_files_iter};
8use foundry_config::{filter::expand_globs, DocConfig};
9use itertools::Itertools;
10use mdbook::MDBook;
11use rayon::prelude::*;
12use std::{
13    cmp::Ordering,
14    fs,
15    path::{Path, PathBuf},
16};
17use toml::value;
18
19/// Build Solidity documentation for a project from natspec comments.
20/// The builder parses the source files using [Parser],
21/// then formats and writes the elements as the output.
22#[derive(Debug)]
23pub struct DocBuilder {
24    /// The project root
25    pub root: PathBuf,
26    /// Path to Solidity source files.
27    pub sources: PathBuf,
28    /// Paths to external libraries.
29    pub libraries: Vec<PathBuf>,
30    /// Flag whether to build mdbook.
31    pub should_build: bool,
32    /// Documentation configuration.
33    pub config: DocConfig,
34    /// The array of preprocessors to apply.
35    pub preprocessors: Vec<Box<dyn Preprocessor>>,
36    /// The formatter config.
37    pub fmt: FormatterConfig,
38    /// Whether to include libraries to the output.
39    pub include_libraries: bool,
40}
41
42// TODO: consider using `tfio`
43impl DocBuilder {
44    pub(crate) const SRC: &'static str = "src";
45    const SOL_EXT: &'static str = "sol";
46    const README: &'static str = "README.md";
47    const SUMMARY: &'static str = "SUMMARY.md";
48
49    /// Create new instance of builder.
50    pub fn new(
51        root: PathBuf,
52        sources: PathBuf,
53        libraries: Vec<PathBuf>,
54        include_libraries: bool,
55    ) -> Self {
56        Self {
57            root,
58            sources,
59            libraries,
60            include_libraries,
61            should_build: false,
62            config: DocConfig::default(),
63            preprocessors: Default::default(),
64            fmt: Default::default(),
65        }
66    }
67
68    /// Set `should_build` flag on the builder
69    pub fn with_should_build(mut self, should_build: bool) -> Self {
70        self.should_build = should_build;
71        self
72    }
73
74    /// Set config on the builder.
75    pub fn with_config(mut self, config: DocConfig) -> Self {
76        self.config = config;
77        self
78    }
79
80    /// Set formatter config on the builder.
81    pub fn with_fmt(mut self, fmt: FormatterConfig) -> Self {
82        self.fmt = fmt;
83        self
84    }
85
86    /// Set preprocessors on the builder.
87    pub fn with_preprocessor<P: Preprocessor + 'static>(mut self, preprocessor: P) -> Self {
88        self.preprocessors.push(Box::new(preprocessor) as Box<dyn Preprocessor>);
89        self
90    }
91
92    /// Get the output directory
93    pub fn out_dir(&self) -> PathBuf {
94        self.root.join(&self.config.out)
95    }
96
97    /// Parse the sources and build the documentation.
98    pub fn build(self) -> eyre::Result<()> {
99        // Expand ignore globs
100        let ignored = expand_globs(&self.root, self.config.ignore.iter())?;
101
102        // Collect and parse source files
103        let sources = source_files_iter(&self.sources, SOLC_EXTENSIONS)
104            .filter(|file| !ignored.contains(file))
105            .collect::<Vec<_>>();
106
107        if sources.is_empty() {
108            sh_println!("No sources detected at {}", self.sources.display())?;
109            return Ok(())
110        }
111
112        let library_sources = self
113            .libraries
114            .iter()
115            .flat_map(|lib| source_files_iter(lib, SOLC_EXTENSIONS))
116            .collect::<Vec<_>>();
117
118        let combined_sources = sources
119            .iter()
120            .map(|path| (path, false))
121            .chain(library_sources.iter().map(|path| (path, true)))
122            .collect::<Vec<_>>();
123
124        let documents = combined_sources
125            .par_iter()
126            .enumerate()
127            .map(|(i, (path, from_library))| {
128                let path = *path;
129                let from_library = *from_library;
130
131                // Read and parse source file
132                let source = fs::read_to_string(path)?;
133
134                let (mut source_unit, comments) = match solang_parser::parse(&source, i) {
135                    Ok(res) => res,
136                    Err(err) => {
137                        if from_library {
138                            // Ignore failures for library files
139                            return Ok(Vec::new());
140                        } else {
141                            return Err(eyre::eyre!(
142                                "Failed to parse Solidity code for {}\nDebug info: {:?}",
143                                path.display(),
144                                err
145                            ));
146                        }
147                    }
148                };
149
150                // Visit the parse tree
151                let mut doc = Parser::new(comments, source).with_fmt(self.fmt.clone());
152                source_unit
153                    .visit(&mut doc)
154                    .map_err(|err| eyre::eyre!("Failed to parse source: {err}"))?;
155
156                // Split the parsed items on top-level constants and rest.
157                let (items, consts): (Vec<ParseItem>, Vec<ParseItem>) = doc
158                    .items()
159                    .into_iter()
160                    .partition(|item| !matches!(item.source, ParseSource::Variable(_)));
161
162                // Attempt to group overloaded top-level functions
163                let mut remaining = Vec::with_capacity(items.len());
164                let mut funcs: HashMap<String, Vec<ParseItem>> = HashMap::default();
165                for item in items {
166                    if matches!(item.source, ParseSource::Function(_)) {
167                        funcs.entry(item.source.ident()).or_default().push(item);
168                    } else {
169                        // Put the item back
170                        remaining.push(item);
171                    }
172                }
173                let (items, overloaded): (
174                    HashMap<String, Vec<ParseItem>>,
175                    HashMap<String, Vec<ParseItem>>,
176                ) = funcs.into_iter().partition(|(_, v)| v.len() == 1);
177                remaining.extend(items.into_iter().flat_map(|(_, v)| v));
178
179                // Each regular item will be written into its own file.
180                let mut files = remaining
181                    .into_iter()
182                    .map(|item| {
183                        let relative_path = path.strip_prefix(&self.root)?.join(item.filename());
184                        let target_path = self.config.out.join(Self::SRC).join(relative_path);
185                        let ident = item.source.ident();
186                        Ok(Document::new(
187                            path.clone(),
188                            target_path,
189                            from_library,
190                            self.config.out.clone(),
191                        )
192                        .with_content(DocumentContent::Single(item), ident))
193                    })
194                    .collect::<eyre::Result<Vec<_>>>()?;
195
196                // If top-level constants exist, they will be written to the same file.
197                if !consts.is_empty() {
198                    let filestem = path.file_stem().and_then(|stem| stem.to_str());
199
200                    let filename = {
201                        let mut name = "constants".to_owned();
202                        if let Some(stem) = filestem {
203                            name.push_str(&format!(".{stem}"));
204                        }
205                        name.push_str(".md");
206                        name
207                    };
208                    let relative_path = path.strip_prefix(&self.root)?.join(filename);
209                    let target_path = self.config.out.join(Self::SRC).join(relative_path);
210
211                    let identity = match filestem {
212                        Some(stem) if stem.to_lowercase().contains("constants") => stem.to_owned(),
213                        Some(stem) => format!("{stem} constants"),
214                        None => "constants".to_owned(),
215                    };
216
217                    files.push(
218                        Document::new(
219                            path.clone(),
220                            target_path,
221                            from_library,
222                            self.config.out.clone(),
223                        )
224                        .with_content(DocumentContent::Constants(consts), identity),
225                    )
226                }
227
228                // If overloaded functions exist, they will be written to the same file
229                if !overloaded.is_empty() {
230                    for (ident, funcs) in overloaded {
231                        let filename = funcs.first().expect("no overloaded functions").filename();
232                        let relative_path = path.strip_prefix(&self.root)?.join(filename);
233                        let target_path = self.config.out.join(Self::SRC).join(relative_path);
234                        files.push(
235                            Document::new(
236                                path.clone(),
237                                target_path,
238                                from_library,
239                                self.config.out.clone(),
240                            )
241                            .with_content(DocumentContent::OverloadedFunctions(funcs), ident),
242                        );
243                    }
244                }
245
246                Ok(files)
247            })
248            .collect::<eyre::Result<Vec<_>>>()?;
249
250        // Flatten results and apply preprocessors to files
251        let documents = self
252            .preprocessors
253            .iter()
254            .try_fold(documents.into_iter().flatten().collect_vec(), |docs, p| {
255                p.preprocess(docs)
256            })?;
257
258        // Sort the results
259        let documents = documents.into_iter().sorted_by(|doc1, doc2| {
260            doc1.item_path.display().to_string().cmp(&doc2.item_path.display().to_string())
261        });
262
263        // Write mdbook related files
264        self.write_mdbook(
265            documents.filter(|d| !d.from_library || self.include_libraries).collect_vec(),
266        )?;
267
268        // Build the book if requested
269        if self.should_build {
270            MDBook::load(self.out_dir())
271                .and_then(|book| book.build())
272                .map_err(|err| eyre::eyre!("failed to build book: {err:?}"))?;
273        }
274
275        Ok(())
276    }
277
278    fn write_mdbook(&self, documents: Vec<Document>) -> eyre::Result<()> {
279        let out_dir = self.out_dir();
280        let out_dir_src = out_dir.join(Self::SRC);
281        fs::create_dir_all(&out_dir_src)?;
282
283        // Write readme content if any
284        let homepage_content = {
285            // Default to the homepage README if it's available.
286            // If not, use the src README as a fallback.
287            let homepage_or_src_readme = self
288                .config
289                .homepage
290                .as_ref()
291                .map(|homepage| self.root.join(homepage))
292                .unwrap_or_else(|| self.sources.join(Self::README));
293            // Grab the root readme.
294            let root_readme = self.root.join(Self::README);
295
296            // Check to see if there is a 'homepage' option specified in config.
297            // If not, fall back to src and root readme files, in that order.
298            if homepage_or_src_readme.exists() {
299                fs::read_to_string(homepage_or_src_readme)?
300            } else if root_readme.exists() {
301                fs::read_to_string(root_readme)?
302            } else {
303                String::new()
304            }
305        };
306
307        let readme_path = out_dir_src.join(Self::README);
308        fs::write(readme_path, homepage_content)?;
309
310        // Write summary and section readmes
311        let mut summary = BufWriter::default();
312        summary.write_title("Summary")?;
313        summary.write_link_list_item("Home", Self::README, 0)?;
314        self.write_summary_section(&mut summary, &documents.iter().collect::<Vec<_>>(), None, 0)?;
315        fs::write(out_dir_src.join(Self::SUMMARY), summary.finish())?;
316
317        // Write solidity syntax highlighting
318        fs::write(out_dir.join("solidity.min.js"), include_str!("../static/solidity.min.js"))?;
319
320        // Write css files
321        fs::write(out_dir.join("book.css"), include_str!("../static/book.css"))?;
322
323        // Write book config
324        fs::write(self.out_dir().join("book.toml"), self.book_config()?)?;
325
326        // Write .gitignore
327        let gitignore = "book/";
328        fs::write(self.out_dir().join(".gitignore"), gitignore)?;
329
330        // Write doc files
331        for document in documents {
332            fs::create_dir_all(
333                document
334                    .target_path
335                    .parent()
336                    .ok_or_else(|| eyre::format_err!("empty target path; noop"))?,
337            )?;
338            fs::write(&document.target_path, document.as_doc()?)?;
339        }
340
341        Ok(())
342    }
343
344    fn book_config(&self) -> eyre::Result<String> {
345        // Read the default book first
346        let mut book: value::Table = toml::from_str(include_str!("../static/book.toml"))?;
347        book["book"]
348            .as_table_mut()
349            .unwrap()
350            .insert(String::from("title"), self.config.title.clone().into());
351        if let Some(ref repo) = self.config.repository {
352            book["output"].as_table_mut().unwrap()["html"]
353                .as_table_mut()
354                .unwrap()
355                .insert(String::from("git-repository-url"), repo.clone().into());
356        }
357
358        // Attempt to find the user provided book path
359        let book_path = {
360            if self.config.book.is_file() {
361                Some(self.config.book.clone())
362            } else {
363                let book_path = self.config.book.join("book.toml");
364                if book_path.is_file() {
365                    Some(book_path)
366                } else {
367                    None
368                }
369            }
370        };
371
372        // Merge two book configs
373        if let Some(book_path) = book_path {
374            merge_toml_table(&mut book, toml::from_str(&fs::read_to_string(book_path)?)?);
375        }
376
377        Ok(toml::to_string_pretty(&book)?)
378    }
379
380    fn write_summary_section(
381        &self,
382        summary: &mut BufWriter,
383        files: &[&Document],
384        base_path: Option<&Path>,
385        depth: usize,
386    ) -> eyre::Result<()> {
387        if files.is_empty() {
388            return Ok(())
389        }
390
391        if let Some(path) = base_path {
392            let title = path.iter().next_back().unwrap().to_string_lossy();
393            if depth == 1 {
394                summary.write_title(&title)?;
395            } else {
396                let summary_path = path.join(Self::README);
397                summary.write_link_list_item(
398                    &format!("❱ {title}"),
399                    &summary_path.display().to_string(),
400                    depth - 1,
401                )?;
402            }
403        }
404
405        // Group entries by path depth
406        let mut grouped = HashMap::new();
407        for file in files {
408            let path = file.item_path.strip_prefix(&self.root)?;
409            let key = path.iter().take(depth + 1).collect::<PathBuf>();
410            grouped.entry(key).or_insert_with(Vec::new).push(*file);
411        }
412        // Sort entries by path depth
413        let grouped = grouped.into_iter().sorted_by(|(lhs, _), (rhs, _)| {
414            let lhs_at_end = lhs.extension().map(|ext| ext == Self::SOL_EXT).unwrap_or_default();
415            let rhs_at_end = rhs.extension().map(|ext| ext == Self::SOL_EXT).unwrap_or_default();
416            if lhs_at_end == rhs_at_end {
417                lhs.cmp(rhs)
418            } else if lhs_at_end {
419                Ordering::Greater
420            } else {
421                Ordering::Less
422            }
423        });
424
425        let mut readme = BufWriter::new("\n\n# Contents\n");
426        for (path, files) in grouped {
427            if path.extension().map(|ext| ext == Self::SOL_EXT).unwrap_or_default() {
428                for file in files {
429                    let ident = &file.identity;
430
431                    let summary_path = file
432                        .target_path
433                        .strip_prefix(self.out_dir().strip_prefix(&self.root)?.join(Self::SRC))?;
434                    summary.write_link_list_item(
435                        ident,
436                        &summary_path.display().to_string(),
437                        depth,
438                    )?;
439
440                    let readme_path = base_path
441                        .map(|path| summary_path.strip_prefix(path))
442                        .transpose()?
443                        .unwrap_or(summary_path);
444                    readme.write_link_list_item(ident, &readme_path.display().to_string(), 0)?;
445                }
446            } else {
447                let name = path.iter().next_back().unwrap().to_string_lossy();
448                let readme_path = Path::new("/").join(&path).display().to_string();
449                readme.write_link_list_item(&name, &readme_path, 0)?;
450                self.write_summary_section(summary, &files, Some(&path), depth + 1)?;
451            }
452        }
453        if !readme.is_empty() {
454            if let Some(path) = base_path {
455                let path = self.out_dir().join(Self::SRC).join(path);
456                fs::create_dir_all(&path)?;
457                fs::write(path.join(Self::README), readme.finish())?;
458            }
459        }
460        Ok(())
461    }
462}