1use crate::{
4 TestFunctionExt, preprocessor::DynamicTestLinkingPreprocessor, shell, term::SpinnerReporter,
5};
6use comfy_table::{Cell, Color, Table, modifiers::UTF8_ROUND_CORNERS, presets::ASCII_MARKDOWN};
7use eyre::Result;
8use foundry_block_explorers::contract::Metadata;
9use foundry_compilers::{
10 Artifact, Project, ProjectBuilder, ProjectCompileOutput, ProjectPathsConfig, SolcConfig,
11 artifacts::{BytecodeObject, Contract, Source, remappings::Remapping},
12 compilers::{
13 Compiler,
14 solc::{Solc, SolcCompiler},
15 },
16 info::ContractInfo as CompilerContractInfo,
17 multi::{MultiCompiler, MultiCompilerSettings},
18 project::Preprocessor,
19 report::{BasicStdoutReporter, NoReporter, Report},
20 solc::SolcSettings,
21};
22use num_format::{Locale, ToFormattedString};
23use std::{
24 collections::BTreeMap,
25 fmt::Display,
26 io::IsTerminal,
27 path::{Path, PathBuf},
28 str::FromStr,
29 time::Instant,
30};
31
32#[must_use = "ProjectCompiler does nothing unless you call a `compile*` method"]
37pub struct ProjectCompiler {
38 project_root: PathBuf,
40
41 print_names: Option<bool>,
43
44 print_sizes: Option<bool>,
46
47 quiet: Option<bool>,
49
50 bail: Option<bool>,
52
53 ignore_eip_3860: bool,
55
56 files: Vec<PathBuf>,
58
59 dynamic_test_linking: bool,
61}
62
63impl Default for ProjectCompiler {
64 #[inline]
65 fn default() -> Self {
66 Self::new()
67 }
68}
69
70impl ProjectCompiler {
71 #[inline]
73 pub fn new() -> Self {
74 Self {
75 project_root: PathBuf::new(),
76 print_names: None,
77 print_sizes: None,
78 quiet: Some(crate::shell::is_quiet()),
79 bail: None,
80 ignore_eip_3860: false,
81 files: Vec::new(),
82 dynamic_test_linking: false,
83 }
84 }
85
86 #[inline]
88 pub fn print_names(mut self, yes: bool) -> Self {
89 self.print_names = Some(yes);
90 self
91 }
92
93 #[inline]
95 pub fn print_sizes(mut self, yes: bool) -> Self {
96 self.print_sizes = Some(yes);
97 self
98 }
99
100 #[inline]
102 #[doc(alias = "silent")]
103 pub fn quiet(mut self, yes: bool) -> Self {
104 self.quiet = Some(yes);
105 self
106 }
107
108 #[inline]
110 pub fn bail(mut self, yes: bool) -> Self {
111 self.bail = Some(yes);
112 self
113 }
114
115 #[inline]
117 pub fn ignore_eip_3860(mut self, yes: bool) -> Self {
118 self.ignore_eip_3860 = yes;
119 self
120 }
121
122 #[inline]
124 pub fn files(mut self, files: impl IntoIterator<Item = PathBuf>) -> Self {
125 self.files.extend(files);
126 self
127 }
128
129 #[inline]
131 pub fn dynamic_test_linking(mut self, preprocess: bool) -> Self {
132 self.dynamic_test_linking = preprocess;
133 self
134 }
135
136 #[instrument(target = "forge::compile", skip_all)]
138 pub fn compile<C: Compiler<CompilerContract = Contract>>(
139 mut self,
140 project: &Project<C>,
141 ) -> Result<ProjectCompileOutput<C>>
142 where
143 DynamicTestLinkingPreprocessor: Preprocessor<C>,
144 {
145 self.project_root = project.root().to_path_buf();
146
147 if !project.paths.has_input_files() && self.files.is_empty() {
154 sh_println!("Nothing to compile")?;
155 std::process::exit(0);
156 }
157
158 let files = std::mem::take(&mut self.files);
160 let preprocess = self.dynamic_test_linking;
161 self.compile_with(|| {
162 let sources = if !files.is_empty() {
163 Source::read_all(files)?
164 } else {
165 project.paths.read_input_files()?
166 };
167
168 let mut compiler =
169 foundry_compilers::project::ProjectCompiler::with_sources(project, sources)?;
170 if preprocess {
171 compiler = compiler.with_preprocessor(DynamicTestLinkingPreprocessor);
172 }
173 compiler.compile().map_err(Into::into)
174 })
175 }
176
177 fn compile_with<C: Compiler<CompilerContract = Contract>, F>(
179 self,
180 f: F,
181 ) -> Result<ProjectCompileOutput<C>>
182 where
183 F: FnOnce() -> Result<ProjectCompileOutput<C>>,
184 {
185 let quiet = self.quiet.unwrap_or(false);
186 let bail = self.bail.unwrap_or(true);
187
188 let output = with_compilation_reporter(quiet, Some(self.project_root.clone()), || {
189 tracing::debug!("compiling project");
190
191 let timer = Instant::now();
192 let r = f();
193 let elapsed = timer.elapsed();
194
195 tracing::debug!("finished compiling in {:.3}s", elapsed.as_secs_f64());
196 r
197 })?;
198
199 if bail && output.has_compiler_errors() {
200 eyre::bail!("{output}")
201 }
202
203 if !quiet {
204 if !shell::is_json() {
205 if output.is_unchanged() {
206 sh_println!("No files changed, compilation skipped")?;
207 } else {
208 sh_println!("{output}")?;
210 }
211 }
212
213 self.handle_output(&output)?;
214 }
215
216 Ok(output)
217 }
218
219 fn handle_output<C: Compiler<CompilerContract = Contract>>(
221 &self,
222 output: &ProjectCompileOutput<C>,
223 ) -> Result<()> {
224 let print_names = self.print_names.unwrap_or(false);
225 let print_sizes = self.print_sizes.unwrap_or(false);
226
227 if print_names {
229 let mut artifacts: BTreeMap<_, Vec<_>> = BTreeMap::new();
230 for (name, (_, version)) in output.versioned_artifacts() {
231 artifacts.entry(version).or_default().push(name);
232 }
233
234 if shell::is_json() {
235 sh_println!("{}", serde_json::to_string(&artifacts).unwrap())?;
236 } else {
237 for (version, names) in artifacts {
238 sh_println!(
239 " compiler version: {}.{}.{}",
240 version.major,
241 version.minor,
242 version.patch
243 )?;
244 for name in names {
245 sh_println!(" - {name}")?;
246 }
247 }
248 }
249 }
250
251 if print_sizes {
252 if print_names && !shell::is_json() {
254 sh_println!()?;
255 }
256
257 let mut size_report = SizeReport { contracts: BTreeMap::new() };
258
259 let mut artifacts: BTreeMap<String, Vec<_>> = BTreeMap::new();
260 for (id, artifact) in output.artifact_ids().filter(|(id, _)| {
261 !id.source.to_string_lossy().contains("/forge-std/src/")
263 }) {
264 artifacts.entry(id.name.clone()).or_default().push((id.source.clone(), artifact));
265 }
266
267 for (name, artifact_list) in artifacts {
268 for (path, artifact) in &artifact_list {
269 let runtime_size = contract_size(*artifact, false).unwrap_or_default();
270 let init_size = contract_size(*artifact, true).unwrap_or_default();
271
272 let is_dev_contract = artifact
273 .abi
274 .as_ref()
275 .map(|abi| {
276 abi.functions().any(|f| {
277 f.test_function_kind().is_known()
278 || matches!(f.name.as_str(), "IS_TEST" | "IS_SCRIPT")
279 })
280 })
281 .unwrap_or(false);
282
283 let unique_name = if artifact_list.len() > 1 {
284 format!(
285 "{} ({})",
286 name,
287 path.strip_prefix(&self.project_root).unwrap_or(path).display()
288 )
289 } else {
290 name.clone()
291 };
292
293 size_report.contracts.insert(
294 unique_name,
295 ContractInfo { runtime_size, init_size, is_dev_contract },
296 );
297 }
298 }
299
300 sh_println!("{size_report}")?;
301
302 eyre::ensure!(
303 !size_report.exceeds_runtime_size_limit(),
304 "some contracts exceed the runtime size limit \
305 (EIP-170: {CONTRACT_RUNTIME_SIZE_LIMIT} bytes)"
306 );
307 eyre::ensure!(
309 self.ignore_eip_3860 || !size_report.exceeds_initcode_size_limit(),
310 "some contracts exceed the initcode size limit \
311 (EIP-3860: {CONTRACT_INITCODE_SIZE_LIMIT} bytes)"
312 );
313 }
314
315 Ok(())
316 }
317}
318
319const CONTRACT_RUNTIME_SIZE_LIMIT: usize = 24576;
321
322const CONTRACT_INITCODE_SIZE_LIMIT: usize = 49152;
324
325pub struct SizeReport {
327 pub contracts: BTreeMap<String, ContractInfo>,
329}
330
331impl SizeReport {
332 pub fn max_runtime_size(&self) -> usize {
334 self.contracts
335 .values()
336 .filter(|c| !c.is_dev_contract)
337 .map(|c| c.runtime_size)
338 .max()
339 .unwrap_or(0)
340 }
341
342 pub fn max_init_size(&self) -> usize {
344 self.contracts
345 .values()
346 .filter(|c| !c.is_dev_contract)
347 .map(|c| c.init_size)
348 .max()
349 .unwrap_or(0)
350 }
351
352 pub fn exceeds_runtime_size_limit(&self) -> bool {
354 self.max_runtime_size() > CONTRACT_RUNTIME_SIZE_LIMIT
355 }
356
357 pub fn exceeds_initcode_size_limit(&self) -> bool {
359 self.max_init_size() > CONTRACT_INITCODE_SIZE_LIMIT
360 }
361}
362
363impl Display for SizeReport {
364 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
365 if shell::is_json() {
366 writeln!(f, "{}", self.format_json_output())?;
367 } else {
368 writeln!(f, "\n{}", self.format_table_output())?;
369 }
370 Ok(())
371 }
372}
373
374impl SizeReport {
375 fn format_json_output(&self) -> String {
376 let contracts = self
377 .contracts
378 .iter()
379 .filter(|(_, c)| !c.is_dev_contract && (c.runtime_size > 0 || c.init_size > 0))
380 .map(|(name, contract)| {
381 (
382 name.clone(),
383 serde_json::json!({
384 "runtime_size": contract.runtime_size,
385 "init_size": contract.init_size,
386 "runtime_margin": CONTRACT_RUNTIME_SIZE_LIMIT as isize - contract.runtime_size as isize,
387 "init_margin": CONTRACT_INITCODE_SIZE_LIMIT as isize - contract.init_size as isize,
388 }),
389 )
390 })
391 .collect::<serde_json::Map<_, _>>();
392
393 serde_json::to_string(&contracts).unwrap()
394 }
395
396 fn format_table_output(&self) -> Table {
397 let mut table = Table::new();
398 if shell::is_markdown() {
399 table.load_preset(ASCII_MARKDOWN);
400 } else {
401 table.apply_modifier(UTF8_ROUND_CORNERS);
402 }
403
404 table.set_header(vec![
405 Cell::new("Contract"),
406 Cell::new("Runtime Size (B)"),
407 Cell::new("Initcode Size (B)"),
408 Cell::new("Runtime Margin (B)"),
409 Cell::new("Initcode Margin (B)"),
410 ]);
411
412 let contracts = self
414 .contracts
415 .iter()
416 .filter(|(_, c)| !c.is_dev_contract && (c.runtime_size > 0 || c.init_size > 0));
417 for (name, contract) in contracts {
418 let runtime_margin =
419 CONTRACT_RUNTIME_SIZE_LIMIT as isize - contract.runtime_size as isize;
420 let init_margin = CONTRACT_INITCODE_SIZE_LIMIT as isize - contract.init_size as isize;
421
422 let runtime_color = match contract.runtime_size {
423 ..18_000 => Color::Reset,
424 18_000..=CONTRACT_RUNTIME_SIZE_LIMIT => Color::Yellow,
425 _ => Color::Red,
426 };
427
428 let init_color = match contract.init_size {
429 ..36_000 => Color::Reset,
430 36_000..=CONTRACT_INITCODE_SIZE_LIMIT => Color::Yellow,
431 _ => Color::Red,
432 };
433
434 let locale = &Locale::en;
435 table.add_row([
436 Cell::new(name),
437 Cell::new(contract.runtime_size.to_formatted_string(locale)).fg(runtime_color),
438 Cell::new(contract.init_size.to_formatted_string(locale)).fg(init_color),
439 Cell::new(runtime_margin.to_formatted_string(locale)).fg(runtime_color),
440 Cell::new(init_margin.to_formatted_string(locale)).fg(init_color),
441 ]);
442 }
443
444 table
445 }
446}
447
448fn contract_size<T: Artifact>(artifact: &T, initcode: bool) -> Option<usize> {
450 let bytecode = if initcode {
451 artifact.get_bytecode_object()?
452 } else {
453 artifact.get_deployed_bytecode_object()?
454 };
455
456 let size = match bytecode.as_ref() {
457 BytecodeObject::Bytecode(bytes) => bytes.len(),
458 BytecodeObject::Unlinked(unlinked) => {
459 let mut size = unlinked.len();
462 if unlinked.starts_with("0x") {
463 size -= 2;
464 }
465 size / 2
467 }
468 };
469
470 Some(size)
471}
472
473#[derive(Clone, Copy, Debug)]
475pub struct ContractInfo {
476 pub runtime_size: usize,
478 pub init_size: usize,
480 pub is_dev_contract: bool,
482}
483
484pub fn compile_target<C: Compiler<CompilerContract = Contract>>(
490 target_path: &Path,
491 project: &Project<C>,
492 quiet: bool,
493) -> Result<ProjectCompileOutput<C>>
494where
495 DynamicTestLinkingPreprocessor: Preprocessor<C>,
496{
497 ProjectCompiler::new().quiet(quiet).files([target_path.into()]).compile(project)
498}
499
500pub fn etherscan_project(metadata: &Metadata, target_path: &Path) -> Result<Project> {
502 let target_path = dunce::canonicalize(target_path)?;
503 let sources_path = target_path.join(&metadata.contract_name);
504 metadata.source_tree().write_to(&target_path)?;
505
506 let mut settings = metadata.settings()?;
507
508 for remapping in &mut settings.remappings {
510 let new_path = sources_path.join(remapping.path.trim_start_matches('/'));
511 remapping.path = new_path.display().to_string();
512 }
513
514 if !settings.remappings.iter().any(|remapping| remapping.name.starts_with("@openzeppelin/")) {
516 let oz = Remapping {
517 context: None,
518 name: "@openzeppelin/".into(),
519 path: sources_path.join("@openzeppelin").display().to_string(),
520 };
521 settings.remappings.push(oz);
522 }
523
524 let paths = ProjectPathsConfig::builder()
528 .sources(sources_path.clone())
529 .remappings(settings.remappings.clone())
530 .build_with_root(sources_path);
531
532 let v = metadata.compiler_version()?;
534 let solc = Solc::find_or_install(&v)?;
535
536 let compiler = MultiCompiler { solc: Some(SolcCompiler::Specific(solc)), vyper: None };
537
538 Ok(ProjectBuilder::<MultiCompiler>::default()
539 .settings(MultiCompilerSettings {
540 solc: SolcSettings {
541 settings: SolcConfig::builder().settings(settings).build(),
542 ..Default::default()
543 },
544 ..Default::default()
545 })
546 .paths(paths)
547 .ephemeral()
548 .no_artifacts()
549 .build(compiler)?)
550}
551
552pub fn with_compilation_reporter<O>(
554 quiet: bool,
555 project_root: Option<PathBuf>,
556 f: impl FnOnce() -> O,
557) -> O {
558 #[expect(clippy::collapsible_else_if)]
559 let reporter = if quiet || shell::is_json() {
560 Report::new(NoReporter::default())
561 } else {
562 if std::io::stdout().is_terminal() {
563 Report::new(SpinnerReporter::spawn(project_root))
564 } else {
565 Report::new(BasicStdoutReporter::default())
566 }
567 };
568
569 foundry_compilers::report::with_scoped(&reporter, f)
570}
571
572#[derive(Clone, PartialEq, Eq)]
579pub enum PathOrContractInfo {
580 Path(PathBuf),
582 ContractInfo(CompilerContractInfo),
584}
585
586impl PathOrContractInfo {
587 pub fn path(&self) -> Option<PathBuf> {
589 match self {
590 Self::Path(path) => Some(path.to_path_buf()),
591 Self::ContractInfo(info) => info.path.as_ref().map(PathBuf::from),
592 }
593 }
594
595 pub fn name(&self) -> Option<&str> {
597 match self {
598 Self::Path(_) => None,
599 Self::ContractInfo(info) => Some(&info.name),
600 }
601 }
602}
603
604impl FromStr for PathOrContractInfo {
605 type Err = eyre::Error;
606
607 fn from_str(s: &str) -> Result<Self> {
608 if let Ok(contract) = CompilerContractInfo::from_str(s) {
609 return Ok(Self::ContractInfo(contract));
610 }
611 let path = PathBuf::from(s);
612 if path.extension().is_some_and(|ext| ext == "sol" || ext == "vy") {
613 return Ok(Self::Path(path));
614 }
615 Err(eyre::eyre!("Invalid contract identifier, file is not *.sol or *.vy: {}", s))
616 }
617}
618
619impl std::fmt::Debug for PathOrContractInfo {
620 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
621 match self {
622 Self::Path(path) => write!(f, "Path({})", path.display()),
623 Self::ContractInfo(info) => {
624 write!(f, "ContractInfo({info})")
625 }
626 }
627 }
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633
634 #[test]
635 fn parse_contract_identifiers() {
636 let t = ["src/Counter.sol", "src/Counter.sol:Counter", "Counter"];
637
638 let i1 = PathOrContractInfo::from_str(t[0]).unwrap();
639 assert_eq!(i1, PathOrContractInfo::Path(PathBuf::from(t[0])));
640
641 let i2 = PathOrContractInfo::from_str(t[1]).unwrap();
642 assert_eq!(
643 i2,
644 PathOrContractInfo::ContractInfo(CompilerContractInfo {
645 path: Some("src/Counter.sol".to_string()),
646 name: "Counter".to_string()
647 })
648 );
649
650 let i3 = PathOrContractInfo::from_str(t[2]).unwrap();
651 assert_eq!(
652 i3,
653 PathOrContractInfo::ContractInfo(CompilerContractInfo {
654 path: None,
655 name: "Counter".to_string()
656 })
657 );
658 }
659}