Skip to main content

foundry_common/
compile.rs

1//! Support for compiling [foundry_compilers::Project]
2
3use crate::{
4    TestFunctionExt, preprocessor::DynamicTestLinkingPreprocessor, shell, term::SpinnerReporter,
5};
6use alloy_json_abi::JsonAbi;
7use comfy_table::{Cell, Color, Table, modifiers::UTF8_ROUND_CORNERS, presets::ASCII_MARKDOWN};
8use eyre::{OptionExt, Result};
9use foundry_block_explorers::contract::Metadata;
10use foundry_compilers::{
11    Artifact, Project, ProjectBuilder, ProjectCompileOutput, ProjectPathsConfig, SolcConfig,
12    artifacts::{
13        BytecodeObject, Contract, Source, output_selection::OutputSelection, remappings::Remapping,
14    },
15    compilers::{
16        Compiler,
17        solc::{Solc, SolcCompiler},
18    },
19    info::ContractInfo as CompilerContractInfo,
20    multi::{MultiCompiler, MultiCompilerSettings},
21    project::Preprocessor,
22    report::{BasicStdoutReporter, NoReporter, Report},
23    solc::SolcSettings,
24};
25use num_format::{Locale, ToFormattedString};
26use solar::{
27    ast::{Arena, ContractKind, ItemKind},
28    interface::{Session, source_map::FileName},
29    parse::Parser,
30};
31use std::{
32    collections::{BTreeMap, BTreeSet, HashMap, HashSet},
33    fmt::Display,
34    io::IsTerminal,
35    path::{Path, PathBuf},
36    str::FromStr,
37    sync::Arc,
38    time::Instant,
39};
40
41/// A Solar compiler instance, to grant syntactic and semantic analysis capabilities.
42pub type Analysis = Arc<solar::sema::Compiler>;
43
44/// Builder type to configure how to compile a project.
45///
46/// This is merely a wrapper for [`Project::compile()`] which also prints to stdout depending on its
47/// settings.
48#[must_use = "ProjectCompiler does nothing unless you call a `compile*` method"]
49pub struct ProjectCompiler {
50    /// The root of the project.
51    project_root: PathBuf,
52
53    /// Whether to also print contract names.
54    print_names: Option<bool>,
55
56    /// Whether to also print contract sizes.
57    print_sizes: Option<bool>,
58
59    /// Whether to print anything at all. Overrides other `print` options.
60    quiet: Option<bool>,
61
62    /// Whether to bail on compiler errors.
63    bail: Option<bool>,
64
65    /// Whether to ignore the contract initcode size limit introduced by EIP-3860.
66    ignore_eip_3860: bool,
67
68    /// Contract size limits used when reporting compiled contract sizes.
69    size_limits: ContractSizeLimits,
70
71    /// Extra files to include, that are not necessarily in the project's source directory.
72    files: Vec<PathBuf>,
73
74    /// Whether to compile with dynamic linking tests and scripts.
75    dynamic_test_linking: bool,
76}
77
78impl Default for ProjectCompiler {
79    #[inline]
80    fn default() -> Self {
81        Self::new()
82    }
83}
84
85impl ProjectCompiler {
86    /// Create a new builder with the default settings.
87    #[inline]
88    pub fn new() -> Self {
89        Self {
90            project_root: PathBuf::new(),
91            print_names: None,
92            print_sizes: None,
93            quiet: Some(crate::shell::is_quiet()),
94            bail: None,
95            ignore_eip_3860: false,
96            size_limits: ContractSizeLimits::default(),
97            files: Vec::new(),
98            dynamic_test_linking: false,
99        }
100    }
101
102    /// Sets whether to print contract names.
103    #[inline]
104    pub const fn print_names(mut self, yes: bool) -> Self {
105        self.print_names = Some(yes);
106        self
107    }
108
109    /// Sets whether to print contract sizes.
110    #[inline]
111    pub const fn print_sizes(mut self, yes: bool) -> Self {
112        self.print_sizes = Some(yes);
113        self
114    }
115
116    /// Sets whether to print anything at all. Overrides other `print` options.
117    #[inline]
118    #[doc(alias = "silent")]
119    pub const fn quiet(mut self, yes: bool) -> Self {
120        self.quiet = Some(yes);
121        self
122    }
123
124    /// Sets whether to bail on compiler errors.
125    #[inline]
126    pub const fn bail(mut self, yes: bool) -> Self {
127        self.bail = Some(yes);
128        self
129    }
130
131    /// Sets whether to ignore EIP-3860 initcode size limits.
132    #[inline]
133    pub const fn ignore_eip_3860(mut self, yes: bool) -> Self {
134        self.ignore_eip_3860 = yes;
135        self
136    }
137
138    /// Sets the contract size limits for size reports.
139    #[inline]
140    pub const fn size_limits(mut self, limits: ContractSizeLimits) -> Self {
141        self.size_limits = limits;
142        self
143    }
144
145    /// Sets extra files to include, that are not necessarily in the project's source dir.
146    #[inline]
147    pub fn files(mut self, files: impl IntoIterator<Item = PathBuf>) -> Self {
148        self.files.extend(files);
149        self
150    }
151
152    /// Sets if tests should be dynamically linked.
153    #[inline]
154    pub const fn dynamic_test_linking(mut self, preprocess: bool) -> Self {
155        self.dynamic_test_linking = preprocess;
156        self
157    }
158
159    /// Compiles the project.
160    #[instrument(target = "forge::compile", skip_all)]
161    pub fn compile<C: Compiler<CompilerContract = Contract>>(
162        mut self,
163        project: &Project<C>,
164    ) -> Result<ProjectCompileOutput<C>>
165    where
166        DynamicTestLinkingPreprocessor: Preprocessor<C>,
167    {
168        self.project_root = project.root().to_path_buf();
169
170        // TODO: Avoid using std::process::exit(0).
171        // Replacing this with a return (e.g., Ok(ProjectCompileOutput::default())) would be more
172        // idiomatic, but it currently requires a `Default` bound on `C::Language`, which
173        // breaks compatibility with downstream crates like `foundry-cli`. This would need a
174        // broader refactor across the call chain. Leaving it as-is for now until a larger
175        // refactor is feasible.
176        if !project.paths.has_input_files() && self.files.is_empty() {
177            sh_println!("Nothing to compile")?;
178            std::process::exit(0);
179        }
180
181        // Taking is fine since we don't need these in `compile_with`.
182        let files = std::mem::take(&mut self.files);
183        let preprocess = self.dynamic_test_linking;
184        self.compile_with(|| {
185            let sources = if files.is_empty() {
186                project.paths.read_input_files()?
187            } else {
188                Source::read_all(files)?
189            };
190
191            let mut compiler =
192                foundry_compilers::project::ProjectCompiler::with_sources(project, sources)?;
193            if preprocess {
194                compiler = compiler.with_preprocessor(DynamicTestLinkingPreprocessor);
195            }
196            compiler.compile().map_err(Into::into)
197        })
198    }
199
200    /// Compiles the project with the given closure
201    fn compile_with<C: Compiler<CompilerContract = Contract>, F>(
202        self,
203        f: F,
204    ) -> Result<ProjectCompileOutput<C>>
205    where
206        F: FnOnce() -> Result<ProjectCompileOutput<C>>,
207    {
208        let quiet = self.quiet.unwrap_or(false);
209        let bail = self.bail.unwrap_or(true);
210
211        let output = with_compilation_reporter(quiet, Some(self.project_root.clone()), || {
212            tracing::debug!("compiling project");
213
214            let timer = Instant::now();
215            let r = f();
216            let elapsed = timer.elapsed();
217
218            tracing::debug!("finished compiling in {:.3}s", elapsed.as_secs_f64());
219            r
220        })?;
221
222        if bail && output.has_compiler_errors() {
223            eyre::bail!("{output}");
224        }
225
226        if !quiet {
227            if !shell::is_json() {
228                if output.is_unchanged() {
229                    sh_println!("No files changed, compilation skipped")?;
230                } else {
231                    // print the compiler output / warnings
232                    sh_println!("{output}")?;
233                }
234            }
235
236            if !(shell::is_json() && output.has_compiler_errors()) {
237                self.handle_output(&output)?;
238            }
239        }
240
241        Ok(output)
242    }
243
244    /// If configured, this will print sizes or names
245    fn handle_output<C: Compiler<CompilerContract = Contract>>(
246        &self,
247        output: &ProjectCompileOutput<C>,
248    ) -> Result<()> {
249        let print_names = self.print_names.unwrap_or(false);
250        let print_sizes = self.print_sizes.unwrap_or(false);
251
252        // print any sizes or names
253        if print_names {
254            let mut artifacts: BTreeMap<_, Vec<_>> = BTreeMap::new();
255            for (name, (_, version)) in output.versioned_artifacts() {
256                artifacts.entry(version).or_default().push(name);
257            }
258
259            if shell::is_json() {
260                sh_println!("{}", serde_json::to_string(&artifacts).unwrap())?;
261            } else {
262                for (version, names) in artifacts {
263                    sh_println!(
264                        "  compiler version: {}.{}.{}",
265                        version.major,
266                        version.minor,
267                        version.patch
268                    )?;
269                    for name in names {
270                        sh_println!("    - {name}")?;
271                    }
272                }
273            }
274        }
275
276        if print_sizes {
277            // add extra newline if names were already printed
278            if print_names && !shell::is_json() {
279                sh_println!()?;
280            }
281
282            let mut size_report =
283                SizeReport { contracts: BTreeMap::new(), limits: self.size_limits };
284
285            let mut artifacts: BTreeMap<String, Vec<_>> = BTreeMap::new();
286            for (id, artifact) in output.artifact_ids().filter(|(id, _)| {
287                // filter out forge-std specific contracts
288                !id.source.to_string_lossy().contains("/forge-std/src/")
289            }) {
290                artifacts.entry(id.name.clone()).or_default().push((id.source.clone(), artifact));
291            }
292
293            // Internal libraries are inlined into consumers and never deployed; skip them.
294            // Only artifacts whose ABI has no functions can be internal libraries, so restrict the
295            // solar parse to those sources to avoid a second full parse pass.
296            let abs_source = |path: &Path| -> PathBuf {
297                if path.is_absolute() { path.to_path_buf() } else { self.project_root.join(path) }
298            };
299            let source_paths = artifacts
300                .values()
301                .flatten()
302                .filter(|(_, artifact)| {
303                    artifact.abi.as_ref().is_some_and(|abi| abi.functions().next().is_none())
304                })
305                .map(|(path, _)| abs_source(path))
306                .collect::<BTreeSet<_>>();
307            let libraries = collect_libraries(&source_paths);
308
309            for (name, artifact_list) in artifacts {
310                // A library with no functions in its ABI is internal-only; fail open if the ABI is
311                // missing. Filter first so the duplicate-name suffix below reflects kept contracts.
312                let kept = artifact_list
313                    .iter()
314                    .filter(|(path, artifact)| {
315                        let is_library = libraries
316                            .get(&abs_source(path))
317                            .is_some_and(|libs| libs.contains(&name));
318                        let has_no_abi_functions = artifact
319                            .abi
320                            .as_ref()
321                            .is_some_and(|abi| abi.functions().next().is_none());
322                        !(is_library && has_no_abi_functions)
323                    })
324                    .collect::<Vec<_>>();
325
326                for (path, artifact) in &kept {
327                    let runtime_size = contract_size(*artifact, false).unwrap_or_default();
328                    let init_size = contract_size(*artifact, true).unwrap_or_default();
329
330                    let is_dev_contract = artifact
331                        .abi
332                        .as_ref()
333                        .map(|abi| {
334                            abi.functions().any(|f| {
335                                f.test_function_kind().is_known()
336                                    || matches!(f.name.as_str(), "IS_TEST" | "IS_SCRIPT")
337                            })
338                        })
339                        .unwrap_or(false);
340
341                    let unique_name = if kept.len() > 1 {
342                        format!(
343                            "{} ({})",
344                            name,
345                            path.strip_prefix(&self.project_root).unwrap_or(path).display()
346                        )
347                    } else {
348                        name.clone()
349                    };
350
351                    size_report.contracts.insert(
352                        unique_name,
353                        ContractInfo { runtime_size, init_size, is_dev_contract },
354                    );
355                }
356            }
357
358            sh_println!("{size_report}")?;
359
360            let runtime_eip = if size_report.limits.runtime == CONTRACT_RUNTIME_SIZE_LIMIT {
361                "EIP-170: "
362            } else {
363                ""
364            };
365            eyre::ensure!(
366                !size_report.exceeds_runtime_size_limit(),
367                "some contracts exceed the runtime size limit ({runtime_eip}{} bytes)",
368                size_report.limits.runtime
369            );
370            // Check size limits only if not ignoring EIP-3860
371            let initcode_eip = if size_report.limits.initcode == CONTRACT_INITCODE_SIZE_LIMIT {
372                "EIP-3860: "
373            } else {
374                ""
375            };
376            eyre::ensure!(
377                self.ignore_eip_3860 || !size_report.exceeds_initcode_size_limit(),
378                "some contracts exceed the initcode size limit ({initcode_eip}{} bytes)",
379                size_report.limits.initcode
380            );
381        }
382
383        Ok(())
384    }
385}
386
387// https://eips.ethereum.org/EIPS/eip-170
388const CONTRACT_RUNTIME_SIZE_LIMIT: usize = 24576;
389
390// https://eips.ethereum.org/EIPS/eip-3860
391const CONTRACT_INITCODE_SIZE_LIMIT: usize = 49152;
392
393const CONTRACT_RUNTIME_SIZE_WARN_THRESHOLD: usize = 18_000;
394const CONTRACT_INITCODE_SIZE_WARN_THRESHOLD: usize = 36_000;
395
396/// Runtime and initcode byte-size limits for compiled contract size reports.
397#[derive(Clone, Copy, Debug, PartialEq, Eq)]
398pub struct ContractSizeLimits {
399    /// Maximum deployed runtime bytecode size.
400    pub runtime: usize,
401    /// Maximum initcode bytecode size.
402    pub initcode: usize,
403}
404
405impl ContractSizeLimits {
406    /// Creates a new set of contract size limits.
407    pub const fn new(runtime: usize, initcode: usize) -> Self {
408        Self { runtime, initcode }
409    }
410
411    /// Creates limits from a runtime code-size limit, using the EIP-3860 2x initcode ratio.
412    pub const fn with_runtime_limit(runtime: usize) -> Self {
413        Self { runtime, initcode: runtime.saturating_mul(2) }
414    }
415
416    const fn runtime_warning_threshold(self) -> usize {
417        scaled_threshold(
418            self.runtime,
419            CONTRACT_RUNTIME_SIZE_WARN_THRESHOLD,
420            CONTRACT_RUNTIME_SIZE_LIMIT,
421        )
422    }
423
424    const fn initcode_warning_threshold(self) -> usize {
425        scaled_threshold(
426            self.initcode,
427            CONTRACT_INITCODE_SIZE_WARN_THRESHOLD,
428            CONTRACT_INITCODE_SIZE_LIMIT,
429        )
430    }
431}
432
433impl Default for ContractSizeLimits {
434    fn default() -> Self {
435        Self::new(CONTRACT_RUNTIME_SIZE_LIMIT, CONTRACT_INITCODE_SIZE_LIMIT)
436    }
437}
438
439const fn scaled_threshold(limit: usize, threshold: usize, default_limit: usize) -> usize {
440    limit.saturating_mul(threshold) / default_limit
441}
442
443/// Contracts with info about their size
444pub struct SizeReport {
445    /// `contract name -> info`
446    pub contracts: BTreeMap<String, ContractInfo>,
447    /// Size limits used to calculate margins and failures.
448    pub limits: ContractSizeLimits,
449}
450
451impl SizeReport {
452    /// Returns the maximum runtime code size, excluding dev contracts.
453    pub fn max_runtime_size(&self) -> usize {
454        self.contracts
455            .values()
456            .filter(|c| !c.is_dev_contract)
457            .map(|c| c.runtime_size)
458            .max()
459            .unwrap_or(0)
460    }
461
462    /// Returns the maximum initcode size, excluding dev contracts.
463    pub fn max_init_size(&self) -> usize {
464        self.contracts
465            .values()
466            .filter(|c| !c.is_dev_contract)
467            .map(|c| c.init_size)
468            .max()
469            .unwrap_or(0)
470    }
471
472    /// Returns true if any contract exceeds the runtime size limit, excluding dev contracts.
473    pub fn exceeds_runtime_size_limit(&self) -> bool {
474        self.max_runtime_size() > self.limits.runtime
475    }
476
477    /// Returns true if any contract exceeds the initcode size limit, excluding dev contracts.
478    pub fn exceeds_initcode_size_limit(&self) -> bool {
479        self.max_init_size() > self.limits.initcode
480    }
481}
482
483impl Display for SizeReport {
484    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
485        if shell::is_json() {
486            writeln!(f, "{}", self.format_json_output())?;
487        } else {
488            writeln!(f, "\n{}", self.format_table_output())?;
489        }
490        Ok(())
491    }
492}
493
494impl SizeReport {
495    fn format_json_output(&self) -> String {
496        let contracts = self
497            .contracts
498            .iter()
499            .filter(|(_, c)| !c.is_dev_contract && (c.runtime_size > 0 || c.init_size > 0))
500            .map(|(name, contract)| {
501                (
502                    name.clone(),
503                    serde_json::json!({
504                        "runtime_size": contract.runtime_size,
505                        "init_size": contract.init_size,
506                        "runtime_margin": self.limits.runtime as isize - contract.runtime_size as isize,
507                        "init_margin": self.limits.initcode as isize - contract.init_size as isize,
508                    }),
509                )
510            })
511            .collect::<serde_json::Map<_, _>>();
512
513        serde_json::to_string(&contracts).unwrap()
514    }
515
516    fn format_table_output(&self) -> Table {
517        let mut table = Table::new();
518        if shell::is_markdown() {
519            table.load_preset(ASCII_MARKDOWN);
520        } else {
521            table.apply_modifier(UTF8_ROUND_CORNERS);
522        }
523
524        table.set_header(vec![
525            Cell::new("Contract"),
526            Cell::new("Runtime Size (B)"),
527            Cell::new("Initcode Size (B)"),
528            Cell::new("Runtime Margin (B)"),
529            Cell::new("Initcode Margin (B)"),
530        ]);
531
532        // Filters out dev contracts (Test or Script)
533        let contracts = self
534            .contracts
535            .iter()
536            .filter(|(_, c)| !c.is_dev_contract && (c.runtime_size > 0 || c.init_size > 0));
537        let runtime_warning_threshold = self.limits.runtime_warning_threshold();
538        let initcode_warning_threshold = self.limits.initcode_warning_threshold();
539        for (name, contract) in contracts {
540            let runtime_margin = self.limits.runtime as isize - contract.runtime_size as isize;
541            let init_margin = self.limits.initcode as isize - contract.init_size as isize;
542
543            let runtime_color = if contract.runtime_size < runtime_warning_threshold {
544                Color::Reset
545            } else if contract.runtime_size <= self.limits.runtime {
546                Color::Yellow
547            } else {
548                Color::Red
549            };
550
551            let init_color = if contract.init_size < initcode_warning_threshold {
552                Color::Reset
553            } else if contract.init_size <= self.limits.initcode {
554                Color::Yellow
555            } else {
556                Color::Red
557            };
558
559            let locale = &Locale::en;
560            table.add_row([
561                Cell::new(name),
562                Cell::new(contract.runtime_size.to_formatted_string(locale)).fg(runtime_color),
563                Cell::new(contract.init_size.to_formatted_string(locale)).fg(init_color),
564                Cell::new(runtime_margin.to_formatted_string(locale)).fg(runtime_color),
565                Cell::new(init_margin.to_formatted_string(locale)).fg(init_color),
566            ]);
567        }
568
569        table
570    }
571}
572
573/// Parses each source file with solar and returns the library names declared in it.
574///
575/// Files that fail to parse are skipped, so a missing entry means "unknown", not "no libraries".
576fn collect_libraries(sources: &BTreeSet<PathBuf>) -> HashMap<PathBuf, HashSet<String>> {
577    let mut result: HashMap<PathBuf, HashSet<String>> = HashMap::new();
578    let sess = Session::builder().with_silent_emitter(None).build();
579    let _ = sess.enter(|| -> solar::interface::Result<()> {
580        for path in sources {
581            let arena = Arena::new();
582            let mut parser = match Parser::from_lazy_source_code(
583                &sess,
584                &arena,
585                FileName::from(path.clone()),
586                || std::fs::read_to_string(path),
587            ) {
588                Ok(parser) => parser,
589                Err(_) => continue,
590            };
591            let Ok(ast) = parser.parse_file() else { continue };
592            let libs = ast
593                .items
594                .iter()
595                .filter_map(|item| match &item.kind {
596                    ItemKind::Contract(c) if c.kind == ContractKind::Library => {
597                        Some(c.name.as_str().to_string())
598                    }
599                    _ => None,
600                })
601                .collect::<HashSet<_>>();
602            if !libs.is_empty() {
603                result.insert(path.clone(), libs);
604            }
605        }
606        Ok(())
607    });
608    result
609}
610
611/// Returns the deployed or init size of the contract.
612fn contract_size<T: Artifact>(artifact: &T, initcode: bool) -> Option<usize> {
613    let bytecode = if initcode {
614        artifact.get_bytecode_object()?
615    } else {
616        artifact.get_deployed_bytecode_object()?
617    };
618
619    let size = match bytecode.as_ref() {
620        BytecodeObject::Bytecode(bytes) => bytes.len(),
621        BytecodeObject::Unlinked(unlinked) => {
622            // we don't need to account for placeholders here, because library placeholders take up
623            // 40 characters: `__$<library hash>$__` which is the same as a 20byte address in hex.
624            let mut size = unlinked.len();
625            if unlinked.starts_with("0x") {
626                size -= 2;
627            }
628            // hex -> bytes
629            size / 2
630        }
631    };
632
633    Some(size)
634}
635
636/// How big the contract is and whether it is a dev contract where size limits can be neglected
637#[derive(Clone, Copy, Debug)]
638pub struct ContractInfo {
639    /// Size of the runtime code in bytes
640    pub runtime_size: usize,
641    /// Size of the initcode in bytes
642    pub init_size: usize,
643    /// A development contract is either a Script or a Test contract.
644    pub is_dev_contract: bool,
645}
646
647/// Compiles target file path.
648///
649/// If `quiet` is set, the compilation reporter's progress/status output is suppressed.
650/// (When not suppressed, that output is emitted to stderr; see `with_compilation_reporter`.)
651///
652/// **Note:** this expects the `target_path` to be absolute
653pub fn compile_target<C: Compiler<CompilerContract = Contract>>(
654    target_path: &Path,
655    project: &Project<C>,
656    quiet: bool,
657) -> Result<ProjectCompileOutput<C>>
658where
659    DynamicTestLinkingPreprocessor: Preprocessor<C>,
660{
661    ProjectCompiler::new().quiet(quiet).files([target_path.into()]).compile(project)
662}
663
664/// Compiles the project requesting only ABI output.
665pub fn compile_abi_project<C: Compiler<CompilerContract = Contract>>(
666    project: &mut Project<C>,
667    compiler: ProjectCompiler,
668) -> Result<ProjectCompileOutput<C>>
669where
670    DynamicTestLinkingPreprocessor: Preprocessor<C>,
671{
672    project.update_output_selection(|selection| {
673        // Request ABI so compilers populate `contracts` without producing bytecode outputs.
674        *selection = OutputSelection::common_output_selection(["abi".to_string()]);
675    });
676    compiler.compile(project)
677}
678
679/// Compiles the target contract requesting only ABI output and returns its ABI.
680pub fn compile_target_abi(
681    project: &mut Project<MultiCompiler>,
682    target_path: &Path,
683    target_name: &str,
684) -> Result<JsonAbi> {
685    let target_path = dunce::canonicalize(target_path)?;
686    let output = compile_abi_project(
687        project,
688        ProjectCompiler::new().quiet(true).files([target_path.clone()]),
689    )?;
690
691    let artifact = output
692        .find(&target_path, target_name)
693        .ok_or_eyre("failed to find target artifact when compiling for abi")?;
694    artifact.abi.clone().ok_or_eyre("target artifact does not have an ABI")
695}
696
697/// Creates a [Project] from an Etherscan source.
698pub fn etherscan_project(metadata: &Metadata, target_path: &Path) -> Result<Project> {
699    let target_path = dunce::canonicalize(target_path)?;
700    let sources_path = target_path.join(&metadata.contract_name);
701    metadata.source_tree().write_to(&target_path)?;
702
703    let mut settings = metadata.settings()?;
704
705    // make remappings absolute with our root
706    for remapping in &mut settings.remappings {
707        let new_path = sources_path.join(remapping.path.trim_start_matches('/'));
708        remapping.path = new_path.display().to_string();
709    }
710
711    // add missing remappings
712    if !settings.remappings.iter().any(|remapping| remapping.name.starts_with("@openzeppelin/")) {
713        let oz = Remapping {
714            context: None,
715            name: "@openzeppelin/".into(),
716            path: sources_path.join("@openzeppelin").display().to_string(),
717        };
718        settings.remappings.push(oz);
719    }
720
721    // root/
722    //   ContractName/
723    //     [source code]
724    let paths = ProjectPathsConfig::builder()
725        .sources(sources_path.clone())
726        .remappings(settings.remappings.clone())
727        .build_with_root(sources_path);
728
729    // TODO: detect vyper
730    let v = metadata.compiler_version()?;
731    let solc = Solc::find_or_install(&v)?;
732
733    let compiler = MultiCompiler { solc: Some(SolcCompiler::Specific(solc)), vyper: None };
734
735    Ok(ProjectBuilder::<MultiCompiler>::default()
736        .settings(MultiCompilerSettings {
737            solc: SolcSettings {
738                settings: SolcConfig::builder().settings(settings).build(),
739                ..Default::default()
740            },
741            ..Default::default()
742        })
743        .paths(paths)
744        .ephemeral()
745        .no_artifacts()
746        .build(compiler)?)
747}
748
749/// Configures the reporter and runs the given closure.
750///
751/// In TTY mode, [`SpinnerReporter`] paints the progress to stderr. The non-TTY fallback
752/// still writes to stdout via `BasicStdoutReporter`; migrating that path to stderr is
753/// part of the per-command stdout migration tracked in `docs/dev/output-channels.md`
754/// (it would shift many existing snapshot tests at once).
755pub fn with_compilation_reporter<O>(
756    quiet: bool,
757    project_root: Option<PathBuf>,
758    f: impl FnOnce() -> O,
759) -> O {
760    #[expect(clippy::collapsible_else_if)]
761    let reporter = if quiet || shell::is_json() {
762        Report::new(NoReporter::default())
763    } else {
764        if std::io::stderr().is_terminal() {
765            Report::new(SpinnerReporter::spawn(project_root))
766        } else {
767            Report::new(BasicStdoutReporter::default())
768        }
769    };
770
771    foundry_compilers::report::with_scoped(&reporter, f)
772}
773
774/// Container type for parsing contract identifiers from CLI.
775///
776/// Passed string can be of the following forms:
777/// - `src/Counter.sol` - path to the contract file, in the case where it only contains one contract
778/// - `src/Counter.sol:Counter` - path to the contract file and the contract name
779/// - `Counter` - contract name only
780#[derive(Clone, PartialEq, Eq)]
781pub enum PathOrContractInfo {
782    /// Non-canonicalized path provided via CLI.
783    Path(PathBuf),
784    /// Contract info provided via CLI.
785    ContractInfo(CompilerContractInfo),
786}
787
788impl PathOrContractInfo {
789    /// Returns the path to the contract file if provided.
790    pub fn path(&self) -> Option<PathBuf> {
791        match self {
792            Self::Path(path) => Some(path.clone()),
793            Self::ContractInfo(info) => info.path.as_ref().map(PathBuf::from),
794        }
795    }
796
797    /// Returns the contract name if provided.
798    pub fn name(&self) -> Option<&str> {
799        match self {
800            Self::Path(_) => None,
801            Self::ContractInfo(info) => Some(&info.name),
802        }
803    }
804}
805
806impl FromStr for PathOrContractInfo {
807    type Err = eyre::Error;
808
809    fn from_str(s: &str) -> Result<Self> {
810        if let Ok(contract) = CompilerContractInfo::from_str(s) {
811            return Ok(Self::ContractInfo(contract));
812        }
813        let path = PathBuf::from(s);
814        if path.extension().is_some_and(|ext| ext == "sol" || ext == "vy") {
815            return Ok(Self::Path(path));
816        }
817        Err(eyre::eyre!("Invalid contract identifier, file is not *.sol or *.vy: {}", s))
818    }
819}
820
821impl std::fmt::Debug for PathOrContractInfo {
822    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
823        match self {
824            Self::Path(path) => write!(f, "Path({})", path.display()),
825            Self::ContractInfo(info) => {
826                write!(f, "ContractInfo({info})")
827            }
828        }
829    }
830}
831
832#[cfg(test)]
833mod tests {
834    use super::*;
835
836    #[test]
837    fn parse_contract_identifiers() {
838        let t = ["src/Counter.sol", "src/Counter.sol:Counter", "Counter"];
839
840        let i1 = PathOrContractInfo::from_str(t[0]).unwrap();
841        assert_eq!(i1, PathOrContractInfo::Path(PathBuf::from(t[0])));
842
843        let i2 = PathOrContractInfo::from_str(t[1]).unwrap();
844        assert_eq!(
845            i2,
846            PathOrContractInfo::ContractInfo(CompilerContractInfo {
847                path: Some("src/Counter.sol".to_string()),
848                name: "Counter".to_string()
849            })
850        );
851
852        let i3 = PathOrContractInfo::from_str(t[2]).unwrap();
853        assert_eq!(
854            i3,
855            PathOrContractInfo::ContractInfo(CompilerContractInfo {
856                path: None,
857                name: "Counter".to_string()
858            })
859        );
860    }
861
862    #[test]
863    fn size_report_uses_configured_limits() {
864        let mut contracts = BTreeMap::new();
865        contracts.insert(
866            "LargeContract".to_string(),
867            ContractInfo { runtime_size: 30_000, init_size: 60_000, is_dev_contract: false },
868        );
869
870        let default_report =
871            SizeReport { contracts: contracts.clone(), limits: ContractSizeLimits::default() };
872        assert!(default_report.exceeds_runtime_size_limit());
873        assert!(default_report.exceeds_initcode_size_limit());
874
875        let custom_report =
876            SizeReport { contracts, limits: ContractSizeLimits::new(131_072, 262_144) };
877        assert!(!custom_report.exceeds_runtime_size_limit());
878        assert!(!custom_report.exceeds_initcode_size_limit());
879        let output: serde_json::Value =
880            serde_json::from_str(&custom_report.format_json_output()).unwrap();
881        assert_eq!(
882            output,
883            serde_json::json!({
884                "LargeContract": {
885                    "runtime_size": 30000,
886                    "init_size": 60000,
887                    "runtime_margin": 101072,
888                    "init_margin": 202144,
889                }
890            })
891        );
892    }
893
894    #[test]
895    fn contract_size_limits_derive_initcode_limit_from_runtime_limit() {
896        assert_eq!(
897            ContractSizeLimits::with_runtime_limit(50_000),
898            ContractSizeLimits::new(50_000, 100_000)
899        );
900    }
901}