Skip to main content

forge/cmd/
build.rs

1use super::watch::WatchArgs;
2use crate::Lockfile;
3use clap::Parser;
4use eyre::Result;
5use forge_lint::{
6    linter::Linter,
7    sol::{DeniedLintDiagnostics, SolidityLinter},
8};
9use foundry_cli::{
10    opts::{BuildOpts, configure_pcx_from_solc, get_solar_sources_from_compile_output},
11    utils::{Git, LoadConfig, cache_local_signatures},
12};
13use foundry_common::{
14    compile::{ContractSizeLimits, ProjectCompiler},
15    shell,
16};
17use foundry_compilers::{
18    CompilationError, FileFilter, Project, ProjectCompileOutput,
19    compilers::{Language, multi::MultiCompilerLanguage},
20    solc::SolcLanguage,
21    utils::source_files_iter,
22};
23use foundry_config::{
24    Config, SkipBuildFilters,
25    figment::{
26        self, Metadata, Profile, Provider,
27        error::Kind::InvalidType,
28        value::{Dict, Map, Value},
29    },
30    filter::{expand_globs, is_ignored_path},
31};
32use serde::Serialize;
33use solar::{
34    interface::{Session, config::CompileOpts},
35    sema::Compiler,
36};
37use std::{fmt::Write, path::PathBuf};
38
39foundry_config::merge_impl_figment_convert!(BuildArgs, build);
40
41/// CLI arguments for `forge build`.
42///
43/// CLI arguments take the highest precedence in the Config/Figment hierarchy.
44/// In order to override them in the foundry `Config` they need to be merged into an existing
45/// `figment::Provider`, like `foundry_config::Config` is.
46///
47/// `BuildArgs` implements `figment::Provider` in which all config related fields are serialized and
48/// then merged into an existing `Config`, effectively overwriting them.
49///
50/// Some arguments are marked as `#[serde(skip)]` and require manual processing in
51/// `figment::Provider` implementation
52#[derive(Clone, Debug, Default, Serialize, Parser)]
53#[command(next_help_heading = "Build options", about = None, long_about = None)] // override doc
54pub struct BuildArgs {
55    /// Build source files from specified paths.
56    #[serde(skip)]
57    pub paths: Option<Vec<PathBuf>>,
58
59    /// Print compiled contract names.
60    #[arg(long)]
61    #[serde(skip)]
62    pub names: bool,
63
64    /// Print compiled contract sizes.
65    /// Constructor argument length is not included in the calculation of initcode size.
66    #[arg(long)]
67    #[serde(skip)]
68    pub sizes: bool,
69
70    /// Ignore initcode contract bytecode size limit introduced by EIP-3860.
71    #[arg(long, alias = "ignore-initcode-size")]
72    #[serde(skip)]
73    pub ignore_eip_3860: bool,
74
75    /// Skip the post-build lint step for this invocation.
76    ///
77    /// Equivalent to setting `lint_on_build = false` under `[lint]` in foundry.toml,
78    /// but only for the current command.
79    #[arg(long, visible_alias = "skip-lint")]
80    #[serde(skip)]
81    pub no_lint: bool,
82
83    #[command(flatten)]
84    #[serde(flatten)]
85    pub build: BuildOpts,
86
87    #[command(flatten)]
88    #[serde(skip)]
89    pub watch: WatchArgs,
90}
91
92impl BuildArgs {
93    pub async fn run(self, locked: bool) -> Result<ProjectCompileOutput> {
94        let mut config = self.load_config()?;
95
96        if locked {
97            self.check_foundry_lock_consistency(&config)?;
98        }
99
100        self.install_missing_dependencies(&mut config)?;
101
102        self.check_soldeer_lock_consistency(&config).await;
103
104        let project = config.project()?;
105
106        // Collect sources to compile if build subdirectories specified.
107        let mut files = vec![];
108        let mut selected_paths = vec![];
109        if let Some(paths) = &self.paths {
110            for path in paths {
111                let joined = project.root().join(path);
112                let path = if joined.exists() { &joined } else { path };
113                selected_paths.push(path.clone());
114                files.extend(source_files_iter(path, MultiCompilerLanguage::FILE_EXTENSIONS));
115            }
116            if files.is_empty() && config.external_compilers.is_empty() {
117                eyre::bail!("No source files found in specified build paths.");
118            }
119        }
120
121        let format_json = shell::is_json();
122
123        let mut output = ProjectCompiler::new()
124            .allow_linked_libraries(config.allow_linked_libraries)
125            .external_compilers(&config)
126            .files(files)
127            .selected_paths(selected_paths)
128            .dynamic_test_linking(config.dynamic_test_linking)
129            .print_compiler_settings(shell::verbosity() >= 2)
130            .print_names(self.names)
131            .print_sizes(self.sizes)
132            .ignore_eip_3860(self.ignore_eip_3860)
133            .size_limits(contract_size_limits(&config))
134            .bail(!format_json)
135            .compile(&project)?;
136
137        // Cache project selectors.
138        cache_local_signatures(&output)?;
139
140        if format_json && (!self.names && !self.sizes || output.has_compiler_errors()) {
141            sh_println!("{}", serde_json::to_string_pretty(&output.output())?)?;
142        }
143        if format_json && output.has_compiler_errors() {
144            std::process::exit(1);
145        }
146
147        // Only run the `SolidityLinter` if lint on build and no compilation errors.
148        if !self.no_lint
149            && config.lint.lint_on_build
150            && !output.output().errors.iter().any(|e| e.is_error())
151            && let Err(err) = self.lint(&project, &config, self.paths.as_deref(), &mut output)
152        {
153            if err.downcast_ref::<DeniedLintDiagnostics>().is_none() {
154                emit_lint_failure_notice();
155            }
156            return Err(err.wrap_err("post-build lint step failed"));
157        }
158
159        Ok(output)
160    }
161
162    fn lint(
163        &self,
164        project: &Project,
165        config: &Config,
166        files: Option<&[PathBuf]>,
167        output: &mut ProjectCompileOutput,
168    ) -> Result<()> {
169        let format_json = shell::is_json();
170        if project.compiler.solc.is_some() && !shell::is_quiet() {
171            let linter = SolidityLinter::new(config.project_paths())
172                .with_json_emitter(format_json)
173                .with_description(!format_json)
174                .with_severity(if config.lint.severity.is_empty() {
175                    None
176                } else {
177                    Some(config.lint.severity.clone())
178                })
179                .without_lints(if config.lint.exclude_lints.is_empty() {
180                    None
181                } else {
182                    Some(
183                        config
184                            .lint
185                            .exclude_lints
186                            .iter()
187                            .filter_map(|s| s.parse::<forge_lint::sol::SolLint>().ok())
188                            .collect(),
189                    )
190                })
191                .with_lint_specific(&config.lint.lint_specific);
192
193            // Expand ignore globs and canonicalize from the get go
194            let ignored = expand_globs(&config.root, config.lint.ignore.iter())?
195                .iter()
196                .flat_map(foundry_common::fs::canonicalize_path)
197                .collect::<Vec<_>>();
198
199            let skip = SkipBuildFilters::new(config.skip.clone(), config.root.clone());
200            let curr_dir = std::env::current_dir()?;
201            let input_files = config
202                .project_paths::<SolcLanguage>()
203                .input_files_iter()
204                .filter(|p| {
205                    // Lint only specified build files, if any.
206                    if let Some(files) = files {
207                        return files.iter().any(|file| &curr_dir.join(file) == p);
208                    }
209                    skip.is_match(p) && !is_ignored_path(p, &ignored, &curr_dir)
210                })
211                .collect::<Vec<_>>();
212
213            let solar_sources =
214                get_solar_sources_from_compile_output(config, output, Some(&input_files), None)?;
215            if solar_sources.input.sources.is_empty() {
216                if !input_files.is_empty() {
217                    sh_warn!("unable to lint. Solar only supports Solidity versions >=0.8.0")?;
218                }
219                return Ok(());
220            }
221
222            // NOTE(rusowsky): Once solar can drop unsupported versions, rather than creating a new
223            // compiler, we should reuse the parser from the project output.
224            let opts = CompileOpts::default();
225            let mut compiler =
226                Compiler::new(Session::builder().opts(opts).with_stderr_emitter().build());
227
228            // Load the solar-compatible sources to the pcx before linting
229            compiler.enter_mut(|compiler| {
230                let mut pcx = compiler.parse();
231                configure_pcx_from_solc(&mut pcx, &config.project_paths(), &solar_sources, true);
232                pcx.set_resolve_imports(true);
233                pcx.parse();
234            });
235
236            linter.lint(&input_files, config.deny, &mut compiler)?;
237        }
238
239        Ok(())
240    }
241
242    /// Returns the `Project` for the current workspace
243    ///
244    /// This loads the `foundry_config::Config` for the current workspace (see
245    /// [`foundry_config::utils::find_project_root`] and merges the cli `BuildArgs` into it before
246    /// returning [`foundry_config::Config::project()`]
247    pub fn project(&self) -> Result<Project> {
248        self.build.project()
249    }
250
251    /// Returns whether `BuildArgs` was configured with `--watch`
252    pub const fn is_watch(&self) -> bool {
253        self.watch.watch.is_some()
254    }
255
256    /// Returns the [`watchexec::Config`] necessary to bootstrap a new watch loop.
257    pub(crate) fn watchexec_config(&self) -> Result<watchexec::Config> {
258        // Use the path arguments or if none where provided the `src`, `test` and `script`
259        // directories as well as the `foundry.toml` configuration file.
260        self.watch.watchexec_config(|| {
261            let config = self.load_config()?;
262            let foundry_toml: PathBuf = config.root.join(Config::FILE_NAME);
263            Ok([config.src, config.test, config.script, foundry_toml])
264        })
265    }
266
267    /// Check soldeer.lock file consistency using soldeer_core APIs
268    async fn check_soldeer_lock_consistency(&self, config: &Config) {
269        let soldeer_lock_path = config.root.join("soldeer.lock");
270        if !soldeer_lock_path.exists() {
271            return;
272        }
273
274        // Note: read_lockfile returns Ok with empty entries for malformed files
275        let Ok(lockfile) = soldeer_core::lock::read_lockfile(&soldeer_lock_path) else {
276            return;
277        };
278
279        let deps_dir = config.root.join("dependencies");
280        for entry in &lockfile.entries {
281            let dep_name = entry.name();
282
283            // Use soldeer_core's integrity check
284            match soldeer_core::install::check_dependency_integrity(entry, &deps_dir).await {
285                Ok(status) => {
286                    use soldeer_core::install::DependencyStatus;
287                    // Check if status indicates a problem
288                    if matches!(
289                        status,
290                        DependencyStatus::Missing | DependencyStatus::FailedIntegrity
291                    ) {
292                        sh_warn!("Dependency '{}' integrity check failed: {:?}", dep_name, status)
293                            .ok();
294                    }
295                }
296                Err(e) => {
297                    sh_warn!("Dependency '{}' integrity check error: {}", dep_name, e).ok();
298                }
299            }
300        }
301    }
302
303    /// Checks foundry.lock file consistency with Git submodules.
304    fn check_foundry_lock_consistency(&self, config: &Config) -> Result<()> {
305        let git = Git::new(&config.root);
306        let mut lockfile = Lockfile::new(&config.root).with_git(&git);
307        let mismatches = lockfile.check()?;
308        if mismatches.is_empty() {
309            return Ok(());
310        }
311
312        let mut message = String::from("foundry.lock does not match installed dependencies:");
313        for mismatch in mismatches {
314            write!(message, "\n  {mismatch}")?;
315        }
316        Err(eyre::eyre!(message))
317    }
318}
319
320fn contract_size_limits(config: &Config) -> ContractSizeLimits {
321    config
322        .code_size_limit
323        .map(ContractSizeLimits::with_runtime_limit)
324        .or_else(|| {
325            config
326                .networks
327                .contract_size_limits()
328                .map(|limits| ContractSizeLimits::new(limits.runtime, limits.initcode))
329        })
330        .unwrap_or_else(|| ContractSizeLimits::for_spec_id(config.evm_spec_id()))
331}
332/// Notice shown on lint-on-build failure; printed separately so it survives single-line
333/// cause-chain rendering.
334const LINT_FAILURE_NOTICE: &str = "\
335note: internal lint engine failure (compilation itself succeeded).
336note: please file a bug report at
337      https://github.com/foundry-rs/foundry/issues/new?template=BUG-FORM.yml
338      and attach the full output above.
339help: rerun with `--no-lint` to skip linting for this build, or consider temporarily
340      disabling forge lint on build:
341      https://getfoundry.sh/forge/linting#disable-linting-on-build
342";
343
344fn emit_lint_failure_notice() {
345    if shell::is_json() {
346        return;
347    }
348    let _ = sh_eprintln!("\n{LINT_FAILURE_NOTICE}");
349}
350
351// Make this args a `figment::Provider` so that it can be merged into the `Config`
352impl Provider for BuildArgs {
353    fn metadata(&self) -> Metadata {
354        Metadata::named("Build Args Provider")
355    }
356
357    fn data(&self) -> Result<Map<Profile, Dict>, figment::Error> {
358        let value = Value::serialize(self)?;
359        let error = InvalidType(value.to_actual(), "map".into());
360        let mut dict = value.into_dict().ok_or(error)?;
361
362        if self.names {
363            dict.insert("names".to_string(), true.into());
364        }
365
366        if self.sizes {
367            dict.insert("sizes".to_string(), true.into());
368        }
369
370        if self.ignore_eip_3860 {
371            dict.insert("ignore_eip_3860".to_string(), true.into());
372        }
373
374        Ok(Map::from([(Config::selected_profile(), dict)]))
375    }
376}