Skip to main content

forge/cmd/
build.rs

1use super::{install, watch::WatchArgs};
2use crate::diagnostic::build::SOLC_ERROR;
3use clap::Parser;
4use eyre::Result;
5use forge_lint::{linter::Linter, sol::SolidityLinter};
6use foundry_cli::{
7    ExitCode,
8    json::{JsonEnvelope, JsonMessage, print_json},
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, DenyLevel, 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::Opts},
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    /// Reject flags whose stdout shape conflicts with the envelope contract.
93    /// Must be called before dispatch so `--watch` is caught before the watch
94    /// loop short-circuits past `BuildArgs::run`.
95    pub fn ensure_machine_compatible(&self) {
96        if !foundry_cli::is_machine() {
97            return;
98        }
99        let unsupported =
100            [("--watch", self.is_watch()), ("--names", self.names), ("--sizes", self.sizes)]
101                .into_iter()
102                .filter_map(|(name, on)| on.then_some(name))
103                .collect::<Vec<_>>();
104        if !unsupported.is_empty() {
105            foundry_cli::machine::bail_machine_usage_with_details(
106                format!(
107                    "`forge build` under `--machine` does not yet support {}; \
108                     run without `--machine` or omit those flags.",
109                    unsupported.join(", ")
110                ),
111                serde_json::json!({ "unsupported_flags": unsupported }),
112            );
113        }
114    }
115
116    pub async fn run(self) -> Result<ProjectCompileOutput> {
117        self.ensure_machine_compatible();
118
119        let machine_mode = foundry_cli::is_machine();
120        let mut config = self.load_config()?;
121
122        // Skip implicit dep install under `--machine`: it writes to stdout and mutates the project
123        // behind the agent's back. Missing deps will surface as a typed compile-error envelope.
124        if !machine_mode
125            && install::install_missing_dependencies(&mut config).await
126            && config.auto_detect_remappings
127        {
128            // need to re-configure here to also catch additional remappings
129            config = self.load_config()?;
130        }
131
132        // Lock-consistency checks emit `sh_warn!` to stderr; skip under `--machine` to keep
133        // the agent channel quiet.
134        if !machine_mode {
135            self.check_soldeer_lock_consistency(&config).await;
136            self.check_foundry_lock_consistency(&config);
137        }
138
139        let project = config.project()?;
140
141        // Collect sources to compile if build subdirectories specified.
142        let mut files = vec![];
143        if let Some(paths) = &self.paths {
144            for path in paths {
145                let joined = project.root().join(path);
146                let path = if joined.exists() { &joined } else { path };
147                files.extend(source_files_iter(path, MultiCompilerLanguage::FILE_EXTENSIONS));
148            }
149            if files.is_empty() {
150                eyre::bail!("No source files found in specified build paths.")
151            }
152        }
153
154        let format_json = shell::is_json();
155
156        // Pre-empt the inner `"Nothing to compile" + exit(0)` path so it can't break the
157        // envelope-only stdout contract.
158        if machine_mode && !project.paths.has_input_files() && self.paths.is_none() {
159            let payload = BuildData { artifacts: 0, errors: 0, warnings: 0, unchanged: false };
160            print_json(&JsonEnvelope::success(payload))?;
161            std::process::exit(ExitCode::Success.to_i32());
162        }
163
164        // Under `--machine`: force quiet (envelope-only stdout) and disable `bail` so we can
165        // emit a typed failure envelope instead of a generic eyre error. v1 skips post-build
166        // lint (not yet modeled in the envelope schema).
167        let mut compiler = ProjectCompiler::new()
168            .files(files)
169            .dynamic_test_linking(config.dynamic_test_linking)
170            .print_names(self.names)
171            .print_sizes(self.sizes)
172            .ignore_eip_3860(self.ignore_eip_3860)
173            .size_limits(
174                config
175                    .code_size_limit
176                    .map(ContractSizeLimits::with_runtime_limit)
177                    .unwrap_or_default(),
178            )
179            .bail(!format_json && !machine_mode);
180        if machine_mode {
181            compiler = compiler.quiet(true);
182        }
183
184        let mut output = compiler.compile(&project)?;
185
186        // Under `--machine`, emit the typed envelope *before* `cache_local_signatures` so a
187        // cache-write failure can never mask a compile-error envelope as `cli.unknown`.
188        if machine_mode && output.has_compiler_errors() {
189            let errors: Vec<JsonMessage> = output
190                .output()
191                .errors
192                .iter()
193                .filter(|e| e.is_error())
194                .map(|e| JsonMessage::error(SOLC_ERROR, e.to_string()))
195                .collect();
196            // Best-effort: bubbling via `?` on a broken stdout would demote
197            // the canonical `Build (4)` exit to `GenericError (1)`.
198            let _ = print_json(&JsonEnvelope::<()>::failure(errors));
199            std::process::exit(ExitCode::Build.to_i32());
200        }
201
202        // Cache project selectors.
203        cache_local_signatures(&output)?;
204
205        if format_json && !self.names && !self.sizes && !machine_mode {
206            sh_println!("{}", serde_json::to_string_pretty(&output.output())?)?;
207        }
208
209        if machine_mode {
210            // Lint output isn't modeled in the v1 envelope; refuse configs that would otherwise
211            // diverge human and machine outcomes (deferred until after compile so a failed
212            // build still emits `compiler.solc.error` + `Build (4)`).
213            if !self.no_lint && config.lint.lint_on_build && config.deny != DenyLevel::Never {
214                foundry_cli::machine::bail_machine_usage(
215                    "`forge build --machine` does not model lint diagnostics in v1; \
216                     `lint_on_build = true` with `deny != never` would diverge human and \
217                     machine outcomes. Pass `--no-lint` or set `deny = never`.",
218                );
219            }
220
221            let payload = BuildData::from_output(&output);
222            print_json(&JsonEnvelope::success(payload))?;
223            return Ok(output);
224        }
225
226        // Only run the `SolidityLinter` if lint on build and no compilation errors.
227        if !self.no_lint
228            && config.lint.lint_on_build
229            && !output.output().errors.iter().any(|e| e.is_error())
230            && let Err(err) = self.lint(&project, &config, self.paths.as_deref(), &mut output)
231        {
232            emit_lint_failure_notice();
233            return Err(err.wrap_err("post-build lint step failed"));
234        }
235
236        Ok(output)
237    }
238
239    fn lint(
240        &self,
241        project: &Project,
242        config: &Config,
243        files: Option<&[PathBuf]>,
244        output: &mut ProjectCompileOutput,
245    ) -> Result<()> {
246        let format_json = shell::is_json();
247        if project.compiler.solc.is_some() && !shell::is_quiet() {
248            let linter = SolidityLinter::new(config.project_paths())
249                .with_json_emitter(format_json)
250                .with_description(!format_json)
251                .with_severity(if config.lint.severity.is_empty() {
252                    None
253                } else {
254                    Some(config.lint.severity.clone())
255                })
256                .without_lints(if config.lint.exclude_lints.is_empty() {
257                    None
258                } else {
259                    Some(
260                        config
261                            .lint
262                            .exclude_lints
263                            .iter()
264                            .filter_map(|s| forge_lint::sol::SolLint::try_from(s.as_str()).ok())
265                            .collect(),
266                    )
267                })
268                .with_lint_specific(&config.lint.lint_specific);
269
270            // Expand ignore globs and canonicalize from the get go
271            let ignored = expand_globs(&config.root, config.lint.ignore.iter())?
272                .iter()
273                .flat_map(foundry_common::fs::canonicalize_path)
274                .collect::<Vec<_>>();
275
276            let skip = SkipBuildFilters::new(config.skip.clone(), config.root.clone());
277            let curr_dir = std::env::current_dir()?;
278            let input_files = config
279                .project_paths::<SolcLanguage>()
280                .input_files_iter()
281                .filter(|p| {
282                    // Lint only specified build files, if any.
283                    if let Some(files) = files {
284                        return files.iter().any(|file| &curr_dir.join(file) == p);
285                    }
286                    skip.is_match(p)
287                        && !(ignored.contains(p) || ignored.contains(&curr_dir.join(p)))
288                })
289                .collect::<Vec<_>>();
290
291            let solar_sources =
292                get_solar_sources_from_compile_output(config, output, Some(&input_files), None)?;
293            if solar_sources.input.sources.is_empty() {
294                if !input_files.is_empty() {
295                    sh_warn!("unable to lint. Solar only supports Solidity versions >=0.8.0")?;
296                }
297                return Ok(());
298            }
299
300            // NOTE(rusowsky): Once solar can drop unsupported versions, rather than creating a new
301            // compiler, we should reuse the parser from the project output.
302            let mut opts = Opts::default();
303            opts.unstable.typeck = true;
304            let mut compiler =
305                Compiler::new(Session::builder().opts(opts).with_stderr_emitter().build());
306
307            // Load the solar-compatible sources to the pcx before linting
308            compiler.enter_mut(|compiler| {
309                let mut pcx = compiler.parse();
310                configure_pcx_from_solc(&mut pcx, &config.project_paths(), &solar_sources, true);
311                pcx.set_resolve_imports(true);
312                pcx.parse();
313            });
314
315            linter.lint(&input_files, config.deny, &mut compiler)?;
316        }
317
318        Ok(())
319    }
320
321    /// Returns the `Project` for the current workspace
322    ///
323    /// This loads the `foundry_config::Config` for the current workspace (see
324    /// [`foundry_config::utils::find_project_root`] and merges the cli `BuildArgs` into it before
325    /// returning [`foundry_config::Config::project()`]
326    pub fn project(&self) -> Result<Project> {
327        self.build.project()
328    }
329
330    /// Returns whether `BuildArgs` was configured with `--watch`
331    pub const fn is_watch(&self) -> bool {
332        self.watch.watch.is_some()
333    }
334
335    /// Returns the [`watchexec::Config`] necessary to bootstrap a new watch loop.
336    pub(crate) fn watchexec_config(&self) -> Result<watchexec::Config> {
337        // Use the path arguments or if none where provided the `src`, `test` and `script`
338        // directories as well as the `foundry.toml` configuration file.
339        self.watch.watchexec_config(|| {
340            let config = self.load_config()?;
341            let foundry_toml: PathBuf = config.root.join(Config::FILE_NAME);
342            Ok([config.src, config.test, config.script, foundry_toml])
343        })
344    }
345
346    /// Check soldeer.lock file consistency using soldeer_core APIs
347    async fn check_soldeer_lock_consistency(&self, config: &Config) {
348        let soldeer_lock_path = config.root.join("soldeer.lock");
349        if !soldeer_lock_path.exists() {
350            return;
351        }
352
353        // Note: read_lockfile returns Ok with empty entries for malformed files
354        let Ok(lockfile) = soldeer_core::lock::read_lockfile(&soldeer_lock_path) else {
355            return;
356        };
357
358        let deps_dir = config.root.join("dependencies");
359        for entry in &lockfile.entries {
360            let dep_name = entry.name();
361
362            // Use soldeer_core's integrity check
363            match soldeer_core::install::check_dependency_integrity(entry, &deps_dir).await {
364                Ok(status) => {
365                    use soldeer_core::install::DependencyStatus;
366                    // Check if status indicates a problem
367                    if matches!(
368                        status,
369                        DependencyStatus::Missing | DependencyStatus::FailedIntegrity
370                    ) {
371                        sh_warn!("Dependency '{}' integrity check failed: {:?}", dep_name, status)
372                            .ok();
373                    }
374                }
375                Err(e) => {
376                    sh_warn!("Dependency '{}' integrity check error: {}", dep_name, e).ok();
377                }
378            }
379        }
380    }
381
382    /// Check foundry.lock file consistency with git submodules
383    fn check_foundry_lock_consistency(&self, config: &Config) {
384        use crate::lockfile::{DepIdentifier, FOUNDRY_LOCK, Lockfile};
385
386        let foundry_lock_path = config.root.join(FOUNDRY_LOCK);
387        if !foundry_lock_path.exists() {
388            return;
389        }
390
391        let git = Git::new(&config.root);
392
393        let mut lockfile = Lockfile::new(&config.root).with_git(&git);
394        if let Err(e) = lockfile.read() {
395            if !e.to_string().contains("Lockfile not found") {
396                sh_warn!("Failed to parse foundry.lock: {}", e).ok();
397            }
398            return;
399        }
400
401        for (dep_path, dep_identifier) in lockfile.iter() {
402            let full_path = config.root.join(dep_path);
403
404            if !full_path.exists() {
405                sh_warn!("Dependency '{}' not found at expected path", dep_path.display()).ok();
406                continue;
407            }
408
409            let actual_rev = match git.get_rev("HEAD", &full_path) {
410                Ok(rev) => rev,
411                Err(_) => {
412                    sh_warn!("Failed to get git revision for dependency '{}'", dep_path.display())
413                        .ok();
414                    continue;
415                }
416            };
417
418            // Compare with the expected revision from lockfile
419            let expected_rev = match dep_identifier {
420                DepIdentifier::Branch { rev, .. }
421                | DepIdentifier::Tag { rev, .. }
422                | DepIdentifier::Rev { rev, .. } => rev.clone(),
423            };
424
425            if actual_rev != expected_rev {
426                sh_warn!(
427                    "Dependency '{}' revision mismatch: expected '{}', found '{}'",
428                    dep_path.display(),
429                    expected_rev,
430                    actual_rev
431                )
432                .ok();
433            }
434        }
435    }
436}
437
438/// Stable payload emitted in the `forge build` envelope under `--machine`.
439#[derive(Clone, Debug, Serialize)]
440pub struct BuildData {
441    /// Total number of compiled artifacts in the project.
442    pub artifacts: usize,
443    /// Number of compiler errors in the output.
444    pub errors: usize,
445    /// Number of compiler warnings in the output.
446    pub warnings: usize,
447    /// Whether the build was a no-op (no input files changed since the last
448    /// compile). Mirrors `ProjectCompileOutput::is_unchanged`.
449    pub unchanged: bool,
450}
451
452impl BuildData {
453    fn from_output(output: &ProjectCompileOutput) -> Self {
454        let artifacts = output.artifact_ids().count();
455        let mut errors = 0usize;
456        let mut warnings = 0usize;
457        for diag in &output.output().errors {
458            if diag.is_error() {
459                errors += 1;
460            } else {
461                warnings += 1;
462            }
463        }
464        Self { artifacts, errors, warnings, unchanged: output.is_unchanged() }
465    }
466}
467
468/// Notice shown on lint-on-build failure; printed separately so it survives single-line
469/// cause-chain rendering.
470const LINT_FAILURE_NOTICE: &str = "\
471note: internal lint engine failure (compilation itself succeeded).
472note: please file a bug report at
473      https://github.com/foundry-rs/foundry/issues/new?template=BUG-FORM.yml
474      and attach the full output above.
475help: rerun with `--no-lint` to skip linting for this build, or consider temporarily
476      disabling forge lint on build:
477      https://getfoundry.sh/forge/linting#disable-linting-on-build
478";
479
480fn emit_lint_failure_notice() {
481    if shell::is_json() {
482        return;
483    }
484    let _ = sh_eprintln!("\n{LINT_FAILURE_NOTICE}");
485}
486
487// Make this args a `figment::Provider` so that it can be merged into the `Config`
488impl Provider for BuildArgs {
489    fn metadata(&self) -> Metadata {
490        Metadata::named("Build Args Provider")
491    }
492
493    fn data(&self) -> Result<Map<Profile, Dict>, figment::Error> {
494        let value = Value::serialize(self)?;
495        let error = InvalidType(value.to_actual(), "map".into());
496        let mut dict = value.into_dict().ok_or(error)?;
497
498        if self.names {
499            dict.insert("names".to_string(), true.into());
500        }
501
502        if self.sizes {
503            dict.insert("sizes".to_string(), true.into());
504        }
505
506        if self.ignore_eip_3860 {
507            dict.insert("ignore_eip_3860".to_string(), true.into());
508        }
509
510        Ok(Map::from([(Config::selected_profile(), dict)]))
511    }
512}