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#[derive(Debug)]
23pub struct DocBuilder {
24 root: PathBuf,
26 sources: PathBuf,
28 libraries: Vec<PathBuf>,
30 should_build: bool,
32 config: DocConfig,
34 preprocessors: Vec<Box<dyn Preprocessor>>,
36 fmt: FormatterConfig,
38 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 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 pub fn with_should_build(mut self, should_build: bool) -> Self {
69 self.should_build = should_build;
70 self
71 }
72
73 pub fn with_config(mut self, config: DocConfig) -> Self {
75 self.config = config;
76 self
77 }
78
79 pub fn with_fmt(mut self, fmt: FormatterConfig) -> Self {
81 self.fmt = fmt;
82 self
83 }
84
85 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 pub fn out_dir(&self) -> Result<PathBuf> {
93 Ok(self.root.join(&self.config.out).canonicalize()?)
94 }
95
96 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 let ignored = expand_globs(&self.root, self.config.ignore.iter())?;
103
104 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 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 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 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 let (items, consts): (Vec<ParseItem>, Vec<ParseItem>) = doc
167 .items()
168 .into_iter()
169 .partition(|item| !matches!(item.source, ParseSource::Variable(_)));
170
171 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 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 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 !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.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 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 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 self.write_mdbook(documents)?;
291
292 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 let homepage_content = {
309 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 let root_readme = self.root.join(Self::README);
319
320 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 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 fs::write(out_dir.join("solidity.min.js"), include_str!("../static/solidity.min.js"))?;
343
344 fs::write(out_dir.join("book.css"), include_str!("../static/book.css"))?;
346
347 fs::write(out_dir.join("book.toml"), self.book_config()?)?;
349
350 let gitignore = "book/";
352 fs::write(out_dir.join(".gitignore"), gitignore)?;
353
354 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 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 let git_repo_url = if let Some(path) = &self.config.path {
378 format!("{}/{}", repo.trim_end_matches('/'), path.trim_start_matches('/'))
380 } else {
381 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 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 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 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 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}