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
This commit is contained in:
Matthias Seitz 2022-03-15 13:27:49 +01:00 committed by GitHub
parent fac944be00
commit 49ed78d685
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 674 additions and 3 deletions

View File

@ -37,6 +37,7 @@ sha2 = { version = "0.9.8", default-features = false }
dunce = "1.0.2" dunce = "1.0.2"
solang-parser = { default-features = false, version = "0.1.10" } solang-parser = { default-features = false, version = "0.1.10" }
rayon = "1.5.1" rayon = "1.5.1"
rand = { version = "0.8.5", optional = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
home = "0.5.3" home = "0.5.3"
@ -70,12 +71,17 @@ name = "project"
path = "tests/project.rs" path = "tests/project.rs"
required-features = ["async", "svm", "project-util"] required-features = ["async", "svm", "project-util"]
[[test]]
name = "mocked"
path = "tests/mocked.rs"
required-features = ["async", "svm", "project-util"]
[features] [features]
default = ["rustls"] default = ["rustls"]
async = ["tokio", "futures-util"] async = ["tokio", "futures-util"]
full = ["async", "svm", "svm/blocking"] full = ["async", "svm", "svm/blocking"]
# Utilities for creating and testing project workspaces # Utilities for creating and testing project workspaces
project-util = ["tempfile", "fs_extra"] project-util = ["tempfile", "fs_extra", "rand"]
tests = [] tests = []
openssl = ["svm/openssl"] openssl = ["svm/openssl"]
rustls = ["svm/rustls"] rustls = ["svm/rustls"]

View File

@ -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)] #[derive(Debug, Error)]
#[error("\"{}\": {io}", self.path.display())] #[error("\"{}\": {io}", self.path.display())]
pub struct SolcIoError { pub struct SolcIoError {

View File

@ -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<dyn NamingStrategy + 'static>,
/// 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<MockFile>,
/// all libraries
libraries: Vec<MockLib>,
}
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<str>) -> 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<Remapping> {
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<Remapping> {
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<Item = usize> + '_ {
self.files.iter().map(|f| f.id)
}
/// All ids of internal files
pub fn internal_file_ids(&self) -> impl Iterator<Item = usize> + '_ {
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<Item = usize> + '_ {
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<R: Rng + ?Sized>(
&self,
rng: &mut R,
id: usize,
num: usize,
) -> BTreeSet<MockImport> {
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<R: Rng + ?Sized>(
&self,
rng: &mut R,
lib_id: usize,
id: usize,
num: usize,
) -> BTreeSet<MockImport> {
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<MockImport>,
/// lib id if this file is part of a lib
pub lib_id: Option<usize>,
}
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();
}
}

View File

@ -2,8 +2,9 @@
use crate::{ use crate::{
artifacts::Settings, artifacts::Settings,
config::ProjectPathsConfigBuilder, config::ProjectPathsConfigBuilder,
error::{Result, SolcError}, error::{bail, Result, SolcError},
hh::HardhatArtifacts, hh::HardhatArtifacts,
project_util::mock::{MockProjectGenerator, MockProjectSettings},
utils::tempdir, utils::tempdir,
ArtifactOutput, ConfigurableArtifacts, PathStyle, Project, ProjectCompileOutput, ArtifactOutput, ConfigurableArtifacts, PathStyle, Project, ProjectCompileOutput,
ProjectPathsConfig, SolcIoError, ProjectPathsConfig, SolcIoError,
@ -15,6 +16,8 @@ use std::{
}; };
use tempfile::TempDir; use tempfile::TempDir;
pub mod mock;
/// A [`Project`] wrapper that lives in a new temporary directory /// A [`Project`] wrapper that lives in a new temporary directory
/// ///
/// Once `TempProject` is dropped, the temp dir is automatically removed, see [`TempDir::drop()`] /// Once `TempProject` is dropped, the temp dir is automatically removed, see [`TempDir::drop()`]
@ -154,6 +157,27 @@ impl<T: ArtifactOutput> TempProject<T> {
create_contract_file(lib, content) create_contract_file(lib, content)
} }
/// Adds a basic lib contract `contract <name> {}` as a new file
pub fn add_basic_lib(
&self,
name: impl AsRef<str>,
version: impl AsRef<str>,
) -> Result<PathBuf> {
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 /// Adds a new source file inside the project's source dir
pub fn add_source(&self, name: impl AsRef<str>, content: impl AsRef<str>) -> Result<PathBuf> { pub fn add_source(&self, name: impl AsRef<str>, content: impl AsRef<str>) -> Result<PathBuf> {
let name = contract_file_name(name); let name = contract_file_name(name);
@ -161,6 +185,27 @@ impl<T: ArtifactOutput> TempProject<T> {
create_contract_file(source, content) create_contract_file(source, content)
} }
/// Adds a basic source contract `contract <name> {}` as a new file
pub fn add_basic_source(
&self,
name: impl AsRef<str>,
version: impl AsRef<str>,
) -> Result<PathBuf> {
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. /// Adds a solidity contract in the project's root dir.
/// This will also create all intermediary dirs. /// This will also create all intermediary dirs.
pub fn add_contract(&self, name: impl AsRef<str>, content: impl AsRef<str>) -> Result<PathBuf> { pub fn add_contract(&self, name: impl AsRef<str>, content: impl AsRef<str>) -> Result<PathBuf> {
@ -168,6 +213,89 @@ impl<T: ArtifactOutput> TempProject<T> {
let source = self.root().join(name); let source = self.root().join(name);
create_contract_file(source, content) create_contract_file(source, content)
} }
/// Populate the project with mock files
pub fn mock(&self, gen: &MockProjectGenerator, version: impl AsRef<str>) -> 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<T: ArtifactOutput + Default> TempProject<T> { impl<T: ArtifactOutput + Default> TempProject<T> {
@ -197,7 +325,7 @@ impl<T: ArtifactOutput> fmt::Debug for TempProject<T> {
} }
} }
fn create_contract_file(path: PathBuf, content: impl AsRef<str>) -> Result<PathBuf> { pub(crate) fn create_contract_file(path: PathBuf, content: impl AsRef<str>) -> Result<PathBuf> {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent) std::fs::create_dir_all(parent)
.map_err(|err| SolcIoError::new(err, parent.to_path_buf()))?; .map_err(|err| SolcIoError::new(err, parent.to_path_buf()))?;
@ -237,6 +365,40 @@ impl TempProject<ConfigurableArtifacts> {
let inner = Project::builder().paths(paths).build()?; let inner = Project::builder().paths(paths).build()?;
Ok(Self::create_new(tmp_dir, inner)?) 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<str>) -> Result<Self> {
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<str>) -> Result<Self> {
Self::mocked(&MockProjectSettings::random(), version)
}
} }
impl<T: ArtifactOutput> AsRef<Project<T>> for TempProject<T> { impl<T: ArtifactOutput> AsRef<Project<T>> for TempProject<T> {
@ -284,3 +446,14 @@ pub fn copy_dir(source: impl AsRef<Path>, target_dir: impl AsRef<Path>) -> Resul
fs_extra::dir::copy(source, target_dir, &dir_copy_options())?; fs_extra::dir::copy(source, target_dir, &dir_copy_options())?;
Ok(()) 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();
}
}

View File

@ -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<MockProjectSettings> 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<MockSettings>,
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<MockSettings>) {
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())
}