forge_doc/
builder.rs

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