diff --git a/crates/forge/src/args.rs b/crates/forge/src/args.rs index aada974c91b9a..36488b1b4130c 100644 --- a/crates/forge/src/args.rs +++ b/crates/forge/src/args.rs @@ -76,6 +76,13 @@ pub fn run_command(args: Forge) -> Result<()> { global.block_on(cmd.run()).map(drop) } } + ForgeSubcommand::Check(cmd) => { + if cmd.is_watch() { + global.block_on(watch::watch_check(cmd)) + } else { + global.block_on(cmd.run()) + } + } ForgeSubcommand::VerifyContract(args) => global.block_on(args.run()), ForgeSubcommand::VerifyCheck(args) => global.block_on(args.run()), ForgeSubcommand::VerifyBytecode(cmd) => global.block_on(cmd.run()), diff --git a/crates/forge/src/cmd/check.rs b/crates/forge/src/cmd/check.rs new file mode 100644 index 0000000000000..d119d2c905414 --- /dev/null +++ b/crates/forge/src/cmd/check.rs @@ -0,0 +1,181 @@ +use super::{install, watch::WatchArgs}; +use clap::Parser; +use eyre::Result; +use foundry_cli::{ + opts::{BuildOpts, configure_pcx_from_solc}, + utils::LoadConfig, +}; +use foundry_common::shell; +use foundry_compilers::{ + CompilerInput, Graph, Language, Project, + artifacts::{Source, Sources}, + compilers::multi::MultiCompilerLanguage, + multi::MultiCompilerParser, + solc::{SolcLanguage, SolcVersionedInput}, + utils::source_files_iter, +}; +use foundry_config::{ + Config, + figment::{ + self, Metadata, Profile, Provider, + error::Kind::InvalidType, + value::{Map, Value}, + }, +}; +use serde::Serialize; +use solar::sema::Compiler; +use std::path::PathBuf; + +foundry_config::merge_impl_figment_convert!(CheckArgs, build); + +/// CLI arguments for `forge check`. +/// +/// Similar to `forge build`, but only performs parsing, lowering and semantic analysis. +/// Skips codegen and optimizer steps for faster feedback. +#[derive(Clone, Debug, Default, Serialize, Parser)] +#[command(next_help_heading = "Check options", about = None, long_about = None)] +pub struct CheckArgs { + /// Check source files from specified paths. + #[serde(skip)] + pub paths: Option>, + + #[command(flatten)] + #[serde(flatten)] + pub build: BuildOpts, + + #[command(flatten)] + #[serde(skip)] + pub watch: WatchArgs, +} + +impl CheckArgs { + pub async fn run(self) -> Result<()> { + let mut config = self.load_config()?; + + if install::install_missing_dependencies(&mut config).await && config.auto_detect_remappings + { + // need to re-configure here to also catch additional remappings + config = self.load_config()?; + } + + let project = config.project()?; + + // Collect sources to check if subdirectories specified. + let mut files = vec![]; + if let Some(paths) = &self.paths { + for path in paths { + let joined = project.root().join(path); + let path = if joined.exists() { &joined } else { path }; + files.extend(source_files_iter(path, MultiCompilerLanguage::FILE_EXTENSIONS)); + } + if files.is_empty() { + eyre::bail!("No source files found in specified check paths.") + } + } + + // Run check using Solar for Solidity files + self.check_solidity(&project, &config, files.as_slice())?; + + if !shell::is_json() { + sh_println!("Check completed successfully")?; + } + + Ok(()) + } + + fn check_solidity( + &self, + project: &Project, + config: &Config, + target_files: &[PathBuf], + ) -> Result<()> { + let sources = if target_files.is_empty() { + project.paths.read_input_files()? + } else { + let mut sources = Sources::new(); + for path in target_files { + let canonical = dunce::canonicalize(path)?; + let source = Source::read(&canonical)?; + sources.insert(canonical, source); + } + sources + }; + + let sources_by_version = + Graph::::resolve_sources(&project.paths, sources)? + .into_sources_by_version(project)?; + + for (lang, versions) in sources_by_version.sources { + // Only check Solidity sources + if lang != MultiCompilerLanguage::Solc(SolcLanguage::Solidity) { + continue; + } + + for (version, sources, _) in versions { + let vinput = SolcVersionedInput::build( + sources, + config.solc_settings()?, + SolcLanguage::Solidity, + version.clone(), + ); + + let mut sess = solar::interface::Session::builder().with_stderr_emitter().build(); + sess.dcx.set_flags_mut(|flags| flags.track_diagnostics = false); + + let mut compiler = Compiler::new(sess); + + let result = compiler.enter_mut(|compiler| -> Result<()> { + let mut pcx = compiler.parse(); + configure_pcx_from_solc(&mut pcx, &project.paths, &vinput, true); + pcx.parse(); + + let _ = compiler.lower_asts(); + + Ok(()) + }); + + if compiler.sess().dcx.has_errors().is_err() { + eyre::bail!("Check failed for Solidity version {}", version); + } + + result?; + } + } + + Ok(()) + } + + /// Returns the `Project` for the current workspace + pub fn project(&self) -> Result { + self.build.project() + } + + /// Returns whether `CheckArgs` was configured with `--watch` + pub fn is_watch(&self) -> bool { + self.watch.watch.is_some() + } + + /// Returns the [`watchexec::Config`] necessary to bootstrap a new watch loop. + pub(crate) fn watchexec_config(&self) -> Result { + self.watch.watchexec_config(|| { + let config = self.load_config()?; + let foundry_toml: PathBuf = config.root.join(Config::FILE_NAME); + Ok([config.src, config.test, config.script, foundry_toml]) + }) + } +} + +// Make this args a `figment::Provider` so that it can be merged into the `Config` +impl Provider for CheckArgs { + fn metadata(&self) -> Metadata { + Metadata::named("Check Args Provider") + } + + fn data(&self) -> Result, figment::Error> { + let value = Value::serialize(self)?; + let error = InvalidType(value.to_actual(), "map".into()); + let dict = value.into_dict().ok_or(error)?; + + Ok(Map::from([(Config::selected_profile(), dict)])) + } +} diff --git a/crates/forge/src/cmd/mod.rs b/crates/forge/src/cmd/mod.rs index 0a0945bab99e9..4fdacf5ef7f3e 100644 --- a/crates/forge/src/cmd/mod.rs +++ b/crates/forge/src/cmd/mod.rs @@ -9,6 +9,7 @@ pub mod bind; pub mod bind_json; pub mod build; pub mod cache; +pub mod check; pub mod clone; pub mod compiler; pub mod config; diff --git a/crates/forge/src/cmd/watch.rs b/crates/forge/src/cmd/watch.rs index 67aa70546b89a..fe75df31452b6 100644 --- a/crates/forge/src/cmd/watch.rs +++ b/crates/forge/src/cmd/watch.rs @@ -1,5 +1,5 @@ use super::{ - build::BuildArgs, coverage::CoverageArgs, doc::DocArgs, fmt::FmtArgs, + build::BuildArgs, check::CheckArgs, coverage::CoverageArgs, doc::DocArgs, fmt::FmtArgs, snapshot::GasSnapshotArgs, test::TestArgs, }; use alloy_primitives::map::HashSet; @@ -283,6 +283,13 @@ pub async fn watch_build(args: BuildArgs) -> Result<()> { run(config).await } +/// Executes a [`Watchexec`] that listens for changes in the project's src dir and reruns `forge +/// check` +pub async fn watch_check(args: CheckArgs) -> Result<()> { + let config = args.watchexec_config()?; + run(config).await +} + /// Executes a [`Watchexec`] that listens for changes in the project's src dir and reruns `forge /// snapshot` pub async fn watch_gas_snapshot(args: GasSnapshotArgs) -> Result<()> { diff --git a/crates/forge/src/opts.rs b/crates/forge/src/opts.rs index f025cba73eb8d..97f6ba9d26998 100644 --- a/crates/forge/src/opts.rs +++ b/crates/forge/src/opts.rs @@ -1,9 +1,9 @@ use crate::cmd::{ - bind::BindArgs, bind_json, build::BuildArgs, cache::CacheArgs, clone::CloneArgs, - compiler::CompilerArgs, config, coverage, create::CreateArgs, doc::DocArgs, eip712, flatten, - fmt::FmtArgs, geiger, generate, init::InitArgs, inspect, install::InstallArgs, lint::LintArgs, - remappings::RemappingArgs, remove::RemoveArgs, selectors::SelectorsSubcommands, snapshot, - soldeer, test, tree, update, + bind::BindArgs, bind_json, build::BuildArgs, cache::CacheArgs, check::CheckArgs, + clone::CloneArgs, compiler::CompilerArgs, config, coverage, create::CreateArgs, doc::DocArgs, + eip712, flatten, fmt::FmtArgs, geiger, generate, init::InitArgs, inspect, install::InstallArgs, + lint::LintArgs, remappings::RemappingArgs, remove::RemoveArgs, selectors::SelectorsSubcommands, + snapshot, soldeer, test, tree, update, }; use clap::{Parser, Subcommand, ValueHint}; use forge_script::ScriptArgs; @@ -50,6 +50,14 @@ pub enum ForgeSubcommand { #[command(visible_aliases = ["b", "compile"])] Build(BuildArgs), + /// Check the project's smart contracts for errors. + /// + /// Performs parsing, lowering and semantic analysis without codegen and optimizer steps. + /// This is faster than `build` but cannot guarantee that code will compile due to + /// skipped stack scheduling. + #[command(visible_alias = "ch")] + Check(CheckArgs), + /// Clone a contract from Etherscan. Clone(CloneArgs), diff --git a/crates/forge/tests/cli/check.rs b/crates/forge/tests/cli/check.rs new file mode 100644 index 0000000000000..8be2ab5c36c80 --- /dev/null +++ b/crates/forge/tests/cli/check.rs @@ -0,0 +1,89 @@ +use foundry_test_utils::{forgetest, str}; + +// Test that `forge check` succeeds on a valid project +forgetest_init!(check_basic, |prj, cmd| { + cmd.args(["check"]).assert_success().stdout_eq(str![[r#" +Check completed successfully + +"#]]); +}); + +// Test that `forge check` detects syntax errors +forgetest!(check_syntax_error, |prj, cmd| { + prj.add_source( + "SyntaxError", + r" +contract SyntaxError { + uint256 public value = 10 // missing semicolon + + function test() public {} +} +", + ); + + cmd.args(["check"]).assert_failure().stderr_eq(str![[" +error: expected one of `(`, `.`, `;`, `?`, `[`, or `{`, found keyword `function` +[..] +[..] +[..] +[..] +[..] +[..] +[..] + +Error: Check failed for Solidity version [..] + +"]]); +}); + +// Test that `forge check` detects undefined symbols +forgetest!(check_undefined_symbol, |prj, cmd| { + prj.add_source( + "UndefinedVar", + r" +contract UndefinedVar { + function test() public pure returns (uint256) { + return undefinedVariable; + } +} +", + ); + + cmd.args(["check"]).assert_failure().stderr_eq(str![[" +error: unresolved symbol `undefinedVariable` +[..] +[..] +[..] +[..] + +Error: Check failed for Solidity version [..] + +"]]); +}); + +// Test that `forge check` can check specific paths +forgetest!(check_specific_path, |prj, cmd| { + prj.add_source( + "Good", + r" +contract Good { + uint256 public value; +} +", + ); + + prj.add_source( + "Bad", + r" +contract Bad { + uint256 public value = 10 // missing semicolon +} +", + ); + + // Check only the good file should succeed + cmd.args(["check", "src/Good.sol"]).assert_success().stdout_eq(str![[r#" +Check completed successfully + +"#]]); +}); diff --git a/crates/forge/tests/cli/main.rs b/crates/forge/tests/cli/main.rs index 975d7532cbd8d..8a0190dc7a8e9 100644 --- a/crates/forge/tests/cli/main.rs +++ b/crates/forge/tests/cli/main.rs @@ -9,6 +9,7 @@ mod bind; mod bind_json; mod build; mod cache; +mod check; mod cmd; mod compiler; mod config;