Skip to main content

forge/cmd/
build.rs

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