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    sync::Arc,
30    time::Instant,
31};
32
33/// A Solar compiler instance, to grant syntactic and semantic analysis capabilities.
34pub type Analysis = Arc<solar::sema::Compiler>;
35
36/// Builder type to configure how to compile a project.
37///
38/// This is merely a wrapper for [`Project::compile()`] which also prints to stdout depending on its
39/// settings.
40#[must_use = "ProjectCompiler does nothing unless you call a `compile*` method"]
41pub struct ProjectCompiler {
42    /// The root of the project.
43    project_root: PathBuf,
44
45    /// Whether to also print contract names.
46    print_names: Option<bool>,
47
48    /// Whether to also print contract sizes.
49    print_sizes: Option<bool>,
50
51    /// Whether to print anything at all. Overrides other `print` options.
52    quiet: Option<bool>,
53
54    /// Whether to bail on compiler errors.
55    bail: Option<bool>,
56
57    /// Whether to ignore the contract initcode size limit introduced by EIP-3860.
58    ignore_eip_3860: bool,
59
60    /// Extra files to include, that are not necessarily in the project's source directory.
61    files: Vec<PathBuf>,
62
63    /// Whether to compile with dynamic linking tests and scripts.
64    dynamic_test_linking: bool,
65}
66
67impl Default for ProjectCompiler {
68    #[inline]
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74impl ProjectCompiler {
75    /// Create a new builder with the default settings.
76    #[inline]
77    pub fn new() -> Self {
78        Self {
79            project_root: PathBuf::new(),
80            print_names: None,
81            print_sizes: None,
82            quiet: Some(crate::shell::is_quiet()),
83            bail: None,
84            ignore_eip_3860: false,
85            files: Vec::new(),
86            dynamic_test_linking: false,
87        }
88    }
89
90    /// Sets whether to print contract names.
91    #[inline]
92    pub fn print_names(mut self, yes: bool) -> Self {
93        self.print_names = Some(yes);
94        self
95    }
96
97    /// Sets whether to print contract sizes.
98    #[inline]
99    pub fn print_sizes(mut self, yes: bool) -> Self {
100        self.print_sizes = Some(yes);
101        self
102    }
103
104    /// Sets whether to print anything at all. Overrides other `print` options.
105    #[inline]
106    #[doc(alias = "silent")]
107    pub fn quiet(mut self, yes: bool) -> Self {
108        self.quiet = Some(yes);
109        self
110    }
111
112    /// Sets whether to bail on compiler errors.
113    #[inline]
114    pub fn bail(mut self, yes: bool) -> Self {
115        self.bail = Some(yes);
116        self
117    }
118
119    /// Sets whether to ignore EIP-3860 initcode size limits.
120    #[inline]
121    pub fn ignore_eip_3860(mut self, yes: bool) -> Self {
122        self.ignore_eip_3860 = yes;
123        self
124    }
125
126    /// Sets extra files to include, that are not necessarily in the project's source dir.
127    #[inline]
128    pub fn files(mut self, files: impl IntoIterator<Item = PathBuf>) -> Self {
129        self.files.extend(files);
130        self
131    }
132
133    /// Sets if tests should be dynamically linked.
134    #[inline]
135    pub fn dynamic_test_linking(mut self, preprocess: bool) -> Self {
136        self.dynamic_test_linking = preprocess;
137        self
138    }
139
140    /// Compiles the project.
141    #[instrument(target = "forge::compile", skip_all)]
142    pub fn compile<C: Compiler<CompilerContract = Contract>>(
143        mut self,
144        project: &Project<C>,
145    ) -> Result<ProjectCompileOutput<C>>
146    where
147        DynamicTestLinkingPreprocessor: Preprocessor<C>,
148    {
149        self.project_root = project.root().to_path_buf();
150
151        // TODO: Avoid using std::process::exit(0).
152        // Replacing this with a return (e.g., Ok(ProjectCompileOutput::default())) would be more
153        // idiomatic, but it currently requires a `Default` bound on `C::Language`, which
154        // breaks compatibility with downstream crates like `foundry-cli`. This would need a
155        // broader refactor across the call chain. Leaving it as-is for now until a larger
156        // refactor is feasible.
157        if !project.paths.has_input_files() && self.files.is_empty() {
158            sh_println!("Nothing to compile")?;
159            std::process::exit(0);
160        }
161
162        // Taking is fine since we don't need these in `compile_with`.
163        let files = std::mem::take(&mut self.files);
164        let preprocess = self.dynamic_test_linking;
165        self.compile_with(|| {
166            let sources = if !files.is_empty() {
167                Source::read_all(files)?
168            } else {
169                project.paths.read_input_files()?
170            };
171
172            let mut compiler =
173                foundry_compilers::project::ProjectCompiler::with_sources(project, sources)?;
174            if preprocess {
175                compiler = compiler.with_preprocessor(DynamicTestLinkingPreprocessor);
176            }
177            compiler.compile().map_err(Into::into)
178        })
179    }
180
181    /// Compiles the project with the given closure
182    fn compile_with<C: Compiler<CompilerContract = Contract>, F>(
183        self,
184        f: F,
185    ) -> Result<ProjectCompileOutput<C>>
186    where
187        F: FnOnce() -> Result<ProjectCompileOutput<C>>,
188    {
189        let quiet = self.quiet.unwrap_or(false);
190        let bail = self.bail.unwrap_or(true);
191
192        let output = with_compilation_reporter(quiet, Some(self.project_root.clone()), || {
193            tracing::debug!("compiling project");
194
195            let timer = Instant::now();
196            let r = f();
197            let elapsed = timer.elapsed();
198
199            tracing::debug!("finished compiling in {:.3}s", elapsed.as_secs_f64());
200            r
201        })?;
202
203        if bail && output.has_compiler_errors() {
204            eyre::bail!("{output}")
205        }
206
207        if !quiet {
208            if !shell::is_json() {
209                if output.is_unchanged() {
210                    sh_println!("No files changed, compilation skipped")?;
211                } else {
212                    // print the compiler output / warnings
213                    sh_println!("{output}")?;
214                }
215            }
216
217            self.handle_output(&output)?;
218        }
219
220        Ok(output)
221    }
222
223    /// If configured, this will print sizes or names
224    fn handle_output<C: Compiler<CompilerContract = Contract>>(
225        &self,
226        output: &ProjectCompileOutput<C>,
227    ) -> Result<()> {
228        let print_names = self.print_names.unwrap_or(false);
229        let print_sizes = self.print_sizes.unwrap_or(false);
230
231        // print any sizes or names
232        if print_names {
233            let mut artifacts: BTreeMap<_, Vec<_>> = BTreeMap::new();
234            for (name, (_, version)) in output.versioned_artifacts() {
235                artifacts.entry(version).or_default().push(name);
236            }
237
238            if shell::is_json() {
239                sh_println!("{}", serde_json::to_string(&artifacts).unwrap())?;
240            } else {
241                for (version, names) in artifacts {
242                    sh_println!(
243                        "  compiler version: {}.{}.{}",
244                        version.major,
245                        version.minor,
246                        version.patch
247                    )?;
248                    for name in names {
249                        sh_println!("    - {name}")?;
250                    }
251                }
252            }
253        }
254
255        if print_sizes {
256            // add extra newline if names were already printed
257            if print_names && !shell::is_json() {
258                sh_println!()?;
259            }
260
261            let mut size_report = SizeReport { contracts: BTreeMap::new() };
262
263            let mut artifacts: BTreeMap<String, Vec<_>> = BTreeMap::new();
264            for (id, artifact) in output.artifact_ids().filter(|(id, _)| {
265                // filter out forge-std specific contracts
266                !id.source.to_string_lossy().contains("/forge-std/src/")
267            }) {
268                artifacts.entry(id.name.clone()).or_default().push((id.source.clone(), artifact));
269            }
270
271            for (name, artifact_list) in artifacts {
272                for (path, artifact) in &artifact_list {
273                    let runtime_size = contract_size(*artifact, false).unwrap_or_default();
274                    let init_size = contract_size(*artifact, true).unwrap_or_default();
275
276                    let is_dev_contract = artifact
277                        .abi
278                        .as_ref()
279                        .map(|abi| {
280                            abi.functions().any(|f| {
281                                f.test_function_kind().is_known()
282                                    || matches!(f.name.as_str(), "IS_TEST" | "IS_SCRIPT")
283                            })
284                        })
285                        .unwrap_or(false);
286
287                    let unique_name = if artifact_list.len() > 1 {
288                        format!(
289                            "{} ({})",
290                            name,
291                            path.strip_prefix(&self.project_root).unwrap_or(path).display()
292                        )
293                    } else {
294                        name.clone()
295                    };
296
297                    size_report.contracts.insert(
298                        unique_name,
299                        ContractInfo { runtime_size, init_size, is_dev_contract },
300                    );
301                }
302            }
303
304            sh_println!("{size_report}")?;
305
306            eyre::ensure!(
307                !size_report.exceeds_runtime_size_limit(),
308                "some contracts exceed the runtime size limit \
309                 (EIP-170: {CONTRACT_RUNTIME_SIZE_LIMIT} bytes)"
310            );
311            // Check size limits only if not ignoring EIP-3860
312            eyre::ensure!(
313                self.ignore_eip_3860 || !size_report.exceeds_initcode_size_limit(),
314                "some contracts exceed the initcode size limit \
315                 (EIP-3860: {CONTRACT_INITCODE_SIZE_LIMIT} bytes)"
316            );
317        }
318
319        Ok(())
320    }
321}
322
323// https://eips.ethereum.org/EIPS/eip-170
324const CONTRACT_RUNTIME_SIZE_LIMIT: usize = 24576;
325
326// https://eips.ethereum.org/EIPS/eip-3860
327const CONTRACT_INITCODE_SIZE_LIMIT: usize = 49152;
328
329/// Contracts with info about their size
330pub struct SizeReport {
331    /// `contract name -> info`
332    pub contracts: BTreeMap<String, ContractInfo>,
333}
334
335impl SizeReport {
336    /// Returns the maximum runtime code size, excluding dev contracts.
337    pub fn max_runtime_size(&self) -> usize {
338        self.contracts
339            .values()
340            .filter(|c| !c.is_dev_contract)
341            .map(|c| c.runtime_size)
342            .max()
343            .unwrap_or(0)
344    }
345
346    /// Returns the maximum initcode size, excluding dev contracts.
347    pub fn max_init_size(&self) -> usize {
348        self.contracts
349            .values()
350            .filter(|c| !c.is_dev_contract)
351            .map(|c| c.init_size)
352            .max()
353            .unwrap_or(0)
354    }
355
356    /// Returns true if any contract exceeds the runtime size limit, excluding dev contracts.
357    pub fn exceeds_runtime_size_limit(&self) -> bool {
358        self.max_runtime_size() > CONTRACT_RUNTIME_SIZE_LIMIT
359    }
360
361    /// Returns true if any contract exceeds the initcode size limit, excluding dev contracts.
362    pub fn exceeds_initcode_size_limit(&self) -> bool {
363        self.max_init_size() > CONTRACT_INITCODE_SIZE_LIMIT
364    }
365}
366
367impl Display for SizeReport {
368    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
369        if shell::is_json() {
370            writeln!(f, "{}", self.format_json_output())?;
371        } else {
372            writeln!(f, "\n{}", self.format_table_output())?;
373        }
374        Ok(())
375    }
376}
377
378impl SizeReport {
379    fn format_json_output(&self) -> String {
380        let contracts = self
381            .contracts
382            .iter()
383            .filter(|(_, c)| !c.is_dev_contract && (c.runtime_size > 0 || c.init_size > 0))
384            .map(|(name, contract)| {
385                (
386                    name.clone(),
387                    serde_json::json!({
388                        "runtime_size": contract.runtime_size,
389                        "init_size": contract.init_size,
390                        "runtime_margin": CONTRACT_RUNTIME_SIZE_LIMIT as isize - contract.runtime_size as isize,
391                        "init_margin": CONTRACT_INITCODE_SIZE_LIMIT as isize - contract.init_size as isize,
392                    }),
393                )
394            })
395            .collect::<serde_json::Map<_, _>>();
396
397        serde_json::to_string(&contracts).unwrap()
398    }
399
400    fn format_table_output(&self) -> Table {
401        let mut table = Table::new();
402        if shell::is_markdown() {
403            table.load_preset(ASCII_MARKDOWN);
404        } else {
405            table.apply_modifier(UTF8_ROUND_CORNERS);
406        }
407
408        table.set_header(vec![
409            Cell::new("Contract"),
410            Cell::new("Runtime Size (B)"),
411            Cell::new("Initcode Size (B)"),
412            Cell::new("Runtime Margin (B)"),
413            Cell::new("Initcode Margin (B)"),
414        ]);
415
416        // Filters out dev contracts (Test or Script)
417        let contracts = self
418            .contracts
419            .iter()
420            .filter(|(_, c)| !c.is_dev_contract && (c.runtime_size > 0 || c.init_size > 0));
421        for (name, contract) in contracts {
422            let runtime_margin =
423                CONTRACT_RUNTIME_SIZE_LIMIT as isize - contract.runtime_size as isize;
424            let init_margin = CONTRACT_INITCODE_SIZE_LIMIT as isize - contract.init_size as isize;
425
426            let runtime_color = match contract.runtime_size {
427                ..18_000 => Color::Reset,
428                18_000..=CONTRACT_RUNTIME_SIZE_LIMIT => Color::Yellow,
429                _ => Color::Red,
430            };
431
432            let init_color = match contract.init_size {
433                ..36_000 => Color::Reset,
434                36_000..=CONTRACT_INITCODE_SIZE_LIMIT => Color::Yellow,
435                _ => Color::Red,
436            };
437
438            let locale = &Locale::en;
439            table.add_row([
440                Cell::new(name),
441                Cell::new(contract.runtime_size.to_formatted_string(locale)).fg(runtime_color),
442                Cell::new(contract.init_size.to_formatted_string(locale)).fg(init_color),
443                Cell::new(runtime_margin.to_formatted_string(locale)).fg(runtime_color),
444                Cell::new(init_margin.to_formatted_string(locale)).fg(init_color),
445            ]);
446        }
447
448        table
449    }
450}
451
452/// Returns the deployed or init size of the contract.
453fn contract_size<T: Artifact>(artifact: &T, initcode: bool) -> Option<usize> {
454    let bytecode = if initcode {
455        artifact.get_bytecode_object()?
456    } else {
457        artifact.get_deployed_bytecode_object()?
458    };
459
460    let size = match bytecode.as_ref() {
461        BytecodeObject::Bytecode(bytes) => bytes.len(),
462        BytecodeObject::Unlinked(unlinked) => {
463            // we don't need to account for placeholders here, because library placeholders take up
464            // 40 characters: `__$<library hash>$__` which is the same as a 20byte address in hex.
465            let mut size = unlinked.len();
466            if unlinked.starts_with("0x") {
467                size -= 2;
468            }
469            // hex -> bytes
470            size / 2
471        }
472    };
473
474    Some(size)
475}
476
477/// How big the contract is and whether it is a dev contract where size limits can be neglected
478#[derive(Clone, Copy, Debug)]
479pub struct ContractInfo {
480    /// Size of the runtime code in bytes
481    pub runtime_size: usize,
482    /// Size of the initcode in bytes
483    pub init_size: usize,
484    /// A development contract is either a Script or a Test contract.
485    pub is_dev_contract: bool,
486}
487
488/// Compiles target file path.
489///
490/// If `quiet` no solc related output will be emitted to stdout.
491///
492/// **Note:** this expects the `target_path` to be absolute
493pub fn compile_target<C: Compiler<CompilerContract = Contract>>(
494    target_path: &Path,
495    project: &Project<C>,
496    quiet: bool,
497) -> Result<ProjectCompileOutput<C>>
498where
499    DynamicTestLinkingPreprocessor: Preprocessor<C>,
500{
501    ProjectCompiler::new().quiet(quiet).files([target_path.into()]).compile(project)
502}
503
504/// Creates a [Project] from an Etherscan source.
505pub fn etherscan_project(metadata: &Metadata, target_path: &Path) -> Result<Project> {
506    let target_path = dunce::canonicalize(target_path)?;
507    let sources_path = target_path.join(&metadata.contract_name);
508    metadata.source_tree().write_to(&target_path)?;
509
510    let mut settings = metadata.settings()?;
511
512    // make remappings absolute with our root
513    for remapping in &mut settings.remappings {
514        let new_path = sources_path.join(remapping.path.trim_start_matches('/'));
515        remapping.path = new_path.display().to_string();
516    }
517
518    // add missing remappings
519    if !settings.remappings.iter().any(|remapping| remapping.name.starts_with("@openzeppelin/")) {
520        let oz = Remapping {
521            context: None,
522            name: "@openzeppelin/".into(),
523            path: sources_path.join("@openzeppelin").display().to_string(),
524        };
525        settings.remappings.push(oz);
526    }
527
528    // root/
529    //   ContractName/
530    //     [source code]
531    let paths = ProjectPathsConfig::builder()
532        .sources(sources_path.clone())
533        .remappings(settings.remappings.clone())
534        .build_with_root(sources_path);
535
536    // TODO: detect vyper
537    let v = metadata.compiler_version()?;
538    let solc = Solc::find_or_install(&v)?;
539
540    let compiler = MultiCompiler { solc: Some(SolcCompiler::Specific(solc)), vyper: None };
541
542    Ok(ProjectBuilder::<MultiCompiler>::default()
543        .settings(MultiCompilerSettings {
544            solc: SolcSettings {
545                settings: SolcConfig::builder().settings(settings).build(),
546                ..Default::default()
547            },
548            ..Default::default()
549        })
550        .paths(paths)
551        .ephemeral()
552        .no_artifacts()
553        .build(compiler)?)
554}
555
556/// Configures the reporter and runs the given closure.
557pub fn with_compilation_reporter<O>(
558    quiet: bool,
559    project_root: Option<PathBuf>,
560    f: impl FnOnce() -> O,
561) -> O {
562    #[expect(clippy::collapsible_else_if)]
563    let reporter = if quiet || shell::is_json() {
564        Report::new(NoReporter::default())
565    } else {
566        if std::io::stdout().is_terminal() {
567            Report::new(SpinnerReporter::spawn(project_root))
568        } else {
569            Report::new(BasicStdoutReporter::default())
570        }
571    };
572
573    foundry_compilers::report::with_scoped(&reporter, f)
574}
575
576/// Container type for parsing contract identifiers from CLI.
577///
578/// Passed string can be of the following forms:
579/// - `src/Counter.sol` - path to the contract file, in the case where it only contains one contract
580/// - `src/Counter.sol:Counter` - path to the contract file and the contract name
581/// - `Counter` - contract name only
582#[derive(Clone, PartialEq, Eq)]
583pub enum PathOrContractInfo {
584    /// Non-canonicalized path provided via CLI.
585    Path(PathBuf),
586    /// Contract info provided via CLI.
587    ContractInfo(CompilerContractInfo),
588}
589
590impl PathOrContractInfo {
591    /// Returns the path to the contract file if provided.
592    pub fn path(&self) -> Option<PathBuf> {
593        match self {
594            Self::Path(path) => Some(path.to_path_buf()),
595            Self::ContractInfo(info) => info.path.as_ref().map(PathBuf::from),
596        }
597    }
598
599    /// Returns the contract name if provided.
600    pub fn name(&self) -> Option<&str> {
601        match self {
602            Self::Path(_) => None,
603            Self::ContractInfo(info) => Some(&info.name),
604        }
605    }
606}
607
608impl FromStr for PathOrContractInfo {
609    type Err = eyre::Error;
610
611    fn from_str(s: &str) -> Result<Self> {
612        if let Ok(contract) = CompilerContractInfo::from_str(s) {
613            return Ok(Self::ContractInfo(contract));
614        }
615        let path = PathBuf::from(s);
616        if path.extension().is_some_and(|ext| ext == "sol" || ext == "vy") {
617            return Ok(Self::Path(path));
618        }
619        Err(eyre::eyre!("Invalid contract identifier, file is not *.sol or *.vy: {}", s))
620    }
621}
622
623impl std::fmt::Debug for PathOrContractInfo {
624    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
625        match self {
626            Self::Path(path) => write!(f, "Path({})", path.display()),
627            Self::ContractInfo(info) => {
628                write!(f, "ContractInfo({info})")
629            }
630        }
631    }
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637
638    #[test]
639    fn parse_contract_identifiers() {
640        let t = ["src/Counter.sol", "src/Counter.sol:Counter", "Counter"];
641
642        let i1 = PathOrContractInfo::from_str(t[0]).unwrap();
643        assert_eq!(i1, PathOrContractInfo::Path(PathBuf::from(t[0])));
644
645        let i2 = PathOrContractInfo::from_str(t[1]).unwrap();
646        assert_eq!(
647            i2,
648            PathOrContractInfo::ContractInfo(CompilerContractInfo {
649                path: Some("src/Counter.sol".to_string()),
650                name: "Counter".to_string()
651            })
652        );
653
654        let i3 = PathOrContractInfo::from_str(t[2]).unwrap();
655        assert_eq!(
656            i3,
657            PathOrContractInfo::ContractInfo(CompilerContractInfo {
658                path: None,
659                name: "Counter".to_string()
660            })
661        );
662    }
663}