Skip to main content

forge/cmd/
build.rs

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