feat(core, contract): improve `determine_ethers_crates` (#1988)

* refactor: determine_ethers_crates

* feat: improve crate resolution

* export new types and functions

* export

* fix: file name check

* fix: invert condition and check CARGO_MANIFEST_DIR

* use abigen macro to generate multicall

* chore: rm debug

* rm unnecessary .replace

* chore: clippy

* chore: clippy

* Revert "chore: clippy"

This reverts commit bd220f308d.

* Revert "chore: clippy"

This reverts commit 5550f4e856.

* add tests

* better tests, docs

* add another test

* fix docs

* refactor: add an environment struct for determining ethers crates

* fix: use fmt::Debug to escape paths

* docs: rename and rm old docs

* feat: use global path for crates

* fix: docs

* chore: move rand impl to tests mod
This commit is contained in:
DaniPopes 2023-01-03 14:11:57 +01:00 committed by GitHub
parent fd4da49121
commit 97582cc346
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 594 additions and 1049 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,123 +1,606 @@
use cargo_metadata::MetadataCommand;
use once_cell::sync::Lazy;
use std::{
collections::HashMap,
env, fmt, fs,
path::{Path, PathBuf},
};
use strum::{EnumCount, EnumIter, EnumString, EnumVariantNames, IntoEnumIterator};
use syn::Path;
/// `ethers_crate => name`
type CrateNames = HashMap<EthersCrate, &'static str>;
/// See `determine_ethers_crates`
const DIRS: [&str; 3] = ["benches", "examples", "tests"];
/// Maps an [`EthersCrate`] to its path string.
///
/// This ensures that the `MetadataCommand` is only run once
static ETHERS_CRATES: Lazy<(&'static str, &'static str, &'static str)> =
Lazy::new(determine_ethers_crates);
/// See [`ProjectEnvironment`] for more information.
///
/// Note: this static variable cannot hold [`syn::Path`] because it is not [`Sync`], so the names
/// must be parsed at every call.
static ETHERS_CRATE_NAMES: Lazy<CrateNames> = Lazy::new(|| {
ProjectEnvironment::new_from_env()
.and_then(|x| x.determine_ethers_crates())
.unwrap_or_else(|| EthersCrate::ethers_path_names().collect())
});
/// Convenience function to turn the `ethers_core` name in `ETHERS_CRATE` into a `Path`
pub fn ethers_core_crate() -> Path {
syn::parse_str(ETHERS_CRATES.0).expect("valid path; qed")
}
/// Convenience function to turn the `ethers_contract` name in `ETHERS_CRATE` into an `Path`
pub fn ethers_contract_crate() -> Path {
syn::parse_str(ETHERS_CRATES.1).expect("valid path; qed")
}
pub fn ethers_providers_crate() -> Path {
syn::parse_str(ETHERS_CRATES.2).expect("valid path; qed")
/// Returns the `core` crate's [`Path`][syn::Path].
#[inline]
pub fn ethers_core_crate() -> syn::Path {
get_crate_path(EthersCrate::EthersCore)
}
/// The crates name to use when deriving macros: (`core`, `contract`)
///
/// We try to determine which crate ident to use based on the dependencies of
/// the project in which the macro is used. This is useful because the macros,
/// like `EthEvent` are provided by the `ethers-contract` crate which depends on
/// `ethers_core`. Most commonly `ethers` will be used as dependency which
/// reexports all the different crates, essentially `ethers::core` is
/// `ethers_core` So depending on the dependency used `ethers` ors `ethers_core
/// | ethers_contract`, we need to use the fitting crate ident when expand the
/// macros This will attempt to parse the current `Cargo.toml` and check the
/// ethers related dependencies.
///
/// This determines
/// - `ethers_*` idents if `ethers-core`, `ethers-contract`, `ethers-providers` are present in
/// the manifest or the `ethers` is _not_ present
/// - `ethers::*` otherwise
///
/// This process is a bit hacky, we run `cargo metadata` internally which
/// resolves the current package but creates a new `Cargo.lock` file in the
/// process. This is not a problem for regular workspaces but becomes an issue
/// during publishing with `cargo publish` if the project does not ignore
/// `Cargo.lock` in `.gitignore`, because then cargo can't proceed with
/// publishing the crate because the created `Cargo.lock` leads to a modified
/// workspace, not the `CARGO_MANIFEST_DIR` but the workspace `cargo publish`
/// created in `./target/package/..`. Therefore we check prior to executing
/// `cargo metadata` if a `Cargo.lock` file exists and delete it afterwards if
/// it was created by `cargo metadata`.
pub fn determine_ethers_crates() -> (&'static str, &'static str, &'static str) {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR");
/// Returns the `contract` crate's [`Path`][syn::Path].
#[inline]
pub fn ethers_contract_crate() -> syn::Path {
get_crate_path(EthersCrate::EthersContract)
}
// if there is no cargo manifest, default to `ethers::`-style imports.
let manifest_dir = if let Ok(manifest_dir) = manifest_dir {
manifest_dir
} else {
return ("ethers::core", "ethers::contract", "ethers::providers")
};
/// Returns the `providers` crate's [`Path`][syn::Path].
#[inline]
pub fn ethers_providers_crate() -> syn::Path {
get_crate_path(EthersCrate::EthersProviders)
}
// check if the lock file exists, if it's missing we need to clean up afterward
let lock_file = format!("{manifest_dir}/Cargo.lock");
let needs_lock_file_cleanup = !std::path::Path::new(&lock_file).exists();
/// Returns an [`EthersCrate`]'s [`Path`][syn::Path] in the current project.
#[inline(always)]
pub fn get_crate_path(krate: EthersCrate) -> syn::Path {
krate.get_path()
}
let res = MetadataCommand::new()
.manifest_path(format!("{manifest_dir}/Cargo.toml"))
.exec()
.ok()
.and_then(|metadata| {
metadata.root_package().and_then(|pkg| {
let sub_crates = Some(("ethers_core", "ethers_contract", "ethers_providers"));
/// Represents a generic Rust/Cargo project's environment.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProjectEnvironment {
manifest_dir: PathBuf,
crate_name: Option<String>,
}
// Note(mattsse): this is super hacky but required in order to compile and test
// ethers' internal crates
if [
"ethers-contract",
"ethers-derive-eip712",
"ethers-signers",
"ethers-middleware",
"ethers-solc",
]
.contains(&pkg.name.as_str())
{
return sub_crates
impl ProjectEnvironment {
pub fn new<T: Into<PathBuf>, U: Into<String>>(manifest_dir: T, crate_name: U) -> Self {
Self { manifest_dir: manifest_dir.into(), crate_name: Some(crate_name.into()) }
}
let mut has_ethers_core = false;
let mut has_ethers_contract = false;
let mut has_ethers_providers = false;
for dep in pkg.dependencies.iter() {
match dep.name.as_str() {
"ethers-core" => {
has_ethers_core = true;
}
"ethers-contract" => {
has_ethers_contract = true;
}
"ethers-providers" => {
has_ethers_providers = true;
}
"ethers" => return None,
_ => {}
}
}
if has_ethers_core && has_ethers_contract && has_ethers_providers {
return sub_crates
}
None
pub fn new_from_env() -> Option<Self> {
Some(Self {
manifest_dir: env::var_os("CARGO_MANIFEST_DIR")?.into(),
crate_name: env::var("CARGO_CRATE_NAME").ok(),
})
})
.unwrap_or(("ethers::core", "ethers::contract", "ethers::providers"));
}
if needs_lock_file_cleanup {
// delete the `Cargo.lock` file that was created by `cargo metadata`
// if the package is not part of a workspace
/// Determines the crate paths to use by looking at the [metadata][cargo_metadata] of the
/// project.
///
/// The names will be:
/// - `ethers::*` if `ethers` is a dependency for all crates;
/// - for each `crate`:
/// - `ethers_<crate>` if it is a dependency, otherwise `ethers::<crate>`.
#[inline]
pub fn determine_ethers_crates(&self) -> Option<CrateNames> {
let lock_file = self.manifest_dir.join("Cargo.lock");
let lock_file_existed = lock_file.exists();
let names = self.crate_names_from_metadata();
// remove the lock file created from running the command
if !lock_file_existed && lock_file.exists() {
let _ = std::fs::remove_file(lock_file);
}
res
names
}
#[inline]
pub fn crate_names_from_metadata(&self) -> Option<CrateNames> {
let metadata = MetadataCommand::new().current_dir(&self.manifest_dir).exec().ok()?;
let pkg = metadata.root_package()?;
// return ethers_* if the root package is an internal ethers crate since `ethers` is not
// available
let crate_is_root = self.is_crate_root();
if let Ok(current_pkg) = pkg.name.parse::<EthersCrate>() {
// replace `current_pkg`'s name with "crate"
let names =
EthersCrate::path_names()
.map(|(pkg, name)| {
if crate_is_root && pkg == current_pkg {
(pkg, "crate")
} else {
(pkg, name)
}
})
.collect();
return Some(names)
} /* else if pkg.name == "ethers" {
// should not happen (the root package the `ethers` workspace package itself)
} */
let mut names: CrateNames = EthersCrate::ethers_path_names().collect();
for dep in pkg.dependencies.iter() {
let name = dep.name.as_str();
if name.starts_with("ethers") {
if name == "ethers" {
return None
} else if let Ok(dep) = name.parse::<EthersCrate>() {
names.insert(dep, dep.path_name());
}
}
}
Some(names)
}
/// Returns whether the `crate` path identifier refers to the root package.
///
/// This is false for integration tests, benches, and examples, as the `crate` keyword will not
/// refer to the root package.
///
/// We can find this using some [environment variables set by Cargo during compilation][ref]:
/// - `CARGO_TARGET_TMPDIR` is only set when building integration test or benchmark code;
/// - When `CARGO_MANIFEST_DIR` contains `/benches/` or `/examples/`
/// - `CARGO_CRATE_NAME`, see `is_crate_name_in_dirs`.
///
/// [ref]: https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates
#[inline]
pub fn is_crate_root(&self) -> bool {
env::var_os("CARGO_TARGET_TMPDIR").is_none() &&
self.manifest_dir.components().all(|c| {
let s = c.as_os_str();
s != "examples" && s != "benches"
}) &&
!self.is_crate_name_in_dirs()
}
/// Returns whether `crate_name` is the name of a file or directory in the first level of
/// `manifest_dir/{benches,examples,tests}/`.
///
/// # Example
///
/// With this project structure:
///
/// ```text
/// .
/// ├── Cargo.lock
/// ├── Cargo.toml
/// ├── src/
/// │   ...
/// ├── benches/
/// │   ├── large-input.rs
/// │   └── multi-file-bench/
/// │   ├── main.rs
/// │   └── bench_module.rs
/// ├── examples/
/// │   ├── simple.rs
/// │   └── multi-file-example/
/// │   ├── main.rs
/// │   └── ex_module.rs
/// └── tests/
/// ├── some-integration-tests.rs
/// └── multi-file-test/
/// ├── main.rs
/// └── test_module.rs
/// ```
///
/// The resulting `CARGO_CRATE_NAME` values will be:
///
/// | Path | Value |
/// |:-------------------------------------- | ----------------------:|
/// | benches/large-input.rs | large-input |
/// | benches/multi-file-bench/\*\*/\*.rs | multi-file-bench |
/// | examples/simple.rs | simple |
/// | examples/multi-file-example/\*\*/\*.rs | multi-file-example |
/// | tests/some-integration-tests.rs | some-integration-tests |
/// | tests/multi-file-test/\*\*/\*.rs | multi-file-test |
#[inline]
pub fn is_crate_name_in_dirs(&self) -> bool {
let crate_name = match self.crate_name.as_ref() {
Some(name) => name,
None => return false,
};
let dirs = DIRS.map(|dir| self.manifest_dir.join(dir));
dirs.iter().any(|dir| {
fs::read_dir(dir)
.ok()
.and_then(|entries| {
entries
.filter_map(Result::ok)
.find(|entry| file_stem_eq(entry.path(), crate_name))
})
.is_some()
})
}
}
/// An `ethers-rs` internal crate.
#[derive(
Clone,
Copy,
Debug,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
EnumCount,
EnumIter,
EnumString,
EnumVariantNames,
)]
#[strum(serialize_all = "kebab-case")]
pub enum EthersCrate {
EthersAddressbook,
EthersContract,
EthersContractAbigen,
EthersContractDerive,
EthersCore,
EthersDeriveEip712,
EthersEtherscan,
EthersMiddleware,
EthersProviders,
EthersSigners,
EthersSolc,
}
impl AsRef<str> for EthersCrate {
fn as_ref(&self) -> &str {
self.crate_name()
}
}
impl fmt::Display for EthersCrate {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.pad(self.as_ref())
}
}
impl EthersCrate {
/// "`<self as kebab-case>`"
#[inline]
pub const fn crate_name(self) -> &'static str {
match self {
Self::EthersAddressbook => "ethers-addressbook",
Self::EthersContract => "ethers-contract",
Self::EthersContractAbigen => "ethers-contract-abigen",
Self::EthersContractDerive => "ethers-contract-derive",
Self::EthersCore => "ethers-core",
Self::EthersDeriveEip712 => "ethers-derive-eip712",
Self::EthersEtherscan => "ethers-etherscan",
Self::EthersMiddleware => "ethers-middleware",
Self::EthersProviders => "ethers-providers",
Self::EthersSigners => "ethers-signers",
Self::EthersSolc => "ethers-solc",
}
}
/// "`::<self as snake_case>`"
#[inline]
pub const fn path_name(self) -> &'static str {
match self {
Self::EthersAddressbook => "::ethers_addressbook",
Self::EthersContract => "::ethers_contract",
Self::EthersContractAbigen => "::ethers_contract_abigen",
Self::EthersContractDerive => "::ethers_contract_derive",
Self::EthersCore => "::ethers_core",
Self::EthersDeriveEip712 => "::ethers_derive_eip712",
Self::EthersEtherscan => "::ethers_etherscan",
Self::EthersMiddleware => "::ethers_middleware",
Self::EthersProviders => "::ethers_providers",
Self::EthersSigners => "::ethers_signers",
Self::EthersSolc => "::ethers_solc",
}
}
/// "::ethers::`<self in ethers>`"
#[inline]
pub const fn ethers_path_name(self) -> &'static str {
match self {
// re-exported in ethers::contract
Self::EthersContractAbigen => "::ethers::contract", // partly
Self::EthersContractDerive => "::ethers::contract",
Self::EthersDeriveEip712 => "::ethers::contract",
Self::EthersAddressbook => "::ethers::addressbook",
Self::EthersContract => "::ethers::contract",
Self::EthersCore => "::ethers::core",
Self::EthersEtherscan => "::ethers::etherscan",
Self::EthersMiddleware => "::ethers::middleware",
Self::EthersProviders => "::ethers::providers",
Self::EthersSigners => "::ethers::signers",
Self::EthersSolc => "::ethers::solc",
}
}
/// The path on the file system, from an `ethers-rs` root directory.
#[inline]
pub const fn fs_path(self) -> &'static str {
match self {
Self::EthersContractAbigen => "ethers-contract/ethers-contract-abigen",
Self::EthersContractDerive => "ethers-contract/ethers-contract-derive",
Self::EthersDeriveEip712 => "ethers-core/ethers-derive-eip712",
_ => self.crate_name(),
}
}
/// `<ethers_*>`
#[inline]
pub fn path_names() -> impl Iterator<Item = (Self, &'static str)> {
Self::iter().map(|x| (x, x.path_name()))
}
/// `<ethers::*>`
#[inline]
pub fn ethers_path_names() -> impl Iterator<Item = (Self, &'static str)> {
Self::iter().map(|x| (x, x.ethers_path_name()))
}
/// Returns the [`Path`][syn::Path] in the current project.
#[inline]
pub fn get_path(&self) -> syn::Path {
let name = ETHERS_CRATE_NAMES[self];
syn::parse_str(name).unwrap()
}
}
/// `path.file_stem() == s`
#[inline]
fn file_stem_eq<T: AsRef<Path>, U: AsRef<str>>(path: T, s: U) -> bool {
if let Some(stem) = path.as_ref().file_stem() {
if let Some(stem) = stem.to_str() {
return stem == s.as_ref()
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use rand::{
distributions::{Distribution, Standard},
thread_rng, Rng,
};
use std::{
collections::{BTreeMap, HashSet},
env, fs,
};
use tempfile::TempDir;
impl Distribution<EthersCrate> for Standard {
fn sample<R: Rng + ?Sized>(&self, rng: &mut R) -> EthersCrate {
const RANGE: std::ops::Range<u8> = 0..EthersCrate::COUNT as u8;
// SAFETY: generates in the safe range
unsafe { std::mem::transmute(rng.gen_range(RANGE)) }
}
}
#[test]
fn test_names() {
fn assert_names(s: &ProjectEnvironment, ethers: bool, dependencies: &[EthersCrate]) {
write_manifest(s, ethers, dependencies);
// speeds up consecutive runs by not having to re-create and delete the lockfile
// this is tested separately: test_lock_file
std::fs::write(s.manifest_dir.join("Cargo.lock"), "").unwrap();
let names = s
.determine_ethers_crates()
.unwrap_or_else(|| EthersCrate::ethers_path_names().collect());
let krate = s.crate_name.as_ref().and_then(|x| x.parse::<EthersCrate>().ok());
let is_internal = krate.is_some();
let mut expected: CrateNames = match (is_internal, ethers) {
// internal
(true, _) => EthersCrate::path_names().collect(),
// ethers
(_, true) => EthersCrate::ethers_path_names().collect(),
// no ethers
(_, false) => {
let mut n: CrateNames = EthersCrate::ethers_path_names().collect();
for &dep in dependencies {
n.insert(dep, dep.path_name());
}
n
}
};
if is_internal {
expected.insert(krate.unwrap(), "crate");
}
// don't use assert for a better custom message
if names != expected {
// BTreeMap sorts the keys
let names: BTreeMap<_, _> = names.into_iter().collect();
let expected: BTreeMap<_, _> = expected.into_iter().collect();
panic!("\nCase failed: (`{:?}`, `{ethers}`, `{dependencies:?}`)\nNames: {names:#?}\nExpected: {expected:#?}\n", s.crate_name);
}
}
fn gen_unique<const N: usize>() -> [EthersCrate; N] {
assert!(N < EthersCrate::COUNT);
let rng = &mut thread_rng();
let mut set = HashSet::with_capacity(N);
while set.len() < N {
set.insert(rng.gen());
}
let vec: Vec<_> = set.into_iter().collect();
vec.try_into().unwrap()
}
let (s, _dir) = test_project();
// crate_name -> represents an external crate
// "ethers-contract" -> represents an internal crate
for name in [s.crate_name.as_ref().unwrap(), "ethers-contract"] {
let s = ProjectEnvironment::new(&s.manifest_dir, name);
// only ethers
assert_names(&s, true, &[]);
// only others
assert_names(&s, false, gen_unique::<3>().as_slice());
// ethers and others
assert_names(&s, true, gen_unique::<3>().as_slice());
}
}
#[test]
fn test_lock_file() {
let (s, _dir) = test_project();
write_manifest(&s, true, &[]);
let lock_file = s.manifest_dir.join("Cargo.lock");
assert!(!lock_file.exists());
s.determine_ethers_crates();
assert!(!lock_file.exists());
std::fs::write(&lock_file, "").unwrap();
assert!(lock_file.exists());
s.determine_ethers_crates();
assert!(lock_file.exists());
assert!(!std::fs::read(lock_file).unwrap().is_empty());
}
#[test]
fn test_is_crate_root() {
let (s, _dir) = test_project();
assert!(s.is_crate_root());
// `CARGO_MANIFEST_DIR`
// complex path has `/{dir_name}/` in the path
// name or path validity not checked
let s = ProjectEnvironment::new(
s.manifest_dir.join("examples/complex_examples"),
"complex-examples",
);
assert!(!s.is_crate_root());
let s = ProjectEnvironment::new(
s.manifest_dir.join("benches/complex_benches"),
"complex-benches",
);
assert!(!s.is_crate_root());
}
#[test]
fn test_is_crate_name_in_dirs() {
let (s, _dir) = test_project();
let root = &s.manifest_dir;
for dir_name in DIRS {
for ty in ["simple", "complex"] {
let s = ProjectEnvironment::new(root, format!("{ty}_{dir_name}"));
assert!(s.is_crate_name_in_dirs(), "{s:?}");
}
}
let s = ProjectEnvironment::new(root, "non_existant");
assert!(!s.is_crate_name_in_dirs());
let s = ProjectEnvironment::new(root.join("does-not-exist"), "foo_bar");
assert!(!s.is_crate_name_in_dirs());
}
#[test]
fn test_file_stem_eq() {
let path = Path::new("/tmp/foo.rs");
assert!(file_stem_eq(path, "foo"));
assert!(!file_stem_eq(path, "tmp"));
assert!(!file_stem_eq(path, "foo.rs"));
assert!(!file_stem_eq(path, "fo"));
assert!(!file_stem_eq(path, "f"));
assert!(!file_stem_eq(path, ""));
let path = Path::new("/tmp/foo/");
assert!(file_stem_eq(path, "foo"));
assert!(!file_stem_eq(path, "tmp"));
assert!(!file_stem_eq(path, "fo"));
assert!(!file_stem_eq(path, "f"));
assert!(!file_stem_eq(path, ""));
}
// utils
/// Creates:
///
/// ```text
/// - new_dir
/// - src
/// - main.rs
/// - {dir_name} for dir_name in DIRS
/// - simple_{dir_name}.rs
/// - complex_{dir_name}
/// - src if not "tests"
/// - main.rs
/// - module.rs
/// ```
fn test_project() -> (ProjectEnvironment, TempDir) {
// change the prefix to one without the default `.` because it is not a valid crate name
let dir = tempfile::Builder::new().prefix("tmp").tempdir().unwrap();
let root = dir.path();
let name = root.file_name().unwrap().to_str().unwrap();
// No Cargo.toml, git
fs::create_dir_all(root).unwrap();
let src = root.join("src");
fs::create_dir(&src).unwrap();
fs::write(src.join("main.rs"), "fn main(){}").unwrap();
for dir_name in DIRS {
let new_dir = root.join(dir_name);
fs::create_dir(&new_dir).unwrap();
let simple = new_dir.join(format!("simple_{dir_name}.rs"));
fs::write(simple, "").unwrap();
let mut complex = new_dir.join(format!("complex_{dir_name}"));
if dir_name != "tests" {
fs::create_dir(&complex).unwrap();
fs::write(complex.join("Cargo.toml"), "").unwrap();
complex.push("src");
}
fs::create_dir(&complex).unwrap();
fs::write(complex.join("main.rs"), "").unwrap();
fs::write(complex.join("module.rs"), "").unwrap();
}
// create target dirs
let target = root.join("target");
fs::create_dir(&target).unwrap();
fs::create_dir_all(target.join("tmp")).unwrap();
(ProjectEnvironment::new(root, name), dir)
}
/// Writes a test manifest to `{root}/Cargo.toml`.
fn write_manifest(s: &ProjectEnvironment, ethers: bool, dependencies: &[EthersCrate]) {
// use paths to avoid downloading dependencies
const ETHERS_CORE: &str = env!("CARGO_MANIFEST_DIR");
let ethers_root = Path::new(ETHERS_CORE).parent().unwrap();
let mut dependencies_toml =
String::with_capacity(150 * (ethers as usize + dependencies.len()));
if ethers {
let ethers = format!("ethers = {{ path = {ethers_root:?} }}\n");
dependencies_toml.push_str(&ethers);
}
for dep in dependencies.iter() {
let path = ethers_root.join(dep.fs_path());
let dep = format!("{dep} = {{ path = {path:?} }}\n");
dependencies_toml.push_str(&dep);
}
let contents = format!(
r#"
[package]
name = "{}"
version = "0.0.0"
edition = "2021"
[dependencies]
{dependencies_toml}
"#,
s.crate_name.as_ref().unwrap()
);
fs::write(s.manifest_dir.join("Cargo.toml"), contents).unwrap();
}
}

View File

@ -1,2 +1,2 @@
mod ethers_crate;
pub use ethers_crate::{ethers_contract_crate, ethers_core_crate, ethers_providers_crate};
pub use ethers_crate::*;