Skip to content
Merged
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 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add compile-fail and compile-pass test harness for the contract macro.
- Add `#[contract(emits = [...])]` method-level attribute for manual event registration, covering both trait impls with default implementations and inherent methods that delegate to helpers in other crates.
- Add compile error when a public `&mut self` method emits no events. Suppress with `#[contract(no_event)]`.
- Add detection of variable identifiers used as `abi::emit()` topics (warning pending `proc_macro_diagnostic` stabilisation).
Expand Down
76 changes: 76 additions & 0 deletions contract-macro/tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# `dusk-forge-contract` test harness

End-to-end coverage of the `#[contract]` macro. Four layers:

| Layer | What it pins | Driven by |
|---|---|---|
| Unit tests under `src/` | Pure-Rust validation / parsing helpers | `cargo test -p dusk-forge-contract` |
| `tests/compile-fail/` (+ `compile_fail.rs`) | Each rejection rule produces a diagnostic | trybuild |
| `tests/compile-pass/` (+ `compile_pass.rs`) | Valid contract shapes expand and type-check | `cargo check` on a sub-crate |
| `tests/compile-fail-both-features/` | Mutually-exclusive feature gate fires | `cargo check` (asserts failure) |

`tests/test-contract/` (workspace member) exercises a single rich shape end-to-end into a WASM build. Fixtures here cover the *variations* that one reference contract does not.

## Topic taxonomy

Both `compile-fail/` and `compile-pass/src/` are nested by topic, not flat:

| Topic dir | Scope |
|---|---|
| `methods/` | Inherent method validation: `validate::public_method`, `validate::new_constructor`, `validate::init_method` |
| `traits/` | Trait method validation: `validate::trait_method` |
| `events/` | Event-emission validation: `validate::method_emits_event` |
| `directives/` | `#[contract(...)]` directive parsing |
| `feature_gates/` | `contract` / `data-driver` cargo feature enforcement |

Add a new topic dir only when a rule has no reasonable home in the existing ones. Topics are stable categories — they should outlive individual rule renames inside `validate.rs`.

## `// Pins:` header convention

Every fixture starts with a comment naming the rule it pins:

```rust
// Pins: validate::public_method::async
//
// <one-paragraph explanation of why the rule exists>
```

The pin identifier traces back to the function and reject branch in `src/`. Audit "which fixtures pin this rule?" via `grep -r '// Pins: <id>' tests/`.

If you change a rule's identifier (rename a function, restructure a module), `grep` for the old name and update every fixture header in lockstep.

## Adding a compile-fail fixture

Fixtures here depend on the macro crate directly: `use dusk_forge_contract::contract;`. (The compile-pass sub-crate goes through the `dusk_forge::contract` re-export instead — see below.)

1. Pick the topic dir (see above; introduce a new one if none fits).
2. Create `<rule-name>.rs` with:
- A `// Pins:` header.
- A 10–25 line minimal repro. Use `pub struct MyContract;` and a `const fn new() -> Self` constructor unless the rule under test requires otherwise.
- `fn main() {}` at the bottom.
3. Run `TRYBUILD=overwrite cargo test -p dusk-forge-contract --test compile_fail` to bless the `.stderr` companion.
4. Re-run `cargo test -p dusk-forge-contract --test compile_fail` and confirm the new fixture passes.

Earlier checks in `parse::analyze` can shadow your rule — if the diagnostic you see is for a different rule, adjust the fixture so the rule under test fires first.

## Adding a compile-pass fixture

`tests/compile-pass/` is a small library sub-crate (`compile-pass-fixtures`), not a trybuild glob. Each `.rs` file in `src/<topic>/` contains one `#[dusk_forge::contract] pub mod my_contract { … }` that exercises one valid shape. Fixtures here invoke the macro through the `dusk_forge::contract` re-export (the sub-crate depends on `dusk-forge`, not on `dusk-forge-contract` directly).

1. Pick the topic dir under `tests/compile-pass/src/`.
2. Create `<shape-name>.rs` with:
- A `// Pins:` header naming the canonical valid shape.
- One `#[dusk_forge::contract] pub mod my_contract { … }`.
- An `impl Default for MyContract` if the contract carries a `pub fn new()` (avoids `clippy::new_without_default`).
3. Register the file in `src/<topic>/mod.rs` as `pub mod <shape_name>;`.
4. `cargo test -p dusk-forge-contract --test compile_pass`.

`CONTRACT_SCHEMA` is emitted at the parent scope of the macro-annotated module, so one `#[contract]` per file keeps the constants from colliding.

## Why a sub-crate for compile-pass

The macro emits `compile_error!` when neither `contract` nor `data-driver` is enabled in the consuming crate. trybuild has no per-fixture feature configuration, so its `compile_pass` glob cannot enable the feature that the fixtures need. The sub-crate sets `default = ["contract"]`, mirrors the topic structure of `compile-fail/`, and is driven by a single `cargo check` invocation — symmetric with `tests/compile-fail-both-features/`.

## `_dd.rs` (data-driver-js) variants

The plan envisioned `_dd.rs` companion fixtures where the `data-driver-js` feature changes the diagnostic or the accept/reject decision. None are present today: `data-driver-js` is a downstream cargo feature whose only effect is gating `dusk-data-driver/alloc`; it never reaches the macro's parse or validate phases. Add a `_dd.rs` variant only when a future rule starts behaving differently between the modes.
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// Pins: parse::directives::bare_attribute
//
// `#[contract]` without a directive list on an inner item is rejected. This
// pins the strict parse stance: every inner `#[contract(...)]` attribute must
// be a list, so a future refactor that loosened this check would trip this
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
error: expected attribute arguments in parentheses: `contract(...)`
--> tests/compile-fail/bare_contract_directive.rs:17:11
--> tests/compile-fail/directives/bare_contract_directive.rs:19:11
|
17 | #[contract]
19 | #[contract]
| ^^^^^^^^
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// Pins: parse::directives::duplicate_across_attributes
//
// Duplicate directive keys across multiple `#[contract(...)]` attributes on
// the same item are rejected. This pins the strict parse stance against
// silent last-attr-wins.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
error: duplicate `feeds` directive
--> tests/compile-fail/directives/duplicate_directive_across_attributes.rs:19:20
|
19 | #[contract(feeds = "u32")]
| ^^^^^
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// Pins: parse::directives::duplicate_within_attribute
//
// Duplicate directive keys within a single `#[contract(...)]` attribute are
// rejected. This pins the strict parse stance against silent last-wins.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
error: duplicate `feeds` directive
--> tests/compile-fail/directives/duplicate_directive_within_attribute.rs:17:35
|
17 | #[contract(feeds = "u64", feeds = "u32")]
| ^^^^^

This file was deleted.

This file was deleted.

23 changes: 23 additions & 0 deletions contract-macro/tests/compile-fail/events/mut_self_no_emit.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Pins: validate::method_emits_event::mut_self_no_emit
//
// A public `&mut self` method that neither calls `abi::emit()` in its body,
// nor registers events via `#[contract(emits = [...])]`, nor opts out via
// `#[contract(no_event)]`, must be rejected: state-mutating methods are
// required to be observable.

use dusk_forge_contract::contract;

#[contract]
mod my_contract {
pub struct MyContract;

impl MyContract {
pub const fn new() -> Self {
Self
}

pub fn touch(&mut self) {}
}
}

fn main() {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
error: public method `touch` mutates state but emits no events; add an `abi::emit()` call or suppress with `#[contract(no_event)]`
--> tests/compile-fail/events/mut_self_no_emit.rs:19:13
|
19 | pub fn touch(&mut self) {}
| ^^^^^^^^^^^^^^^^^^^
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Pins: generate::feature_gate::missing
//
// Applying `#[contract]` without enabling either the `contract` or the
// `data-driver` cargo feature fires the generated `compile_error!` that
// guards against unconfigured WASM builds. The contract body is shared
// with `compile-fail-both-features/` via `include!`.

include!("../../common/contract.rs");

fn main() {}
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
error: Enable either 'contract' or 'data-driver' feature for WASM builds
--> tests/compile-fail/../common/contract.rs
--> tests/compile-fail/feature_gates/../../common/contract.rs
|
| #[contract]
| ^^^^^^^^^^^
|
= note: this error originates in the attribute macro `contract` (in Nightly builds, run with -Z macro-backtrace for more info)

warning: unexpected `cfg` condition value: `contract`
--> tests/compile-fail/../common/contract.rs
--> tests/compile-fail/feature_gates/../../common/contract.rs
|
| #[contract]
| ^^^^^^^^^^^
Expand All @@ -21,7 +21,7 @@ warning: unexpected `cfg` condition value: `contract`
= note: this warning originates in the attribute macro `contract` (in Nightly builds, run with -Z macro-backtrace for more info)

warning: unexpected `cfg` condition value: `data-driver`
--> tests/compile-fail/../common/contract.rs
--> tests/compile-fail/feature_gates/../../common/contract.rs
|
| #[contract]
| ^^^^^^^^^^^
Expand Down
23 changes: 23 additions & 0 deletions contract-macro/tests/compile-fail/methods/init_consume_self.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Pins: validate::init_method::consume_self
//
// A private `init` method that consumes `self` must be rejected by the
// init-specific check. (The public flavour fires the broader
// `public_method::consume_self` rule first; this fixture keeps `init`
// private so the init-specific path is exercised.)

use dusk_forge_contract::contract;

#[contract]
mod my_contract {
pub struct MyContract;

impl MyContract {
pub const fn new() -> Self {
Self
}

fn init(self) {}
}
}

fn main() {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
error: `MyContract::init` must take `&mut self`; initialization needs to modify contract state
--> tests/compile-fail/methods/init_consume_self.rs:19:17
|
19 | fn init(self) {}
| ^^^^
21 changes: 21 additions & 0 deletions contract-macro/tests/compile-fail/methods/init_immutable_self.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Pins: validate::init_method::immutable_self
//
// An `init` method with an immutable `&self` receiver must be rejected:
// initialisation has to mutate the contract state.

use dusk_forge_contract::contract;

#[contract]
mod my_contract {
pub struct MyContract;

impl MyContract {
pub const fn new() -> Self {
Self
}

pub fn init(&self) {}
}
}

fn main() {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
error: `MyContract::init` must take `&mut self`; initialization needs to modify contract state
--> tests/compile-fail/methods/init_immutable_self.rs:17:21
|
17 | pub fn init(&self) {}
| ^^^^^
24 changes: 24 additions & 0 deletions contract-macro/tests/compile-fail/methods/init_no_receiver.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Pins: validate::init_method::no_receiver
//
// An `init` method declared as an associated function (no `self`) must be
// rejected: initialisation needs access to contract state through `&mut
// self`.

use dusk_forge_contract::contract;

#[contract]
mod my_contract {
pub struct MyContract;

impl MyContract {
pub const fn new() -> Self {
Self
}

pub fn init(seed: u64) {
let _ = seed;
}
}
}

fn main() {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
error: `MyContract::init` must take `&mut self`; initialization requires access to contract state
--> tests/compile-fail/methods/init_no_receiver.rs:18:13
|
18 | pub fn init(seed: u64) {
| ^^^^^^^^^^^^^^^^^^
24 changes: 24 additions & 0 deletions contract-macro/tests/compile-fail/methods/init_returns_value.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Pins: validate::init_method::returns_value
//
// An `init` method that returns a non-unit type must be rejected:
// initialisation has no caller to consume a return value, so errors must
// panic instead.

use dusk_forge_contract::contract;

#[contract]
mod my_contract {
pub struct MyContract;

impl MyContract {
pub const fn new() -> Self {
Self
}

pub fn init(&mut self) -> bool {
true
}
}
}

fn main() {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
error: `MyContract::init` must return `()`; use `panic!` or `assert!` for initialization errors
--> tests/compile-fail/methods/init_returns_value.rs:18:32
|
18 | pub fn init(&mut self) -> bool {
| ^^^^^^^
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Pins: validate::new_constructor::has_params
//
// The `new` constructor must take no parameters: it must produce a default
// state with no input, since the host has no way to pass arguments when
// initialising the static STATE.

use dusk_forge_contract::contract;

#[contract]
mod my_contract {
pub struct MyContract;

impl MyContract {
pub const fn new(seed: u64) -> Self {
let _ = seed;
Self
}
}
}

fn main() {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
error: `MyContract::new` must have no parameters; use `const fn new() -> Self` to create a default state
--> tests/compile-fail/methods/new_constructor_has_params.rs:14:26
|
14 | pub const fn new(seed: u64) -> Self {
| ^^^^^^^^^
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Pins: validate::new_constructor::missing
//
// A contract module without a `const fn new() -> Self` method must be
// rejected: the static STATE singleton is initialised via that constructor.

use dusk_forge_contract::contract;

#[contract]
mod my_contract {
pub struct MyContract;

impl MyContract {
pub fn get(&self) -> u64 {
0
}
}
}

fn main() {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
error: #[contract] requires `MyContract` to have a `const fn new() -> Self` method to initialize the static STATE variable
--> tests/compile-fail/methods/new_constructor_missing.rs:10:5
|
10 | pub struct MyContract;
| ^^^^^^^^^^^^^^^^^^^^^^
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Pins: validate::new_constructor::not_const
//
// The `new` constructor must be `const fn`: it initialises a `static mut`
// in the generated WASM module, which only `const` evaluation can produce.

use dusk_forge_contract::contract;

#[contract]
mod my_contract {
pub struct MyContract;

impl MyContract {
pub fn new() -> Self {
Self
}
}
}

fn main() {}
Loading