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 sync::Arc,
30 time::Instant,
31};
32
33pub type Analysis = Arc<solar::sema::Compiler>;
35
36#[must_use = "ProjectCompiler does nothing unless you call a `compile*` method"]
41pub struct ProjectCompiler {
42 project_root: PathBuf,
44
45 print_names: Option<bool>,
47
48 print_sizes: Option<bool>,
50
51 quiet: Option<bool>,
53
54 bail: Option<bool>,
56
57 ignore_eip_3860: bool,
59
60 size_limits: ContractSizeLimits,
62
63 files: Vec<PathBuf>,
65
66 dynamic_test_linking: bool,
68}
69
70impl Default for ProjectCompiler {
71 #[inline]
72 fn default() -> Self {
73 Self::new()
74 }
75}
76
77impl ProjectCompiler {
78 #[inline]
80 pub fn new() -> Self {
81 Self {
82 project_root: PathBuf::new(),
83 print_names: None,
84 print_sizes: None,
85 quiet: Some(crate::shell::is_quiet()),
86 bail: None,
87 ignore_eip_3860: false,
88 size_limits: ContractSizeLimits::default(),
89 files: Vec::new(),
90 dynamic_test_linking: false,
91 }
92 }
93
94 #[inline]
96 pub const fn print_names(mut self, yes: bool) -> Self {
97 self.print_names = Some(yes);
98 self
99 }
100
101 #[inline]
103 pub const fn print_sizes(mut self, yes: bool) -> Self {
104 self.print_sizes = Some(yes);
105 self
106 }
107
108 #[inline]
110 #[doc(alias = "silent")]
111 pub const fn quiet(mut self, yes: bool) -> Self {
112 self.quiet = Some(yes);
113 self
114 }
115
116 #[inline]
118 pub const fn bail(mut self, yes: bool) -> Self {
119 self.bail = Some(yes);
120 self
121 }
122
123 #[inline]
125 pub const fn ignore_eip_3860(mut self, yes: bool) -> Self {
126 self.ignore_eip_3860 = yes;
127 self
128 }
129
130 #[inline]
132 pub const fn size_limits(mut self, limits: ContractSizeLimits) -> Self {
133 self.size_limits = limits;
134 self
135 }
136
137 #[inline]
139 pub fn files(mut self, files: impl IntoIterator<Item = PathBuf>) -> Self {
140 self.files.extend(files);
141 self
142 }
143
144 #[inline]
146 pub const fn dynamic_test_linking(mut self, preprocess: bool) -> Self {
147 self.dynamic_test_linking = preprocess;
148 self
149 }
150
151 #[instrument(target = "forge::compile", skip_all)]
153 pub fn compile<C: Compiler<CompilerContract = Contract>>(
154 mut self,
155 project: &Project<C>,
156 ) -> Result<ProjectCompileOutput<C>>
157 where
158 DynamicTestLinkingPreprocessor: Preprocessor<C>,
159 {
160 self.project_root = project.root().to_path_buf();
161
162 if !project.paths.has_input_files() && self.files.is_empty() {
169 sh_println!("Nothing to compile")?;
170 std::process::exit(0);
171 }
172
173 let files = std::mem::take(&mut self.files);
175 let preprocess = self.dynamic_test_linking;
176 self.compile_with(|| {
177 let sources = if files.is_empty() {
178 project.paths.read_input_files()?
179 } else {
180 Source::read_all(files)?
181 };
182
183 let mut compiler =
184 foundry_compilers::project::ProjectCompiler::with_sources(project, sources)?;
185 if preprocess {
186 compiler = compiler.with_preprocessor(DynamicTestLinkingPreprocessor);
187 }
188 compiler.compile().map_err(Into::into)
189 })
190 }
191
192 fn compile_with<C: Compiler<CompilerContract = Contract>, F>(
194 self,
195 f: F,
196 ) -> Result<ProjectCompileOutput<C>>
197 where
198 F: FnOnce() -> Result<ProjectCompileOutput<C>>,
199 {
200 let quiet = self.quiet.unwrap_or(false);
201 let bail = self.bail.unwrap_or(true);
202
203 let output = with_compilation_reporter(quiet, Some(self.project_root.clone()), || {
204 tracing::debug!("compiling project");
205
206 let timer = Instant::now();
207 let r = f();
208 let elapsed = timer.elapsed();
209
210 tracing::debug!("finished compiling in {:.3}s", elapsed.as_secs_f64());
211 r
212 })?;
213
214 if bail && output.has_compiler_errors() {
215 eyre::bail!("{output}")
216 }
217
218 if !quiet {
219 if !shell::is_json() {
220 if output.is_unchanged() {
221 sh_println!("No files changed, compilation skipped")?;
222 } else {
223 sh_println!("{output}")?;
225 }
226 }
227
228 self.handle_output(&output)?;
229 }
230
231 Ok(output)
232 }
233
234 fn handle_output<C: Compiler<CompilerContract = Contract>>(
236 &self,
237 output: &ProjectCompileOutput<C>,
238 ) -> Result<()> {
239 let print_names = self.print_names.unwrap_or(false);
240 let print_sizes = self.print_sizes.unwrap_or(false);
241
242 if print_names {
244 let mut artifacts: BTreeMap<_, Vec<_>> = BTreeMap::new();
245 for (name, (_, version)) in output.versioned_artifacts() {
246 artifacts.entry(version).or_default().push(name);
247 }
248
249 if shell::is_json() {
250 sh_println!("{}", serde_json::to_string(&artifacts).unwrap())?;
251 } else {
252 for (version, names) in artifacts {
253 sh_println!(
254 " compiler version: {}.{}.{}",
255 version.major,
256 version.minor,
257 version.patch
258 )?;
259 for name in names {
260 sh_println!(" - {name}")?;
261 }
262 }
263 }
264 }
265
266 if print_sizes {
267 if print_names && !shell::is_json() {
269 sh_println!()?;
270 }
271
272 let mut size_report =
273 SizeReport { contracts: BTreeMap::new(), limits: self.size_limits };
274
275 let mut artifacts: BTreeMap<String, Vec<_>> = BTreeMap::new();
276 for (id, artifact) in output.artifact_ids().filter(|(id, _)| {
277 !id.source.to_string_lossy().contains("/forge-std/src/")
279 }) {
280 artifacts.entry(id.name.clone()).or_default().push((id.source.clone(), artifact));
281 }
282
283 for (name, artifact_list) in artifacts {
284 for (path, artifact) in &artifact_list {
285 let runtime_size = contract_size(*artifact, false).unwrap_or_default();
286 let init_size = contract_size(*artifact, true).unwrap_or_default();
287
288 let is_dev_contract = artifact
289 .abi
290 .as_ref()
291 .map(|abi| {
292 abi.functions().any(|f| {
293 f.test_function_kind().is_known()
294 || matches!(f.name.as_str(), "IS_TEST" | "IS_SCRIPT")
295 })
296 })
297 .unwrap_or(false);
298
299 let unique_name = if artifact_list.len() > 1 {
300 format!(
301 "{} ({})",
302 name,
303 path.strip_prefix(&self.project_root).unwrap_or(path).display()
304 )
305 } else {
306 name.clone()
307 };
308
309 size_report.contracts.insert(
310 unique_name,
311 ContractInfo { runtime_size, init_size, is_dev_contract },
312 );
313 }
314 }
315
316 sh_println!("{size_report}")?;
317
318 let runtime_eip = if size_report.limits.runtime == CONTRACT_RUNTIME_SIZE_LIMIT {
319 "EIP-170: "
320 } else {
321 ""
322 };
323 eyre::ensure!(
324 !size_report.exceeds_runtime_size_limit(),
325 "some contracts exceed the runtime size limit ({runtime_eip}{} bytes)",
326 size_report.limits.runtime
327 );
328 let initcode_eip = if size_report.limits.initcode == CONTRACT_INITCODE_SIZE_LIMIT {
330 "EIP-3860: "
331 } else {
332 ""
333 };
334 eyre::ensure!(
335 self.ignore_eip_3860 || !size_report.exceeds_initcode_size_limit(),
336 "some contracts exceed the initcode size limit ({initcode_eip}{} bytes)",
337 size_report.limits.initcode
338 );
339 }
340
341 Ok(())
342 }
343}
344
345const CONTRACT_RUNTIME_SIZE_LIMIT: usize = 24576;
347
348const CONTRACT_INITCODE_SIZE_LIMIT: usize = 49152;
350
351const CONTRACT_RUNTIME_SIZE_WARN_THRESHOLD: usize = 18_000;
352const CONTRACT_INITCODE_SIZE_WARN_THRESHOLD: usize = 36_000;
353
354#[derive(Clone, Copy, Debug, PartialEq, Eq)]
356pub struct ContractSizeLimits {
357 pub runtime: usize,
359 pub initcode: usize,
361}
362
363impl ContractSizeLimits {
364 pub const fn new(runtime: usize, initcode: usize) -> Self {
366 Self { runtime, initcode }
367 }
368
369 pub const fn with_runtime_limit(runtime: usize) -> Self {
371 Self { runtime, initcode: runtime.saturating_mul(2) }
372 }
373
374 const fn runtime_warning_threshold(self) -> usize {
375 scaled_threshold(
376 self.runtime,
377 CONTRACT_RUNTIME_SIZE_WARN_THRESHOLD,
378 CONTRACT_RUNTIME_SIZE_LIMIT,
379 )
380 }
381
382 const fn initcode_warning_threshold(self) -> usize {
383 scaled_threshold(
384 self.initcode,
385 CONTRACT_INITCODE_SIZE_WARN_THRESHOLD,
386 CONTRACT_INITCODE_SIZE_LIMIT,
387 )
388 }
389}
390
391impl Default for ContractSizeLimits {
392 fn default() -> Self {
393 Self::new(CONTRACT_RUNTIME_SIZE_LIMIT, CONTRACT_INITCODE_SIZE_LIMIT)
394 }
395}
396
397const fn scaled_threshold(limit: usize, threshold: usize, default_limit: usize) -> usize {
398 limit.saturating_mul(threshold) / default_limit
399}
400
401pub struct SizeReport {
403 pub contracts: BTreeMap<String, ContractInfo>,
405 pub limits: ContractSizeLimits,
407}
408
409impl SizeReport {
410 pub fn max_runtime_size(&self) -> usize {
412 self.contracts
413 .values()
414 .filter(|c| !c.is_dev_contract)
415 .map(|c| c.runtime_size)
416 .max()
417 .unwrap_or(0)
418 }
419
420 pub fn max_init_size(&self) -> usize {
422 self.contracts
423 .values()
424 .filter(|c| !c.is_dev_contract)
425 .map(|c| c.init_size)
426 .max()
427 .unwrap_or(0)
428 }
429
430 pub fn exceeds_runtime_size_limit(&self) -> bool {
432 self.max_runtime_size() > self.limits.runtime
433 }
434
435 pub fn exceeds_initcode_size_limit(&self) -> bool {
437 self.max_init_size() > self.limits.initcode
438 }
439}
440
441impl Display for SizeReport {
442 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
443 if shell::is_json() {
444 writeln!(f, "{}", self.format_json_output())?;
445 } else {
446 writeln!(f, "\n{}", self.format_table_output())?;
447 }
448 Ok(())
449 }
450}
451
452impl SizeReport {
453 fn format_json_output(&self) -> String {
454 let contracts = self
455 .contracts
456 .iter()
457 .filter(|(_, c)| !c.is_dev_contract && (c.runtime_size > 0 || c.init_size > 0))
458 .map(|(name, contract)| {
459 (
460 name.clone(),
461 serde_json::json!({
462 "runtime_size": contract.runtime_size,
463 "init_size": contract.init_size,
464 "runtime_margin": self.limits.runtime as isize - contract.runtime_size as isize,
465 "init_margin": self.limits.initcode as isize - contract.init_size as isize,
466 }),
467 )
468 })
469 .collect::<serde_json::Map<_, _>>();
470
471 serde_json::to_string(&contracts).unwrap()
472 }
473
474 fn format_table_output(&self) -> Table {
475 let mut table = Table::new();
476 if shell::is_markdown() {
477 table.load_preset(ASCII_MARKDOWN);
478 } else {
479 table.apply_modifier(UTF8_ROUND_CORNERS);
480 }
481
482 table.set_header(vec![
483 Cell::new("Contract"),
484 Cell::new("Runtime Size (B)"),
485 Cell::new("Initcode Size (B)"),
486 Cell::new("Runtime Margin (B)"),
487 Cell::new("Initcode Margin (B)"),
488 ]);
489
490 let contracts = self
492 .contracts
493 .iter()
494 .filter(|(_, c)| !c.is_dev_contract && (c.runtime_size > 0 || c.init_size > 0));
495 let runtime_warning_threshold = self.limits.runtime_warning_threshold();
496 let initcode_warning_threshold = self.limits.initcode_warning_threshold();
497 for (name, contract) in contracts {
498 let runtime_margin = self.limits.runtime as isize - contract.runtime_size as isize;
499 let init_margin = self.limits.initcode as isize - contract.init_size as isize;
500
501 let runtime_color = if contract.runtime_size < runtime_warning_threshold {
502 Color::Reset
503 } else if contract.runtime_size <= self.limits.runtime {
504 Color::Yellow
505 } else {
506 Color::Red
507 };
508
509 let init_color = if contract.init_size < initcode_warning_threshold {
510 Color::Reset
511 } else if contract.init_size <= self.limits.initcode {
512 Color::Yellow
513 } else {
514 Color::Red
515 };
516
517 let locale = &Locale::en;
518 table.add_row([
519 Cell::new(name),
520 Cell::new(contract.runtime_size.to_formatted_string(locale)).fg(runtime_color),
521 Cell::new(contract.init_size.to_formatted_string(locale)).fg(init_color),
522 Cell::new(runtime_margin.to_formatted_string(locale)).fg(runtime_color),
523 Cell::new(init_margin.to_formatted_string(locale)).fg(init_color),
524 ]);
525 }
526
527 table
528 }
529}
530
531fn contract_size<T: Artifact>(artifact: &T, initcode: bool) -> Option<usize> {
533 let bytecode = if initcode {
534 artifact.get_bytecode_object()?
535 } else {
536 artifact.get_deployed_bytecode_object()?
537 };
538
539 let size = match bytecode.as_ref() {
540 BytecodeObject::Bytecode(bytes) => bytes.len(),
541 BytecodeObject::Unlinked(unlinked) => {
542 let mut size = unlinked.len();
545 if unlinked.starts_with("0x") {
546 size -= 2;
547 }
548 size / 2
550 }
551 };
552
553 Some(size)
554}
555
556#[derive(Clone, Copy, Debug)]
558pub struct ContractInfo {
559 pub runtime_size: usize,
561 pub init_size: usize,
563 pub is_dev_contract: bool,
565}
566
567pub fn compile_target<C: Compiler<CompilerContract = Contract>>(
574 target_path: &Path,
575 project: &Project<C>,
576 quiet: bool,
577) -> Result<ProjectCompileOutput<C>>
578where
579 DynamicTestLinkingPreprocessor: Preprocessor<C>,
580{
581 ProjectCompiler::new().quiet(quiet).files([target_path.into()]).compile(project)
582}
583
584pub fn etherscan_project(metadata: &Metadata, target_path: &Path) -> Result<Project> {
586 let target_path = dunce::canonicalize(target_path)?;
587 let sources_path = target_path.join(&metadata.contract_name);
588 metadata.source_tree().write_to(&target_path)?;
589
590 let mut settings = metadata.settings()?;
591
592 for remapping in &mut settings.remappings {
594 let new_path = sources_path.join(remapping.path.trim_start_matches('/'));
595 remapping.path = new_path.display().to_string();
596 }
597
598 if !settings.remappings.iter().any(|remapping| remapping.name.starts_with("@openzeppelin/")) {
600 let oz = Remapping {
601 context: None,
602 name: "@openzeppelin/".into(),
603 path: sources_path.join("@openzeppelin").display().to_string(),
604 };
605 settings.remappings.push(oz);
606 }
607
608 let paths = ProjectPathsConfig::builder()
612 .sources(sources_path.clone())
613 .remappings(settings.remappings.clone())
614 .build_with_root(sources_path);
615
616 let v = metadata.compiler_version()?;
618 let solc = Solc::find_or_install(&v)?;
619
620 let compiler = MultiCompiler { solc: Some(SolcCompiler::Specific(solc)), vyper: None };
621
622 Ok(ProjectBuilder::<MultiCompiler>::default()
623 .settings(MultiCompilerSettings {
624 solc: SolcSettings {
625 settings: SolcConfig::builder().settings(settings).build(),
626 ..Default::default()
627 },
628 ..Default::default()
629 })
630 .paths(paths)
631 .ephemeral()
632 .no_artifacts()
633 .build(compiler)?)
634}
635
636pub fn with_compilation_reporter<O>(
643 quiet: bool,
644 project_root: Option<PathBuf>,
645 f: impl FnOnce() -> O,
646) -> O {
647 #[expect(clippy::collapsible_else_if)]
648 let reporter = if quiet || shell::is_json() {
649 Report::new(NoReporter::default())
650 } else {
651 if std::io::stderr().is_terminal() {
652 Report::new(SpinnerReporter::spawn(project_root))
653 } else {
654 Report::new(BasicStdoutReporter::default())
655 }
656 };
657
658 foundry_compilers::report::with_scoped(&reporter, f)
659}
660
661#[derive(Clone, PartialEq, Eq)]
668pub enum PathOrContractInfo {
669 Path(PathBuf),
671 ContractInfo(CompilerContractInfo),
673}
674
675impl PathOrContractInfo {
676 pub fn path(&self) -> Option<PathBuf> {
678 match self {
679 Self::Path(path) => Some(path.clone()),
680 Self::ContractInfo(info) => info.path.as_ref().map(PathBuf::from),
681 }
682 }
683
684 pub fn name(&self) -> Option<&str> {
686 match self {
687 Self::Path(_) => None,
688 Self::ContractInfo(info) => Some(&info.name),
689 }
690 }
691}
692
693impl FromStr for PathOrContractInfo {
694 type Err = eyre::Error;
695
696 fn from_str(s: &str) -> Result<Self> {
697 if let Ok(contract) = CompilerContractInfo::from_str(s) {
698 return Ok(Self::ContractInfo(contract));
699 }
700 let path = PathBuf::from(s);
701 if path.extension().is_some_and(|ext| ext == "sol" || ext == "vy") {
702 return Ok(Self::Path(path));
703 }
704 Err(eyre::eyre!("Invalid contract identifier, file is not *.sol or *.vy: {}", s))
705 }
706}
707
708impl std::fmt::Debug for PathOrContractInfo {
709 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
710 match self {
711 Self::Path(path) => write!(f, "Path({})", path.display()),
712 Self::ContractInfo(info) => {
713 write!(f, "ContractInfo({info})")
714 }
715 }
716 }
717}
718
719#[cfg(test)]
720mod tests {
721 use super::*;
722
723 #[test]
724 fn parse_contract_identifiers() {
725 let t = ["src/Counter.sol", "src/Counter.sol:Counter", "Counter"];
726
727 let i1 = PathOrContractInfo::from_str(t[0]).unwrap();
728 assert_eq!(i1, PathOrContractInfo::Path(PathBuf::from(t[0])));
729
730 let i2 = PathOrContractInfo::from_str(t[1]).unwrap();
731 assert_eq!(
732 i2,
733 PathOrContractInfo::ContractInfo(CompilerContractInfo {
734 path: Some("src/Counter.sol".to_string()),
735 name: "Counter".to_string()
736 })
737 );
738
739 let i3 = PathOrContractInfo::from_str(t[2]).unwrap();
740 assert_eq!(
741 i3,
742 PathOrContractInfo::ContractInfo(CompilerContractInfo {
743 path: None,
744 name: "Counter".to_string()
745 })
746 );
747 }
748
749 #[test]
750 fn size_report_uses_configured_limits() {
751 let mut contracts = BTreeMap::new();
752 contracts.insert(
753 "LargeContract".to_string(),
754 ContractInfo { runtime_size: 30_000, init_size: 60_000, is_dev_contract: false },
755 );
756
757 let default_report =
758 SizeReport { contracts: contracts.clone(), limits: ContractSizeLimits::default() };
759 assert!(default_report.exceeds_runtime_size_limit());
760 assert!(default_report.exceeds_initcode_size_limit());
761
762 let custom_report =
763 SizeReport { contracts, limits: ContractSizeLimits::new(131_072, 262_144) };
764 assert!(!custom_report.exceeds_runtime_size_limit());
765 assert!(!custom_report.exceeds_initcode_size_limit());
766 let output: serde_json::Value =
767 serde_json::from_str(&custom_report.format_json_output()).unwrap();
768 assert_eq!(
769 output,
770 serde_json::json!({
771 "LargeContract": {
772 "runtime_size": 30000,
773 "init_size": 60000,
774 "runtime_margin": 101072,
775 "init_margin": 202144,
776 }
777 })
778 );
779 }
780
781 #[test]
782 fn contract_size_limits_derive_initcode_limit_from_runtime_limit() {
783 assert_eq!(
784 ContractSizeLimits::with_runtime_limit(50_000),
785 ContractSizeLimits::new(50_000, 100_000)
786 );
787 }
788}