foundry_common/
compile.rs

1//! Support for compiling [foundry_compilers::Project]
2
3use 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/// Builder type to configure how to compile a project.
33///
34/// This is merely a wrapper for [`Project::compile()`] which also prints to stdout depending on its
35/// settings.
36#[must_use = "ProjectCompiler does nothing unless you call a `compile*` method"]
37pub struct ProjectCompiler {
38    /// The root of the project.
39    project_root: PathBuf,
40
41    /// Whether to also print contract names.
42    print_names: Option<bool>,
43
44    /// Whether to also print contract sizes.
45    print_sizes: Option<bool>,
46
47    /// Whether to print anything at all. Overrides other `print` options.
48    quiet: Option<bool>,
49
50    /// Whether to bail on compiler errors.
51    bail: Option<bool>,
52
53    /// Whether to ignore the contract initcode size limit introduced by EIP-3860.
54    ignore_eip_3860: bool,
55
56    /// Extra files to include, that are not necessarily in the project's source directory.
57    files: Vec<PathBuf>,
58
59    /// Whether to compile with dynamic linking tests and scripts.
60    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    /// Create a new builder with the default settings.
72    #[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    /// Sets whether to print contract names.
87    #[inline]
88    pub fn print_names(mut self, yes: bool) -> Self {
89        self.print_names = Some(yes);
90        self
91    }
92
93    /// Sets whether to print contract sizes.
94    #[inline]
95    pub fn print_sizes(mut self, yes: bool) -> Self {
96        self.print_sizes = Some(yes);
97        self
98    }
99
100    /// Sets whether to print anything at all. Overrides other `print` options.
101    #[inline]
102    #[doc(alias = "silent")]
103    pub fn quiet(mut self, yes: bool) -> Self {
104        self.quiet = Some(yes);
105        self
106    }
107
108    /// Sets whether to bail on compiler errors.
109    #[inline]
110    pub fn bail(mut self, yes: bool) -> Self {
111        self.bail = Some(yes);
112        self
113    }
114
115    /// Sets whether to ignore EIP-3860 initcode size limits.
116    #[inline]
117    pub fn ignore_eip_3860(mut self, yes: bool) -> Self {
118        self.ignore_eip_3860 = yes;
119        self
120    }
121
122    /// Sets extra files to include, that are not necessarily in the project's source dir.
123    #[inline]
124    pub fn files(mut self, files: impl IntoIterator<Item = PathBuf>) -> Self {
125        self.files.extend(files);
126        self
127    }
128
129    /// Sets if tests should be dynamically linked.
130    #[inline]
131    pub fn dynamic_test_linking(mut self, preprocess: bool) -> Self {
132        self.dynamic_test_linking = preprocess;
133        self
134    }
135
136    /// Compiles the project.
137    #[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        // TODO: Avoid using std::process::exit(0).
148        // Replacing this with a return (e.g., Ok(ProjectCompileOutput::default())) would be more
149        // idiomatic, but it currently requires a `Default` bound on `C::Language`, which
150        // breaks compatibility with downstream crates like `foundry-cli`. This would need a
151        // broader refactor across the call chain. Leaving it as-is for now until a larger
152        // refactor is feasible.
153        if !project.paths.has_input_files() && self.files.is_empty() {
154            sh_println!("Nothing to compile")?;
155            std::process::exit(0);
156        }
157
158        // Taking is fine since we don't need these in `compile_with`.
159        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    /// Compiles the project with the given closure
178    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                    // print the compiler output / warnings
209                    sh_println!("{output}")?;
210                }
211            }
212
213            self.handle_output(&output)?;
214        }
215
216        Ok(output)
217    }
218
219    /// If configured, this will print sizes or names
220    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        // print any sizes or names
228        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            // add extra newline if names were already printed
253            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                // filter out forge-std specific contracts
262                !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            // Check size limits only if not ignoring EIP-3860
308            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
319// https://eips.ethereum.org/EIPS/eip-170
320const CONTRACT_RUNTIME_SIZE_LIMIT: usize = 24576;
321
322// https://eips.ethereum.org/EIPS/eip-3860
323const CONTRACT_INITCODE_SIZE_LIMIT: usize = 49152;
324
325/// Contracts with info about their size
326pub struct SizeReport {
327    /// `contract name -> info`
328    pub contracts: BTreeMap<String, ContractInfo>,
329}
330
331impl SizeReport {
332    /// Returns the maximum runtime code size, excluding dev contracts.
333    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    /// Returns the maximum initcode size, excluding dev contracts.
343    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    /// Returns true if any contract exceeds the runtime size limit, excluding dev contracts.
353    pub fn exceeds_runtime_size_limit(&self) -> bool {
354        self.max_runtime_size() > CONTRACT_RUNTIME_SIZE_LIMIT
355    }
356
357    /// Returns true if any contract exceeds the initcode size limit, excluding dev contracts.
358    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        // Filters out dev contracts (Test or Script)
413        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
448/// Returns the deployed or init size of the contract.
449fn 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            // we don't need to account for placeholders here, because library placeholders take up
460            // 40 characters: `__$<library hash>$__` which is the same as a 20byte address in hex.
461            let mut size = unlinked.len();
462            if unlinked.starts_with("0x") {
463                size -= 2;
464            }
465            // hex -> bytes
466            size / 2
467        }
468    };
469
470    Some(size)
471}
472
473/// How big the contract is and whether it is a dev contract where size limits can be neglected
474#[derive(Clone, Copy, Debug)]
475pub struct ContractInfo {
476    /// Size of the runtime code in bytes
477    pub runtime_size: usize,
478    /// Size of the initcode in bytes
479    pub init_size: usize,
480    /// A development contract is either a Script or a Test contract.
481    pub is_dev_contract: bool,
482}
483
484/// Compiles target file path.
485///
486/// If `quiet` no solc related output will be emitted to stdout.
487///
488/// **Note:** this expects the `target_path` to be absolute
489pub 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
500/// Creates a [Project] from an Etherscan source.
501pub 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    // make remappings absolute with our root
509    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    // add missing remappings
515    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    // root/
525    //   ContractName/
526    //     [source code]
527    let paths = ProjectPathsConfig::builder()
528        .sources(sources_path.clone())
529        .remappings(settings.remappings.clone())
530        .build_with_root(sources_path);
531
532    // TODO: detect vyper
533    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
552/// Configures the reporter and runs the given closure.
553pub 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/// Container type for parsing contract identifiers from CLI.
573///
574/// Passed string can be of the following forms:
575/// - `src/Counter.sol` - path to the contract file, in the case where it only contains one contract
576/// - `src/Counter.sol:Counter` - path to the contract file and the contract name
577/// - `Counter` - contract name only
578#[derive(Clone, PartialEq, Eq)]
579pub enum PathOrContractInfo {
580    /// Non-canonicalized path provided via CLI.
581    Path(PathBuf),
582    /// Contract info provided via CLI.
583    ContractInfo(CompilerContractInfo),
584}
585
586impl PathOrContractInfo {
587    /// Returns the path to the contract file if provided.
588    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    /// Returns the contract name if provided.
596    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}