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#[derive(Debug)]
23pub struct DocBuilder {
24 pub root: PathBuf,
26 pub sources: PathBuf,
28 pub libraries: Vec<PathBuf>,
30 pub should_build: bool,
32 pub config: DocConfig,
34 pub preprocessors: Vec<Box<dyn Preprocessor>>,
36 pub fmt: FormatterConfig,
38 pub include_libraries: bool,
40}
41
42impl 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 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 pub fn with_should_build(mut self, should_build: bool) -> Self {
70 self.should_build = should_build;
71 self
72 }
73
74 pub fn with_config(mut self, config: DocConfig) -> Self {
76 self.config = config;
77 self
78 }
79
80 pub fn with_fmt(mut self, fmt: FormatterConfig) -> Self {
82 self.fmt = fmt;
83 self
84 }
85
86 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 pub fn out_dir(&self) -> PathBuf {
94 self.root.join(&self.config.out)
95 }
96
97 pub fn build(self) -> eyre::Result<()> {
99 let ignored = expand_globs(&self.root, self.config.ignore.iter())?;
101
102 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 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 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 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 let (items, consts): (Vec<ParseItem>, Vec<ParseItem>) = doc
158 .items()
159 .into_iter()
160 .partition(|item| !matches!(item.source, ParseSource::Variable(_)));
161
162 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 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 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 !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.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 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 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 self.write_mdbook(
265 documents.filter(|d| !d.from_library || self.include_libraries).collect_vec(),
266 )?;
267
268 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 let homepage_content = {
285 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 let root_readme = self.root.join(Self::README);
295
296 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 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 fs::write(out_dir.join("solidity.min.js"), include_str!("../static/solidity.min.js"))?;
319
320 fs::write(out_dir.join("book.css"), include_str!("../static/book.css"))?;
322
323 fs::write(self.out_dir().join("book.toml"), self.book_config()?)?;
325
326 let gitignore = "book/";
328 fs::write(self.out_dir().join(".gitignore"), gitignore)?;
329
330 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 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 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 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 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 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}