Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,467 changes: 928 additions & 539 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ syn = { version = "2.0", features = [
"extra-traits",
"printing",
] }
darling = { version = "0.20", features = ["diagnostics"] }
thiserror = { package = "miden-thiserror", version = "1.0" }
toml = { version = "0.8", features = ["preserve_order"] }
tokio = { version = "1.39.2", features = ["rt", "time", "macros", "rt-multi-thread"] }
Expand Down
2 changes: 1 addition & 1 deletion hir-macros/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ proc-macro = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
darling = { version = "0.20", features = ["diagnostics"] }
darling.workspace = true
Inflector.workspace = true
proc-macro2.workspace = true
quote.workspace = true
Expand Down
110 changes: 110 additions & 0 deletions test-harness/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Miden Test Harness Library

Most users will interact with the `miden-test-harness` crate via the `#[miden_test]` attribute, which is used on functions just like rust's `#[test]` attribute; however, it's aimed at providing some Miden specific utilities and reducing the amount of boilerplate code required to write tests. Here's an example:

```rust
#[miden_test]
fn foo() {
assert_eq!(2, 1 + 1)
}
```

By default, it will work just like its standard counterpart, that is, `cargo test` should work like normal, IDE's should display overlays to run that specific test, etc.

However, one distinguishing feature of `#[miden_test]` is its ability to receive attribute arguments; which are used to set up the test's _context_.
Every attribute behaves and is configured in a similar fashion: `<attribute_name>(<argument_1> = value, ... <argument_n> = value)`. The passed in arguments will determine the properties and behavior of the attribute. All arguments are **Optional**, meaning that if they're not passed in, a default value will be used.

Additionally, some attributes depend on other attributes being present, _however_, the attributes can be declared in whichever order is preferred, since they will be ordered according to their precedence.
The current precedence is as follows:
- package
- chain
- faucet
- account

There is one "special" attribute which behaves differently from the rest; which is the `help()` attribute. Having the attribute `#[miden_test(help())]` will cause the compiler to display documentation for every supported argument in `#[miden_test]`. Additionally, `help()` itself also supports the `attribute =` argument, which will only display the specified attribute's documentation. For example, `#[miden_test(help(attribute = "account"))]` will display the `account`'s attribute documentation.
The displayed documentation contains a brief explanation of every argument's functionality, its default value, whether it conflicts with another argument, etc.


## Attributes

For a examples using multiple attributes, see the tests present in: `tests/integration-network/src/mockchain/basic_wallet.rs`. Additionally, unit-test style usage can be found in `tests/examples/counter/src/lib.rs`.

### chain

Creates a `MockChainBuilder` that simulates the Miden blockchain in tests. Only one chain is permitted per test.

- `name`: Variable name for this chain in the generated code. Default: `"chain"`.

```rust
#[miden_test(chain(name = "builder"))]
fn my_test() {
...
}
```

### package

Loads a compiled Miden package (`.masp` file) for use in tests.

- `name`: Variable name for this package in the generated code. Default: `"package"`.
- `path`: Path to the Miden Rust project directory to compile. Mutually exclusive with `local`.
- `local`: Load the current crate's package (built via `cargo miden build`). Mainly intended for unit tests in Rust code targeted by midenc. Mutually exclusive with `path`. Default: `false`.

Note: Either `path` or `local = true` must be specified.

```rust
#[miden_test(package(name = "wallet", path = "../examples/basic-wallet"))]
fn my_test() {
...
}
```

### faucet

Creates a faucet account for issuing tokens. Requires a `chain` attribute to be present.

- `name`: Variable name for this faucet in the generated code. Default: `"faucet"`.
- `max_supply`: Maximum token supply the faucet can issue. Default: `1_000_000_000`.
- `token_symbol`: Token symbol identifier (e.g., `"MIDEN"`, `"BTC"`). Default: `"TEST"`.
- `exists`: Whether the faucet exists on-chain at test start. Default: `true`.
- `issuance`: Initial token amount issued when faucet is created. Default: `0`.

```rust
#[miden_test(
chain,
faucet(name = "faucet", max_supply = 1_000_000_000, token_symbol = "MIDEN"),
)]
fn my_test() {
...
}
```


### account
Creates an account and adds it to the mock chain. Requires a `chain` and a `package` attribute to be present.

- `name`: Variable name for this account in the generated code. Default: `"account"`.
- `component`: Component used by this account. Must match a package name. Default: `"wallet"`.
- `seed`: Seed for account generation, expanded to `[seed; 32]`. Default: `1`.
- `with_basic_wallet`: Whether to include the basic wallet component. Default: `false`.

```rust
#[miden_test(
chain(name = "builder"),
package(name = "wallet", path = "../examples/basic-wallet"),
account(name = "alice", component = "wallet", seed = 1),
)]
fn my_test() {
...
}
```

## Implementation notes
Every `attribute` gets built with the passed in arguments and performs a validation, making sure that none of its invariants are broken (this varies from attribute to attribute, but it encompasses things like making sure a MockChain is present, making sure that the required package is present, verifying that no two variables share the same name, etc).
Afterwards, items are sorted according to their precedence and then their respective code gets emitted in the beginning of the test function's block.

For more thorough implementation details see the `test-harness-macros` and `test-harness-lib` crates.

## Future directions
- The `help` attribute could potentially be expanded upon. For instance, conflicts between fields could be marked with an internal attribute (similar to clap's `conflicts_with`'s attribute).
- Currently, attribute order is simply determined by a hardcoded list of precedence. Potentially, if the order in which they need to emit their code gets more complicated, it might be desirable to implement a dependency graph system. Although, that should probably only be tackled if needed.
28 changes: 28 additions & 0 deletions test-harness/test-harness-derive/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[package]
name = "miden-test-harness-derive"
description = "Derive macros used internally."
publish = false
version.workspace = true
rust-version.workspace = true
authors.workspace = true
repository.workspace = true
categories.workspace = true
keywords.workspace = true
license.workspace = true
readme.workspace = true
edition.workspace = true

[lib]
proc-macro = true

[dependencies]
# Miden dependencies
miden-mast-package.workspace = true
miden-testing = "0.12.4"

# External dependencies
quote.workspace = true
syn.workspace = true
darling.workspace = true
anyhow.workspace = true
proc-macro2.workspace = true
3 changes: 3 additions & 0 deletions test-harness/test-harness-derive/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# miden-test-harness-derive

Crate containing internal derive macros for the `miden-test-harness`.
178 changes: 178 additions & 0 deletions test-harness/test-harness-derive/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
use quote::{ToTokens, quote};

/// Derive macro that generates a `help()` function for a struct.
///
/// It is important to note that the struct which contains all the configurable
/// fields is *NOT* the one which determines what's the name of the
/// attribute. That name is derived from its corresponding
/// RecognizedAttrsBuilder enum *variant*.
///
/// So, to sum up:
/// - The attribute name comes from [[RecognizedAttrsBuilder's variant name.
/// - Attribute fields: The variant's corresponding struct.
#[proc_macro_derive(Help)]
pub fn derive_help(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
let input = syn::parse_macro_input!(input as syn::DeriveInput);

match &input.data {
syn::Data::Struct(_) => derive_help_struct(input),
syn::Data::Enum(_) => derive_help_enum(input),
syn::Data::Union(_) => panic!("Help cannot be derived for unions"),
}
}

/// Generates the [[RecognizedAttrsBuilder]] enum `help(filter)` function which is
/// called when the help() attribute is used.
fn derive_help_enum(input: syn::DeriveInput) -> proc_macro::TokenStream {
let enum_name = input.ident;

let syn::Data::Enum(enum_data) = &input.data else {
unreachable!()
};

let mut help_elements = Vec::new();
for variant in &enum_data.variants {
let variant_name = variant.ident.to_string();
// Help is a "special" variant which is used to trigger the help mechanism.
if variant_name == "Help" {
continue;
}

let syn::Fields::Unnamed(struct_field) = &variant.fields else {
let error_message = format!(
"
The Help derive macro only works on enums which have a single struct as field.\n
However, {}::{} was found to have something different.
Valid example:
Account(AccountAttrBuilder),
",
enum_name, variant_name
);
panic!("{error_message}")
};

let amount_of_elements = struct_field.unnamed.len();
if amount_of_elements != 1 {
let list_of_identifiers: String = struct_field
.unnamed
.iter()
.map(|field| field.ty.to_token_stream().to_string().clone())
.fold(String::new(), |acc, ident| {
if acc.is_empty() {
ident
} else {
format!("{acc}, {ident}")
}
});

let error_message = format!(
"
The Help derive macro only works on enums which have a single struct as field.\n
However, {enum_name}::{variant_name} was found to have {amount_of_elements} elements \
({list_of_identifiers}).
",
);
panic!("{error_message}")
}

// Safety: We just checked it was only 1
let attribute_struct = &struct_field.unnamed.first().unwrap().ty;

let help_element = quote! {
(#attribute_struct::struct_name(), #attribute_struct::help())
};

help_elements.push(help_element);
}

let help_calls = help_elements.iter();

// `filter` is the name of an attribute. If the user passes in
// `#[miden_test(help(attribute = "<attribute name>"))]`, only documentation
// for that attribute is displayed.
let code = quote! {
impl #enum_name {
fn help(filter: Option<&str>) -> String {
[#(#help_calls),*]
.into_iter()
.filter_map(|(struct_name, struct_help)| {
if let Some(filter) = filter {
if filter == struct_name {
Some(struct_help)
} else {
None
}
} else {
// If there's no filter, we'll show every help message.
Some(struct_help)
}
})
.fold(String::new(), |acc, s| acc + "\n" + s)
}
}
};

code.into()
}

/// Generates each [[RecognizedAttrsBuilder]] inner structs `help()` function
/// which outputs the struct's documentation string. This is generated from its
/// name and its fields docstrings.
/// The documentation string follows the following format:
/// ---------
/// <struct name>:
/// - <field>: <docstring>
fn derive_help_struct(input: syn::DeriveInput) -> proc_macro::TokenStream {
let struct_name = input.ident;

// NOTE: This does mean that all the structs have to follow the following:
// <StructName>(<StructNameAttrBuilder>)
let struct_name_lowercase = struct_name.to_string().replace("AttrBuilder", "").to_lowercase();

let syn::Data::Struct(data) = &input.data else {
unreachable!()
};

let syn::Fields::Named(fields) = &data.fields else {
panic!("Help can only be derived for structs with named fields");
};

let mut field_docs = String::new();
for field in &fields.named {
let name = field.ident.as_ref().unwrap().to_string();
let mut doc = String::new();
for attr in &field.attrs {
// Doc comments
if attr.path().is_ident("doc")
&& let syn::Meta::NameValue(meta) = &attr.meta
&& let syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(s),
..
}) = &meta.value
{
doc.push_str(s.value().trim());
}
}
// Each field line
field_docs.push_str(format!("\t- {}: {}\n", name, doc).as_str());
}

let code = quote! {
impl #struct_name {
fn help() -> &'static str {
concat!(
"-------------------------------------------------------------------------------",
'\n',
#struct_name_lowercase,
'\n',
#field_docs,
)
}

fn struct_name() -> &'static str {
#struct_name_lowercase
}
}
};
code.into()
}
4 changes: 4 additions & 0 deletions test-harness/test-harness-macros/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ proc-macro = true
# Miden dependencies
miden-mast-package.workspace = true
miden-testing = "0.13"
miden-test-harness-derive = { path = "../test-harness-derive" }

# External dependencies
quote.workspace = true
syn.workspace = true
darling.workspace = true
anyhow.workspace = true
proc-macro2.workspace = true
Loading