forge/cmd/
build.rs

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