From 49ed78d6857076e865c839e917afab34fb7195bd Mon Sep 17 00:00:00 2001 From: Matthias Seitz Date: Tue, 15 Mar 2022 13:27:49 +0100 Subject: [PATCH] feat(solc): add mock project generator (#1011) * feat(solc): add mock project generator * feat: mock project * refactor: rename to mock * feat(solc): mock project support * chore: export helper macros * fix(deps): add required features * style: allow unused --- ethers-solc/Cargo.toml | 8 +- ethers-solc/src/error.rs | 14 + ethers-solc/src/project_util/mock.rs | 397 ++++++++++++++++++ .../{project_util.rs => project_util/mod.rs} | 177 +++++++- ethers-solc/tests/mocked.rs | 81 ++++ 5 files changed, 674 insertions(+), 3 deletions(-) create mode 100644 ethers-solc/src/project_util/mock.rs rename ethers-solc/src/{project_util.rs => project_util/mod.rs} (61%) create mode 100644 ethers-solc/tests/mocked.rs diff --git a/ethers-solc/Cargo.toml b/ethers-solc/Cargo.toml index 26b14ca7..26e03d0f 100644 --- a/ethers-solc/Cargo.toml +++ b/ethers-solc/Cargo.toml @@ -37,6 +37,7 @@ sha2 = { version = "0.9.8", default-features = false } dunce = "1.0.2" solang-parser = { default-features = false, version = "0.1.10" } rayon = "1.5.1" +rand = { version = "0.8.5", optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] home = "0.5.3" @@ -70,12 +71,17 @@ name = "project" path = "tests/project.rs" required-features = ["async", "svm", "project-util"] +[[test]] +name = "mocked" +path = "tests/mocked.rs" +required-features = ["async", "svm", "project-util"] + [features] default = ["rustls"] async = ["tokio", "futures-util"] full = ["async", "svm", "svm/blocking"] # Utilities for creating and testing project workspaces -project-util = ["tempfile", "fs_extra"] +project-util = ["tempfile", "fs_extra", "rand"] tests = [] openssl = ["svm/openssl"] rustls = ["svm/rustls"] diff --git a/ethers-solc/src/error.rs b/ethers-solc/src/error.rs index 2d6751a5..8271cc81 100644 --- a/ethers-solc/src/error.rs +++ b/ethers-solc/src/error.rs @@ -57,6 +57,20 @@ impl SolcError { } } +macro_rules! _format_err { + ($($tt:tt)*) => { + $crate::error::SolcError::msg(format!($($tt)*)) + }; +} +#[allow(unused)] +pub(crate) use _format_err as format_err; + +macro_rules! _bail { + ($($tt:tt)*) => { return Err($crate::error::format_err!($($tt)*)) }; +} +#[allow(unused)] +pub(crate) use _bail as bail; + #[derive(Debug, Error)] #[error("\"{}\": {io}", self.path.display())] pub struct SolcIoError { diff --git a/ethers-solc/src/project_util/mock.rs b/ethers-solc/src/project_util/mock.rs new file mode 100644 index 00000000..07fee0ea --- /dev/null +++ b/ethers-solc/src/project_util/mock.rs @@ -0,0 +1,397 @@ +//! Helpers to generate mock projects + +use crate::{error::Result, remappings::Remapping, ProjectPathsConfig}; +use rand::{self, seq::SliceRandom, Rng}; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeSet, path::Path}; + +/// Represents a virtual project +#[derive(Serialize)] +pub struct MockProjectGenerator { + /// how to name things + #[serde(skip)] + name_strategy: Box, + /// id counter for a file + next_file_id: usize, + /// id counter for a file + next_lib_id: usize, + /// all files for the project + files: Vec, + /// all libraries + libraries: Vec, +} + +impl Default for MockProjectGenerator { + fn default() -> Self { + Self { + name_strategy: Box::new(SimpleNamingStrategy::default()), + next_file_id: 0, + next_lib_id: 0, + files: Default::default(), + libraries: Default::default(), + } + } +} + +impl MockProjectGenerator { + /// Generate all solidity files and write under the paths config + pub fn write_to(&self, paths: &ProjectPathsConfig, version: impl AsRef) -> Result<()> { + let version = version.as_ref(); + for file in self.files.iter() { + let mut imports = Vec::with_capacity(file.imports.len()); + + for import in file.imports.iter() { + match *import { + MockImport::Internal(f) => { + imports.push(format!("import \"./{}.sol\";", self.files[f].name)); + } + MockImport::External(lib, f) => { + imports.push(format!( + "import \"{}/{}.sol\";", + self.libraries[lib].name, self.files[f].name + )); + } + } + } + + let content = format!( + r#" +// SPDX-License-Identifier: UNLICENSED +pragma solidity {}; +{} +contract {} {{}} + "#, + version, + imports.join("\n"), + file.name + ); + + let mut target = if let Some(lib) = file.lib_id { + paths.root.join("lib").join(&self.libraries[lib].name).join("src").join(&file.name) + } else { + paths.sources.join(&file.name) + }; + target.set_extension("sol"); + + super::create_contract_file(target, content)?; + } + + Ok(()) + } + + /// Returns all the remappings for the project for the given root path + pub fn remappings_at(&self, root: &Path) -> Vec { + self.libraries + .iter() + .map(|lib| { + let path = root.join("lib").join(&lib.name).join("src"); + format!("{}/={}/", lib.name, path.display()).parse().unwrap() + }) + .collect() + } + + /// Returns all the remappings for the project + pub fn remappings(&self) -> Vec { + self.libraries + .iter() + .map(|lib| format!("{0}/=lib/{0}/src/", lib.name).parse().unwrap()) + .collect() + } + + /// Create a new project and populate it using the given settings + pub fn new(settings: &MockProjectSettings) -> Self { + let mut mock = Self::default(); + mock.populate(settings); + mock + } + + /// Generates a random project with random settings + pub fn random() -> Self { + let settings = MockProjectSettings::random(); + let mut mock = Self::default(); + mock.populate(&settings); + mock + } + + /// Adds sources and libraries and populates imports based on the settings + pub fn populate(&mut self, settings: &MockProjectSettings) -> &mut Self { + self.add_sources(settings.num_lib_files); + for _ in 0..settings.num_libs { + self.add_lib(settings.num_lib_files); + } + self.populate_imports(settings) + } + + fn next_file_id(&mut self) -> usize { + let next = self.next_file_id; + self.next_file_id += 1; + next + } + + fn next_lib_id(&mut self) -> usize { + let next = self.next_lib_id; + self.next_lib_id += 1; + next + } + + /// Adds a new source file + pub fn add_source(&mut self) -> &mut Self { + let id = self.next_file_id(); + let name = self.name_strategy.new_source_file_name(id); + let file = MockFile { id, name, imports: Default::default(), lib_id: None }; + self.files.push(file); + self + } + + /// Adds `num` new source files + pub fn add_sources(&mut self, num: usize) -> &mut Self { + for _ in 0..num { + self.add_source(); + } + self + } + + /// Adds a new lib with the number of lib files + pub fn add_lib(&mut self, num_files: usize) -> &mut Self { + let lib_id = self.next_lib_id(); + let lib_name = self.name_strategy.new_lib_name(lib_id); + let offset = self.files.len(); + for _ in 0..num_files { + let id = self.next_file_id(); + let name = self.name_strategy.new_lib_file_name(id); + self.files.push(MockFile { + id, + name, + imports: Default::default(), + lib_id: Some(lib_id), + }); + } + self.libraries.push(MockLib { name: lib_name, id: lib_id, num_files, offset }); + self + } + + /// Populates the imports of the project + pub fn populate_imports(&mut self, settings: &MockProjectSettings) -> &mut Self { + let mut rng = rand::thread_rng(); + + // populate imports + for id in 0..self.files.len() { + let imports = if let Some(lib) = self.files[id].lib_id { + let num_imports = rng + .gen_range(settings.min_imports..=settings.max_imports) + .min(self.libraries[lib].num_files.saturating_sub(1)); + self.unique_imports_for_lib(&mut rng, lib, id, num_imports) + } else { + let num_imports = rng + .gen_range(settings.min_imports..=settings.max_imports) + .min(self.files.len().saturating_sub(1)); + self.unique_imports_for_source(&mut rng, id, num_imports) + }; + + self.files[id].imports = imports; + } + self + } + + fn get_import(&self, id: usize) -> MockImport { + if let Some(lib) = self.files[id].lib_id { + MockImport::External(lib, id) + } else { + MockImport::Internal(id) + } + } + + /// All file ids + pub fn file_ids(&self) -> impl Iterator + '_ { + self.files.iter().map(|f| f.id) + } + + /// All ids of internal files + pub fn internal_file_ids(&self) -> impl Iterator + '_ { + self.files.iter().filter(|f| !f.is_external()).map(|f| f.id) + } + + /// All ids of external files + pub fn external_file_ids(&self) -> impl Iterator + '_ { + self.files.iter().filter(|f| f.is_external()).map(|f| f.id) + } + + /// generates exactly `num` unique imports in the range of all files + /// + /// # Panics + /// + /// if `num` can't be satisfied because the range is too narrow + fn unique_imports_for_source( + &self, + rng: &mut R, + id: usize, + num: usize, + ) -> BTreeSet { + assert!(self.files.len() > num); + let mut imports: Vec<_> = (0..self.files.len()).collect(); + imports.shuffle(rng); + imports.into_iter().filter(|i| *i != id).map(|id| self.get_import(id)).take(num).collect() + } + + /// generates exactly `num` unique imports in the range of a lib's files + /// + /// # Panics + /// + /// if `num` can't be satisfied because the range is too narrow + fn unique_imports_for_lib( + &self, + rng: &mut R, + lib_id: usize, + id: usize, + num: usize, + ) -> BTreeSet { + let lib = &self.libraries[lib_id]; + assert!(lib.num_files > num); + let mut imports: Vec<_> = (lib.offset..(lib.offset + lib.len())).collect(); + imports.shuffle(rng); + imports.into_iter().filter(|i| *i != id).map(|id| self.get_import(id)).take(num).collect() + } +} + +/// Used to determine the names for elements +trait NamingStrategy { + /// Return a new name for the given source file id + fn new_source_file_name(&mut self, id: usize) -> String; + + /// Return a new name for the given source file id + fn new_lib_file_name(&mut self, id: usize) -> String; + + /// Return a new name for the given lib id + fn new_lib_name(&mut self, id: usize) -> String; +} + +/// A primitive naming that simply uses ids to create unique names +#[derive(Debug, Clone, Copy, Default)] +pub struct SimpleNamingStrategy { + _priv: (), +} + +impl NamingStrategy for SimpleNamingStrategy { + fn new_source_file_name(&mut self, id: usize) -> String { + format!("SourceFile{}", id) + } + + fn new_lib_file_name(&mut self, id: usize) -> String { + format!("LibFile{}", id) + } + + fn new_lib_name(&mut self, id: usize) -> String { + format!("Lib{}", id) + } +} + +/// Skeleton of a mock source file +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MockFile { + /// internal id of this file + pub id: usize, + /// The source name of this file + pub name: String, + /// all the imported files + pub imports: BTreeSet, + /// lib id if this file is part of a lib + pub lib_id: Option, +} + +impl MockFile { + /// Returns `true` if this file is part of an external lib + pub fn is_external(&self) -> bool { + self.lib_id.is_some() + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)] +pub enum MockImport { + /// Import from the same project + Internal(usize), + /// external library import + /// (`lib id`, `file id`) + External(usize, usize), +} + +/// Container of a mock lib +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MockLib { + /// name of the lib, like `ds-test` + pub name: String, + /// internal id of this lib + pub id: usize, + /// offset in the total set of files + pub offset: usize, + /// number of files included in this lib + pub num_files: usize, +} + +impl MockLib { + pub fn len(&self) -> usize { + self.num_files + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +/// Settings to use when generate a mock project +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +pub struct MockProjectSettings { + /// number of source files to generate + pub num_sources: usize, + /// number of libraries to use + pub num_libs: usize, + /// how many lib files to generate per lib + pub num_lib_files: usize, + /// min amount of import statements a file can use + pub min_imports: usize, + /// max amount of import statements a file can use + pub max_imports: usize, +} + +impl MockProjectSettings { + /// Generates a new instance with random settings within an arbitrary range + pub fn random() -> Self { + let mut rng = rand::thread_rng(); + // arbitrary thresholds + MockProjectSettings { + num_sources: rng.gen_range(2..25), + num_libs: rng.gen_range(0..5), + num_lib_files: rng.gen_range(1..10), + min_imports: rng.gen_range(0..3), + max_imports: rng.gen_range(4..10), + } + } + + /// Generates settings for a large project + pub fn large() -> Self { + // arbitrary thresholds + MockProjectSettings { + num_sources: 35, + num_libs: 4, + num_lib_files: 15, + min_imports: 3, + max_imports: 12, + } + } +} + +impl Default for MockProjectSettings { + fn default() -> Self { + // these are arbitrary + Self { num_sources: 20, num_libs: 2, num_lib_files: 10, min_imports: 0, max_imports: 5 } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn can_generate_mock_project() { + let _ = MockProjectGenerator::random(); + } +} diff --git a/ethers-solc/src/project_util.rs b/ethers-solc/src/project_util/mod.rs similarity index 61% rename from ethers-solc/src/project_util.rs rename to ethers-solc/src/project_util/mod.rs index 8683f264..1f7ae59c 100644 --- a/ethers-solc/src/project_util.rs +++ b/ethers-solc/src/project_util/mod.rs @@ -2,8 +2,9 @@ use crate::{ artifacts::Settings, config::ProjectPathsConfigBuilder, - error::{Result, SolcError}, + error::{bail, Result, SolcError}, hh::HardhatArtifacts, + project_util::mock::{MockProjectGenerator, MockProjectSettings}, utils::tempdir, ArtifactOutput, ConfigurableArtifacts, PathStyle, Project, ProjectCompileOutput, ProjectPathsConfig, SolcIoError, @@ -15,6 +16,8 @@ use std::{ }; use tempfile::TempDir; +pub mod mock; + /// A [`Project`] wrapper that lives in a new temporary directory /// /// Once `TempProject` is dropped, the temp dir is automatically removed, see [`TempDir::drop()`] @@ -154,6 +157,27 @@ impl TempProject { create_contract_file(lib, content) } + /// Adds a basic lib contract `contract {}` as a new file + pub fn add_basic_lib( + &self, + name: impl AsRef, + version: impl AsRef, + ) -> Result { + let name = name.as_ref(); + self.add_lib( + name, + format!( + r#" +// SPDX-License-Identifier: UNLICENSED +pragma solidity {}; +contract {} {{}} + "#, + name, + version.as_ref() + ), + ) + } + /// Adds a new source file inside the project's source dir pub fn add_source(&self, name: impl AsRef, content: impl AsRef) -> Result { let name = contract_file_name(name); @@ -161,6 +185,27 @@ impl TempProject { create_contract_file(source, content) } + /// Adds a basic source contract `contract {}` as a new file + pub fn add_basic_source( + &self, + name: impl AsRef, + version: impl AsRef, + ) -> Result { + let name = name.as_ref(); + self.add_source( + name, + format!( + r#" +// SPDX-License-Identifier: UNLICENSED +pragma solidity {}; +contract {} {{}} + "#, + name, + version.as_ref() + ), + ) + } + /// Adds a solidity contract in the project's root dir. /// This will also create all intermediary dirs. pub fn add_contract(&self, name: impl AsRef, content: impl AsRef) -> Result { @@ -168,6 +213,89 @@ impl TempProject { let source = self.root().join(name); create_contract_file(source, content) } + + /// Populate the project with mock files + pub fn mock(&self, gen: &MockProjectGenerator, version: impl AsRef) -> Result<()> { + gen.write_to(self.paths(), version) + } + + /// Compiles the project and ensures that the output does not contain errors + pub fn ensure_no_errors(&self) -> Result<&Self> { + let compiled = self.compile().unwrap(); + if compiled.has_compiler_errors() { + bail!("Compiled with errors {}", compiled) + } + Ok(self) + } + + /// Compiles the project and ensures that the output is __unchanged__ + pub fn ensure_unchanged(&self) -> Result<&Self> { + let compiled = self.compile().unwrap(); + if !compiled.is_unchanged() { + bail!("Compiled with detected changes {}", compiled) + } + Ok(self) + } + + /// Compiles the project and ensures that the output has __changed__ + pub fn ensure_changed(&self) -> Result<&Self> { + let compiled = self.compile().unwrap(); + if compiled.is_unchanged() { + bail!("Compiled without detecting changes {}", compiled) + } + Ok(self) + } + + /// Compiles the project and ensures that the output does not contain errors and no changes + /// exists on recompiled. + /// + /// This is a convenience function for + /// + /// ```no_run + /// use ethers_solc::project_util::TempProject; + /// let project = TempProject::dapptools().unwrap(); + // project.ensure_no_errors().unwrap(); + // project.ensure_unchanged().unwrap(); + /// ``` + pub fn ensure_no_errors_recompile_unchanged(&self) -> Result<&Self> { + self.ensure_no_errors()?.ensure_unchanged() + } + + /// Compiles the project and asserts that the output does not contain errors and no changes + /// exists on recompiled. + /// + /// This is a convenience function for + /// + /// ```no_run + /// use ethers_solc::project_util::TempProject; + /// let project = TempProject::dapptools().unwrap(); + // project.assert_no_errors(); + // project.assert_unchanged(); + /// ``` + pub fn assert_no_errors_recompile_unchanged(&self) -> &Self { + self.assert_no_errors().assert_unchanged() + } + + /// Compiles the project and asserts that the output does not contain errors + pub fn assert_no_errors(&self) -> &Self { + let compiled = self.compile().unwrap(); + assert!(!compiled.has_compiler_errors()); + self + } + + /// Compiles the project and asserts that the output is unchanged + pub fn assert_unchanged(&self) -> &Self { + let compiled = self.compile().unwrap(); + assert!(compiled.is_unchanged()); + self + } + + /// Compiles the project and asserts that the output is _changed_ + pub fn assert_changed(&self) -> &Self { + let compiled = self.compile().unwrap(); + assert!(!compiled.is_unchanged()); + self + } } impl TempProject { @@ -197,7 +325,7 @@ impl fmt::Debug for TempProject { } } -fn create_contract_file(path: PathBuf, content: impl AsRef) -> Result { +pub(crate) fn create_contract_file(path: PathBuf, content: impl AsRef) -> Result { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .map_err(|err| SolcIoError::new(err, parent.to_path_buf()))?; @@ -237,6 +365,40 @@ impl TempProject { let inner = Project::builder().paths(paths).build()?; Ok(Self::create_new(tmp_dir, inner)?) } + + /// Create a new temporary project and populate it with mock files + /// + /// ```no_run + /// use ethers_solc::project_util::mock::MockProjectSettings; + /// use ethers_solc::project_util::TempProject; + /// let tmp = TempProject::mocked(&MockProjectSettings::default(), "^0.8.10").unwrap(); + /// ``` + pub fn mocked(settings: &MockProjectSettings, version: impl AsRef) -> Result { + let mut tmp = Self::dapptools()?; + let gen = MockProjectGenerator::new(settings); + tmp.mock(&gen, version)?; + let remappings = gen.remappings_at(tmp.root()); + tmp.paths_mut().remappings.extend(remappings); + Ok(tmp) + } + + /// Create a new temporary project and populate it with a random layout + /// + /// ```no_run + /// use ethers_solc::project_util::TempProject; + /// let tmp = TempProject::mocked_random("^0.8.10").unwrap(); + /// ``` + /// + /// This is a convenience function for: + /// + /// ```no_run + /// use ethers_solc::project_util::mock::MockProjectSettings; + /// use ethers_solc::project_util::TempProject; + /// let tmp = TempProject::mocked(&MockProjectSettings::random(), "^0.8.10").unwrap(); + /// ``` + pub fn mocked_random(version: impl AsRef) -> Result { + Self::mocked(&MockProjectSettings::random(), version) + } } impl AsRef> for TempProject { @@ -284,3 +446,14 @@ pub fn copy_dir(source: impl AsRef, target_dir: impl AsRef) -> Resul fs_extra::dir::copy(source, target_dir, &dir_copy_options())?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn can_mock_project() { + let _prj = TempProject::mocked(&Default::default(), "^0.8.11").unwrap(); + let _prj = TempProject::mocked_random("^0.8.11").unwrap(); + } +} diff --git a/ethers-solc/tests/mocked.rs b/ethers-solc/tests/mocked.rs new file mode 100644 index 00000000..946ae132 --- /dev/null +++ b/ethers-solc/tests/mocked.rs @@ -0,0 +1,81 @@ +//! mocked project tests + +use ethers_solc::{ + error::Result, + project_util::{ + mock::{MockProjectGenerator, MockProjectSettings}, + TempProject, + }, +}; + +// default version to use +const DEFAULT_VERSION: &str = "^0.8.10"; + +struct MockSettings { + settings: MockProjectSettings, + version: &'static str, +} + +impl From for MockSettings { + fn from(settings: MockProjectSettings) -> Self { + MockSettings { settings, version: DEFAULT_VERSION } + } +} +impl From<(MockProjectSettings, &'static str)> for MockSettings { + fn from(input: (MockProjectSettings, &'static str)) -> Self { + MockSettings { settings: input.0, version: input.1 } + } +} + +/// Helper function to run a test and report the used generator if the closure failed. +fn run_mock( + settings: impl Into, + f: impl FnOnce(&mut TempProject) -> Result<()>, +) -> TempProject { + let MockSettings { settings, version } = settings.into(); + let gen = MockProjectGenerator::new(&settings); + let mut project = TempProject::dapptools().unwrap(); + let remappings = gen.remappings_at(project.root()); + project.paths_mut().remappings.extend(remappings); + project.mock(&gen, version).unwrap(); + + if let Err(err) = f(&mut project) { + panic!( + "mock failed: `{}` with mock settings:\n {}", + err, + serde_json::to_string(&gen).unwrap() + ); + } + + project +} + +/// Runs a basic set of tests for the given settings +fn run_basic(settings: impl Into) { + let settings = settings.into(); + let version = settings.version; + run_mock(settings, |project| { + project.ensure_no_errors_recompile_unchanged()?; + project.add_basic_source("Dummy", version)?; + project.ensure_changed()?; + Ok(()) + }); +} + +#[test] +fn can_compile_mocked_random() { + run_basic(MockProjectSettings::random()); +} + +// compile a bunch of random projects +#[test] +fn can_compile_mocked_multi() { + for _ in 0..10 { + run_basic(MockProjectSettings::random()); + } +} + +#[test] +fn can_compile_mocked_large() { + run_basic(MockProjectSettings::large()) +}