Skip to content

macro: Validate custom handler signatures and clarify dispatch errors#19

Open
moCello wants to merge 4 commits intomainfrom
mocello/233-macro-code-fixes
Open

macro: Validate custom handler signatures and clarify dispatch errors#19
moCello wants to merge 4 commits intomainfrom
mocello/233-macro-code-fixes

Conversation

@moCello
Copy link
Copy Markdown
Member

@moCello moCello commented Apr 16, 2026

Summary

Two coupled fixes to the contract-macro/ diagnostic surface around custom data-driver handlers. Supersedes task 189 (re-scope).

1. Compile-time handler signature validation

Handler functions registered via #[contract(encode_input = "…")], decode_input, or decode_output are spliced into the generated data_driver module and called directly by the dispatch match arms. A handler with the wrong shape (argument count, argument type, return type) used to slip past the macro and fail downstream with a cryptic type error against code the user didn't write.

Adds a validator that runs over each extracted handler and emits a clear compile_error! at the handler's definition naming the handler, the role, and the expected signature. Types are compared structurally — Vec<u8> (after use alloc::vec::Vec) or Error (after use dusk_data_driver::Error) are accepted, while foo::Error and MyError are rejected.

2. Role-specific runtime errors at the three dispatch sites

The three data-driver dispatch sites (encode_input_fn, decode_input_fn, decode_output_fn) used to return the same vague "custom handler required: {fn}" when a method marked #[contract(custom)] was dispatched without a matching handler. The user couldn't tell which of the three roles they were missing a handler for, nor the signature that role expects.

Each site now emits a role-tailored message that names the role and the expected handler signature in concrete types, so the reader can fix the handler from the error alone.

Single source of truth

Both the validator and the dispatch error messages pull the canonical per-role signature from data_driver::handler_signature, so the two can't drift apart.

Tests

  • 9 unit tests in validate::custom_handler — positive regression per role plus negatives for wrong arg count, wrong arg type, wrong return type, missing return, self receiver, foo::Error prefix, MyError last segment, &mut str mutability.
  • 3 trybuild compile-fail fixtures — one per role, each exercising a different mistake type with verbatim stderr snapshots.
  • 3 existing data_driver unit tests updated (not deleted) to assert on the new role-specific error content.
  • 4 tests pinning the idiomatic short-path forms (Vec<u8>, Error, JsonValue, &'static str).
  • Existing test-contract integration tests (2 handlers, encode_input + decode_output) still pass — no regression for existing users.

@moCello moCello requested a review from HDauven April 16, 2026 14:47
Copy link
Copy Markdown
Member

@HDauven HDauven left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, but there's two edge cases I think we need to handle. I'll leave it up to you to decide whether they're follow-ups or not.

Comment thread contract-macro/src/validate.rs Outdated
Comment thread contract-macro/src/validate.rs Outdated
@moCello moCello force-pushed the mocello/233-macro-code-fixes branch from b8a5129 to 64abc32 Compare April 17, 2026 10:26
Copy link
Copy Markdown
Member

@HDauven HDauven left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

moCello added 4 commits April 17, 2026 12:40
Custom handlers registered via `#[contract(encode_input = "…")]`,
`#[contract(decode_input = "…")]`, or `#[contract(decode_output = "…")]`
are moved verbatim into the generated `data_driver` submodule and called
positionally by the dispatch match arms. A signature that doesn't match
what the dispatch site expects would previously produce a cryptic
downstream type error against code the user didn't write.

The macro now compile-time-checks handler signatures and emits a clear
`compile_error!` at the handler definition, naming the handler, the
role, and the full expected signature. Comparison runs the user's
argument / return types through the contract module's import map and
token-compares against the canonical per-role shape, so idiomatic short
paths (`Vec<u8>`, `Error`, `JsonValue` after a `use`) are accepted
against `alloc::vec::Vec<u8>` / `dusk_data_driver::Error` /
`dusk_data_driver::JsonValue`.

`'static` lifetimes on reference arguments or in return types are
rejected with a dedicated diagnostic — the generated dispatcher passes
a local borrow the handler can't bind, so a `'static` promise would
surface as a lifetime mismatch deep in macro-generated code. Elided and
handler-generic lifetimes (`fn handler<'a>(… &'a …)`) continue to be
accepted.

Canonical per-role signatures are factored into `data_driver.rs` as
`handler_signature` / `role_name` / `handler_signature_display` so the
validator and the code that calls handlers can't drift apart.

Trybuild fixtures pin the diagnostics for:
 - wrong argument type (encode_input),
 - wrong return type (decode_input),
 - wrong argument count (decode_output),
 - `&'static str` rejection.
The three data-driver dispatch sites (`encode_input_fn`, `decode_input_fn`,
`decode_output_fn`) share the same runtime path for functions marked
`is_custom`: when no handler is registered for the relevant role, the
match arm returns `Error::Unsupported`. Previously all three sites
emitted an identical `"custom handler required: {fn_name}"` message,
leaving the user to figure out which of the three roles they'd missed
and what signature the replacement needed.

The three sites now share a `missing_handler_arm` helper that pulls the
role name and canonical handler signature from the per-role helpers
introduced in the previous commit. Each site produces:

    missing <role> handler for `<fn>`; expected handler signature: <sig>

A user seeing this at runtime can identify both which role they
missed and the exact shape of the handler they need to register —
no trawling through macro-generated code required.
Custom data-driver handlers are spliced verbatim from the contract
module into the generated `data_driver` submodule — but the submodule
only preluded `alloc::{format, string::String, vec::Vec}`, nothing from
the user's outer `use` items. A handler written with idiomatic short
paths would fail to compile after expansion even though the validator
accepted it:

    use dusk_data_driver::{Error, JsonValue};
    #[contract(decode_output = "get_value")]
    fn decode_value(bytes: &[u8]) -> Result<JsonValue, Error> { … }
    // → error[E0425]: cannot find type `JsonValue` in this scope

Re-emit the contract module's `use` items inside the generated
submodule so each spliced handler sees the same imports it did at its
original site — signature *and* body. A handler body like
`.map_err(Error::from)` now resolves just as it did in the outer
module.

Only imports a handler actually references are carried over, detected
by scanning each handler's tokens for identifier appearances. This
keeps contract-only imports (e.g. `types::Ownable` gated behind the
`abi` feature in the test-contract) from leaking into the data-driver
build, where their feature gate wouldn't be satisfied.

The submodule scaffolding drops its `use alloc::{format, string::String,
vec::Vec};` prelude and inlines the fully-qualified paths at their use
sites instead, so a user-land `use alloc::vec::Vec;` in the contract
module doesn't collide with a preluded `Vec` in the submodule.

A `compile-pass-short-paths` trybuild fixture and a
`short_paths_compile_pass` integration test pin the round-trip: a
contract with short-path handlers must round-trip through the macro
and compile. This is the specific failure mode Defect 3 exposed —
validator-only coverage missed it precisely because the splicer was
silently broken.
…overage

The previous canonical-path signatures (`alloc::vec::Vec<u8>`,
`dusk_data_driver::Error`, `dusk_data_driver::JsonValue`) side-stepped
the re-emit pipeline — the short paths are the idiomatic Rust a user
would actually write after a `use`, and the only way to catch a
regression in handler-import re-emit is to exercise that form.

`Vec`, `Error`, and `JsonValue` are now imported at the top of the
contract module, gated on the `data-driver` feature (no contract-side
code references the short names, so the gate keeps the contract build
warning-free). The `#[contract]` macro detects which imports handlers
reference and splices only those into the generated `data_driver`
submodule.
@moCello moCello force-pushed the mocello/233-macro-code-fixes branch from 64abc32 to b626bee Compare April 17, 2026 10:41
Copy link
Copy Markdown
Member

@HDauven HDauven left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

There's a remaining edge case around trait imports which the current reemit filter can still miss when moving into the data_driver but I'll handle that in a follow-up PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants