diff --git a/CHANGELOG.md b/CHANGELOG.md index e0585cb..8da5bb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 compile-time signature validation for custom data-driver handlers registered via `#[contract(encode_input = …)]`, `decode_input`, and `decode_output`. Mismatched argument or return types surface as a clear `compile_error!` at the handler definition, naming the handler, the role, and the expected signature. Idiomatic short paths (`Vec`, `Error`, `JsonValue` after a `use`) are canonicalised through the contract module's import map and accepted against the role's canonical form; `'static` lifetimes in handler references are rejected with a pointer to drop them or declare a handler-generic lifetime. - Add detection of variable identifiers used as `abi::emit()` topics (warning pending `proc_macro_diagnostic` stabilisation). - Add the `dusk-forge` CLI with `new`, `build`, `test`, and `check` commands for contract project scaffolding and workflows. - Add `expand`, `clean`, and `completions` commands to the `dusk-forge` CLI. @@ -26,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Make `dusk-forge build data-driver` select the supported project feature (`data-driver-js` or `data-driver`) instead of hardcoding the JS variant. +- Replace the vague `"custom handler required: {fn}"` runtime error emitted at each of the three data-driver dispatch sites with a role-specific message that names the missing handler's role (`encode_input`, `decode_input`, `decode_output`) and the expected handler signature in concrete types. +- Re-emit the contract module's `use` items inside the generated `data_driver` submodule so custom data-driver handlers written with idiomatic short paths (`Vec`, `Error`, `JsonValue` after a `use`) compile end-to-end — the spliced handler body now resolves the same names it did at its original site. Only imports referenced by a handler are carried over, so contract-only imports don't leak into the submodule. The built-in submodule scaffolding also switches to fully-qualified `alloc::vec::Vec` / `alloc::string::String` so a user `use` of the short forms doesn't collide with a preluded import. ## [0.2.2] - 2026-02-02 diff --git a/contract-macro/src/data_driver.rs b/contract-macro/src/data_driver.rs index 1f584d9..126a57c 100644 --- a/contract-macro/src/data_driver.rs +++ b/contract-macro/src/data_driver.rs @@ -13,14 +13,174 @@ //! The module is feature-gated with `#[cfg(feature = "data-driver")]` and uses //! fully-qualified type paths resolved at extraction time. +use std::collections::HashSet; + use proc_macro2::TokenStream as TokenStream2; -use quote::quote; +use quote::{ToTokens, quote}; use crate::resolve::TypeMap; -use crate::{CustomDataDriverHandler, DataDriverRole, EventInfo, FunctionInfo}; +use crate::{CustomDataDriverHandler, DataDriverRole, EventInfo, FunctionInfo, ImportInfo}; + +/// Canonical handler signature for a given role. +/// +/// Both the dispatch code in this module (which splices `handler(arg)` calls +/// into the generated match arms) and the compile-time handler validator +/// consume this — changes to the dispatch shape must go through here so the +/// two agree. +pub(crate) struct HandlerSignature { + /// The handler's sole argument type, e.g. `&str` or `&[u8]`. + pub arg_type: TokenStream2, + /// The handler's return type, e.g. `Result, …>`. + pub return_type: TokenStream2, +} + +/// Canonical signature per role. +/// +/// The `arg_type` reflects the name used in the dispatch (`json` is `&str`, +/// `rkyv` is `&[u8]`). The `return_type` reflects the trait method that owns +/// each match arm. +pub(crate) fn handler_signature(role: DataDriverRole) -> HandlerSignature { + match role { + DataDriverRole::EncodeInput => HandlerSignature { + arg_type: quote!(&str), + return_type: quote!(Result, dusk_data_driver::Error>), + }, + DataDriverRole::DecodeInput | DataDriverRole::DecodeOutput => HandlerSignature { + arg_type: quote!(&[u8]), + return_type: quote!(Result), + }, + } +} + +/// Human-readable role name, matching the attribute the user writes. +pub(crate) fn role_name(role: DataDriverRole) -> &'static str { + match role { + DataDriverRole::EncodeInput => "encode_input", + DataDriverRole::DecodeInput => "decode_input", + DataDriverRole::DecodeOutput => "decode_output", + } +} + +/// Render the canonical handler signature for display in diagnostics, +/// e.g. `fn(&str) -> Result, dusk_data_driver::Error>`. +pub(crate) fn handler_signature_display(role: DataDriverRole) -> String { + let sig = handler_signature(role); + let arg = pretty_tokens(&sig.arg_type); + let ret = pretty_tokens(&sig.return_type); + format!("fn({arg}) -> {ret}") +} + +/// Normalized token string — collapses whitespace differences introduced by +/// `quote!` so compared signatures are stable regardless of how the user +/// spaced their handler's types. +pub(crate) fn normalize_tokens_string(tokens: &TokenStream2) -> String { + tokens + .to_string() + .split_whitespace() + .collect::>() + .join(" ") +} + +/// Pretty-printed form of a type token stream for human-readable diagnostics. +/// +/// `TokenStream::to_string` emits `& str` / `Result < T , E >` with spaces +/// that rustc's type printer doesn't use — this trims them back down so the +/// signature displayed to the user matches what they would write in code. +pub(crate) fn pretty_tokens(tokens: &TokenStream2) -> String { + normalize_tokens_string(tokens) + .replace(" :: ", "::") + .replace(" < ", "<") + .replace(" <", "<") + .replace(" > ", ">") + .replace(" >", ">") + .replace("& ", "&") + .replace(" ,", ",") +} + +/// Generate the runtime error arm body for an `is_custom` function at the +/// given dispatch site. +/// +/// Produces a `Result::Err` with a role-tailored message that names both the +/// role (so the user knows which handler is missing) and the expected +/// handler signature in concrete types (so the user can fix the handler from +/// the error alone). +fn missing_handler_arm(fn_name: &str, role: DataDriverRole) -> TokenStream2 { + let role_str = role_name(role); + let sig_str = handler_signature_display(role); + quote! { + #fn_name => Err(dusk_data_driver::Error::Unsupported( + alloc::format!( + "missing {} handler for `{}`; expected handler signature: {}", + #role_str, #fn_name, #sig_str + ) + )) + } +} + +/// Build `use` items that mirror the contract module's imports needed by +/// custom handlers, to be spliced into the generated `data_driver` submodule. +/// +/// Only imports referenced by handler tokens (signature or body) are emitted, +/// to keep the submodule from inheriting contract-only imports (e.g. ABI +/// types feature-gated out of the data-driver build). Each entry becomes +/// `use as ;` when the path's last segment differs from the +/// name (i.e. the user wrote `use X as Y;`), and `use ;` otherwise — +/// so handlers moved into the submodule resolve the same short names they +/// resolved in the outer module, for both signature and body. +fn reemit_imports( + imports: &[ImportInfo], + handlers: &[CustomDataDriverHandler], +) -> Vec { + let handler_idents = collect_handler_identifiers(handlers); + + imports + .iter() + .filter(|import| handler_idents.contains(&import.name)) + .filter_map(|import| { + let path: syn::Path = syn::parse_str(&import.path).ok()?; + let last_seg = path.segments.last()?.ident.to_string(); + let item = if last_seg == import.name { + quote! { use #path; } + } else { + let alias: syn::Ident = syn::parse_str(&import.name).ok()?; + quote! { use #path as #alias; } + }; + Some(item) + }) + .collect() +} + +/// Collect every identifier that appears in any handler's tokens. +/// +/// Used to filter the contract module's imports down to those the handlers +/// actually reference. A handler that uses `Error::from(…)` contributes +/// `Error` (plus `from`, which no import will match); an import named +/// `BTreeMap` that no handler mentions is skipped. +fn collect_handler_identifiers(handlers: &[CustomDataDriverHandler]) -> HashSet { + use proc_macro2::TokenTree; + + fn walk(stream: TokenStream2, out: &mut HashSet) { + for tree in stream { + match tree { + TokenTree::Ident(ident) => { + out.insert(ident.to_string()); + } + TokenTree::Group(group) => walk(group.stream(), out), + _ => {} + } + } + } + + let mut idents = HashSet::new(); + for handler in handlers { + walk(handler.func.to_token_stream(), &mut idents); + } + idents +} /// Generate the `data_driver` module at crate root level. pub(crate) fn module( + imports: &[ImportInfo], type_map: &TypeMap, functions: &[FunctionInfo], events: &[EventInfo], @@ -34,6 +194,12 @@ pub(crate) fn module( // Collect custom handler functions to include in the module let custom_handler_fns: Vec<_> = custom_handlers.iter().map(|h| &h.func).collect(); + // Re-emit the contract module's `use` items inside the generated submodule + // so custom handlers — spliced verbatim from the contract module — resolve + // the same short-name paths they did at their original site (handler + // signature *and* body). + let contract_imports = reemit_imports(imports, custom_handlers); + quote! { /// Auto-generated data driver module. /// @@ -41,10 +207,18 @@ pub(crate) fn module( /// for encoding/decoding contract function inputs, outputs, and events. #[cfg(feature = "data-driver")] pub mod data_driver { + #![allow(unused_imports)] + extern crate alloc; - use alloc::format; - use alloc::string::String; - use alloc::vec::Vec; + + // Imports re-emitted from the contract module so that spliced + // custom handler functions resolve the same short-name paths here + // as they did at their original definition site. + // + // The macro-generated scaffolding below uses fully-qualified paths + // (`alloc::vec::Vec`, `alloc::string::String`) so user imports of + // `Vec` / `String` won't collide with a preluded one we control. + #(#contract_imports)* // Custom handler functions moved from the contract module #(#custom_handler_fns)* @@ -59,7 +233,7 @@ pub(crate) fn module( &self, fn_name: &str, json: &str, - ) -> Result, dusk_data_driver::Error> { + ) -> Result, dusk_data_driver::Error> { match fn_name { #(#encode_input_arms,)* name => Err(dusk_data_driver::Error::Unsupported( @@ -107,7 +281,7 @@ pub(crate) fn module( } } - fn get_schema(&self) -> String { + fn get_schema(&self) -> alloc::string::String { super::CONTRACT_SCHEMA.to_json() } } @@ -147,11 +321,7 @@ fn generate_encode_input_arms( let input_type = get_resolved_type(&f.input_type, type_map); if f.is_custom { - quote! { - #name_str => Err(dusk_data_driver::Error::Unsupported( - alloc::format!("custom handler required: {}", #name_str) - )) - } + missing_handler_arm(&name_str, DataDriverRole::EncodeInput) } else { quote! { #name_str => dusk_data_driver::json_to_rkyv::<#input_type>(json) @@ -187,11 +357,7 @@ fn generate_decode_input_arms( let input_type = get_resolved_type(&f.input_type, type_map); if f.is_custom { - quote! { - #name_str => Err(dusk_data_driver::Error::Unsupported( - alloc::format!("custom handler required: {}", #name_str) - )) - } + missing_handler_arm(&name_str, DataDriverRole::DecodeInput) } else { quote! { #name_str => dusk_data_driver::rkyv_to_json::<#input_type>(rkyv) @@ -243,11 +409,7 @@ fn generate_decode_output_arms( }; if f.is_custom { - quote! { - #name_str => Err(dusk_data_driver::Error::Unsupported( - alloc::format!("custom handler required: {}", #name_str) - )) - } + missing_handler_arm(&name_str, DataDriverRole::DecodeOutput) } else if type_str == "()" { quote! { #name_str => Ok(dusk_data_driver::JsonValue::Null) @@ -508,7 +670,18 @@ mod tests { assert!(arm_str.contains("\"custom_fn\"")); assert!(arm_str.contains("Err")); assert!(arm_str.contains("Unsupported")); - assert!(arm_str.contains("custom handler required")); + // The generated error names the role so a user seeing it at runtime + // can tell which of the three sites is missing a handler. + assert!( + arm_str.contains("\"encode_input\""), + "error should name the encode_input role: {arm_str}" + ); + // The generated error includes the canonical handler signature in + // concrete types so the user can fix the handler from the message. + assert!( + arm_str.contains(&handler_signature_display(DataDriverRole::EncodeInput)), + "error should include the encode_input signature verbatim: {arm_str}" + ); } #[test] @@ -590,7 +763,14 @@ mod tests { assert_eq!(arms.len(), 1); let arm_str = normalize_tokens(arms[0].clone()); assert!(arm_str.contains("Err")); - assert!(arm_str.contains("custom handler required")); + assert!( + arm_str.contains("\"decode_input\""), + "error should name the decode_input role: {arm_str}" + ); + assert!( + arm_str.contains(&handler_signature_display(DataDriverRole::DecodeInput)), + "error should include the decode_input signature verbatim: {arm_str}" + ); } #[test] @@ -888,7 +1068,14 @@ mod tests { assert_eq!(arms.len(), 1); let arm_str = normalize_tokens(arms[0].clone()); assert!(arm_str.contains("Err")); - assert!(arm_str.contains("custom handler required")); + assert!( + arm_str.contains("\"decode_output\""), + "error should name the decode_output role: {arm_str}" + ); + assert!( + arm_str.contains(&handler_signature_display(DataDriverRole::DecodeOutput)), + "error should include the decode_output signature verbatim: {arm_str}" + ); } #[test] @@ -1196,7 +1383,7 @@ mod tests { let events = vec![make_event("PAUSED", quote! { PauseEvent })]; - let output = module(&type_map, &functions, &events, &[]); + let output = module(&[], &type_map, &functions, &events, &[]); let output_str = normalize_tokens(output); // Verify module structure @@ -1218,4 +1405,237 @@ mod tests { // Verify WASM entrypoint assert!(output_str.contains("generate_wasm_entrypoint")); } + + // ========================================================================= + // role_name / handler_signature_display tests + // ========================================================================= + + #[test] + fn test_role_name_covers_each_role() { + // Role names must match the attribute the user writes at the + // handler's definition site — anything else would send users + // looking for a `decode-input` attribute that doesn't exist. + assert_eq!(role_name(DataDriverRole::EncodeInput), "encode_input"); + assert_eq!(role_name(DataDriverRole::DecodeInput), "decode_input"); + assert_eq!(role_name(DataDriverRole::DecodeOutput), "decode_output"); + } + + #[test] + fn test_handler_signature_encode_input() { + let sig = handler_signature(DataDriverRole::EncodeInput); + assert_eq!(normalize_tokens(sig.arg_type.clone()), "& str"); + assert_eq!( + normalize_tokens(sig.return_type.clone()), + "Result < alloc :: vec :: Vec < u8 > , dusk_data_driver :: Error >" + ); + } + + #[test] + fn test_handler_signature_decode_input_matches_decode_output() { + // Both decoder roles take the same rkyv bytes and return the same + // JsonValue — the dispatch site uses identical call shapes, so the + // canonical signatures must agree. + let decode_input = handler_signature(DataDriverRole::DecodeInput); + let decode_output = handler_signature(DataDriverRole::DecodeOutput); + assert_eq!( + normalize_tokens(decode_input.arg_type.clone()), + normalize_tokens(decode_output.arg_type.clone()), + ); + assert_eq!( + normalize_tokens(decode_input.return_type.clone()), + normalize_tokens(decode_output.return_type.clone()), + ); + assert_eq!(normalize_tokens(decode_input.arg_type), "& [u8]"); + assert_eq!( + normalize_tokens(decode_input.return_type), + "Result < dusk_data_driver :: JsonValue , dusk_data_driver :: Error >" + ); + } + + #[test] + fn test_handler_signature_display_format() { + // The display form is what diagnostics show the user — it must + // render as a complete `fn(arg) -> ret` form they can copy-paste. + assert_eq!( + handler_signature_display(DataDriverRole::EncodeInput), + "fn(&str) -> Result, dusk_data_driver::Error>", + ); + assert_eq!( + handler_signature_display(DataDriverRole::DecodeInput), + "fn(&[u8]) -> Result", + ); + assert_eq!( + handler_signature_display(DataDriverRole::DecodeOutput), + "fn(&[u8]) -> Result", + ); + } + + // ========================================================================= + // reemit_imports / collect_handler_identifiers tests + // ========================================================================= + // + // These back the splice-side half of the validator-vs-splicer contract: + // the validator accepts short-path handlers only if the splicer can make + // those same paths resolve in the generated submodule. `reemit_imports` + // decides which user imports follow the handlers into the submodule; + // `collect_handler_identifiers` feeds its filter. A bug here resurfaces + // Defect 3 — a validator-only unit test won't catch it. + + fn import(name: &str, path: &str) -> ImportInfo { + ImportInfo { + name: name.into(), + path: path.into(), + } + } + + fn handler(func: syn::ItemFn) -> CustomDataDriverHandler { + CustomDataDriverHandler { + fn_name: "h".into(), + role: DataDriverRole::EncodeInput, + func, + } + } + + fn emitted_as_string(stream: &TokenStream2) -> String { + stream.to_string() + } + + #[test] + fn test_reemit_imports_skips_unreferenced() { + // A handler that only mentions `Error` must not pull the `Unused` + // import into the data-driver submodule — otherwise contract-only + // imports (e.g. `types::Ownable` gated behind the `abi` feature) + // would break the data-driver build. + let imports = vec![ + import("Error", "foo::Error"), + import("Unused", "bar::Unused"), + ]; + let h = handler(syn::parse_quote! { + fn h(x: &str) -> Result<(), Error> { unimplemented!() } + }); + let emitted = reemit_imports(&imports, &[h]); + + assert_eq!(emitted.len(), 1, "only `Error` should be re-emitted"); + let s = emitted_as_string(&emitted[0]); + assert!(s.contains("Error"), "emitted `use` references Error: {s}"); + assert!(!s.contains("Unused"), "Unused import must be filtered out"); + } + + #[test] + fn test_reemit_imports_preserves_rename() { + // `use foo::Bar as Baz;` is how the parser records a renamed import + // (`name` = "Baz", `path` = "foo::Bar"). When the handler references + // `Baz`, re-emit must produce `use foo::Bar as Baz;` — keeping the + // original type reachable under the alias the handler uses. + let imports = vec![import("Baz", "foo::Bar")]; + let h = handler(syn::parse_quote! { + fn h(x: &str) -> Result<(), Baz> { unimplemented!() } + }); + let emitted = reemit_imports(&imports, &[h]); + + assert_eq!(emitted.len(), 1); + let s = emitted_as_string(&emitted[0]); + assert!(s.contains("Bar"), "emit references the real path: {s}"); + assert!(s.contains("Baz"), "emit preserves the alias: {s}"); + assert!(s.contains("as"), "emit uses `as` for renamed imports: {s}"); + } + + #[test] + fn test_reemit_imports_plain_path_omits_as() { + // `use foo::Bar;` (no rename) must emit without an `as` clause — a + // stray self-alias like `use foo::Bar as Bar;` is legal Rust but + // noisy in expanded output and a signal the rename detection is + // off. + let imports = vec![import("Bar", "foo::Bar")]; + let h = handler(syn::parse_quote! { + fn h(x: &str) -> Result<(), Bar> { unimplemented!() } + }); + let emitted = reemit_imports(&imports, &[h]); + + assert_eq!(emitted.len(), 1); + let s = emitted_as_string(&emitted[0]); + assert!( + !s.contains(" as "), + "plain imports must not emit a self-rename: {s}" + ); + assert!(s.contains("Bar")); + } + + #[test] + fn test_reemit_imports_no_handlers_emits_nothing() { + // Without handlers, there's nothing to resolve short paths for — + // re-emitting any import is wasted noise and risks unrelated + // conflicts in the submodule. + let imports = vec![import("Error", "foo::Error")]; + let emitted = reemit_imports(&imports, &[]); + assert!(emitted.is_empty()); + } + + #[test] + fn test_reemit_imports_multi_segment_path() { + // `use dusk_data_driver::Error;` → 3-segment path. The emit must + // carry the full path so the alias resolves to the right type, not + // just `Error` (which wouldn't be in scope in the submodule). + let imports = vec![import("Error", "dusk_data_driver::Error")]; + let h = handler(syn::parse_quote! { + fn h(x: &str) -> Result<(), Error> { unimplemented!() } + }); + let emitted = reemit_imports(&imports, &[h]); + + let s = emitted_as_string(&emitted[0]); + assert!( + s.contains("dusk_data_driver"), + "emit carries the full path: {s}" + ); + assert!(s.contains("Error")); + } + + #[test] + fn test_collect_handler_identifiers_from_signature() { + // Signature references: `Result`, `Vec`, `u8`, `Error`. Arg type + // `&str` is split into `&` (punct) + `str` (ident) — only `str` + // counts. The collector must pick up signature idents even without + // a body. + let h = handler(syn::parse_quote! { + fn h(json: &str) -> Result, Error> { unimplemented!() } + }); + let idents = collect_handler_identifiers(&[h]); + for name in ["Result", "Vec", "u8", "Error", "str", "json"] { + assert!( + idents.contains(name), + "{name} should be collected from signature, got: {idents:?}" + ); + } + } + + #[test] + fn test_collect_handler_identifiers_walks_nested_groups() { + // Closures, blocks, and method chains nest tokens inside + // `TokenTree::Group` — the walker must recurse into them or body + // references like `.map_err(Error::from)` get missed and their + // imports would be wrongly filtered out. + let h = handler(syn::parse_quote! { + fn h(b: &[u8]) -> Result<(), Error> { + let _result = (|| Error::from(()))(); + Ok(()) + } + }); + let idents = collect_handler_identifiers(&[h]); + assert!( + idents.contains("Error"), + "identifier inside closure body must be collected: {idents:?}" + ); + assert!( + idents.contains("from"), + "method path segments inside closures must be collected: {idents:?}" + ); + } + + #[test] + fn test_collect_handler_identifiers_empty() { + // No handlers → empty set, not a panic. Guards the zero-handler + // fast path that `reemit_imports` relies on to emit nothing. + let idents = collect_handler_identifiers(&[]); + assert!(idents.is_empty()); + } } diff --git a/contract-macro/src/extract.rs b/contract-macro/src/extract.rs index 94ad052..4dd35f3 100644 --- a/contract-macro/src/extract.rs +++ b/contract-macro/src/extract.rs @@ -946,6 +946,9 @@ pub(crate) fn contract_data<'a>( let trait_impls = trait_impls(items, &name); let custom_handlers = custom_data_driver_handlers(items); + for handler in &custom_handlers { + validate::custom_handler(handler, &imports)?; + } Ok(ContractData { imports, diff --git a/contract-macro/src/lib.rs b/contract-macro/src/lib.rs index 80ce316..1630e6a 100644 --- a/contract-macro/src/lib.rs +++ b/contract-macro/src/lib.rs @@ -563,7 +563,8 @@ pub fn contract(_attr: TokenStream, item: TokenStream) -> TokenStream { let type_map = resolve::build_type_map(&imports, &functions, &events); // Generate data_driver module at crate root level (outside contract module) - let data_driver = data_driver::module(&type_map, &functions, &events, &custom_handlers); + let data_driver = + data_driver::module(&imports, &type_map, &functions, &events, &custom_handlers); // Rebuild the module with stripped contract attributes on methods let mod_vis = &module.vis; diff --git a/contract-macro/src/resolve.rs b/contract-macro/src/resolve.rs index 1df4b3e..c981b6d 100644 --- a/contract-macro/src/resolve.rs +++ b/contract-macro/src/resolve.rs @@ -26,6 +26,17 @@ fn build_import_map(imports: &[ImportInfo]) -> HashMap { .collect() } +/// Resolve a `syn::Type` to its fully qualified string form using the +/// contract module's imports. +/// +/// Shared with the handler-signature validator so the validator and +/// `build_type_map` agree on what "resolved" means — if short-path handlers +/// compile end-to-end, they also match the canonical expected signatures. +pub(crate) fn resolve_type(ty: &syn::Type, imports: &[ImportInfo]) -> String { + let import_map = build_import_map(imports); + resolve_syn_type(ty, &import_map) +} + /// Resolve a type path to its fully qualified form. /// /// Given a type like `Deposit` or `events::PauseToggled` and an import map, @@ -38,7 +49,7 @@ fn build_import_map(imports: &[ImportInfo]) -> HashMap { /// - Multi-segment paths: `events::PauseToggled` -> /// `my_crate::events::PauseToggled` /// - Generic types: `Option` -> `Option` -fn resolve_type(ty: &TokenStream2, import_map: &HashMap) -> String { +fn resolve_type_tokens(ty: &TokenStream2, import_map: &HashMap) -> String { let ty_str = ty.to_string(); // Handle unit type @@ -190,17 +201,17 @@ pub(crate) fn build_type_map( // Resolve function input, output, and feed types for func in functions { let input_key = func.input_type.to_string(); - let input_resolved = resolve_type(&func.input_type, &import_map); + let input_resolved = resolve_type_tokens(&func.input_type, &import_map); type_map.insert(input_key, input_resolved); let output_key = func.output_type.to_string(); - let output_resolved = resolve_type(&func.output_type, &import_map); + let output_resolved = resolve_type_tokens(&func.output_type, &import_map); type_map.insert(output_key, output_resolved); // Resolve feed_type if present (from #[contract(feeds = "Type")]) if let Some(feed_type) = &func.feed_type { let feed_key = feed_type.to_string(); - let feed_resolved = resolve_type(feed_type, &import_map); + let feed_resolved = resolve_type_tokens(feed_type, &import_map); type_map.insert(feed_key, feed_resolved); } } @@ -208,7 +219,7 @@ pub(crate) fn build_type_map( // Resolve event data types and topic paths for event in events { let data_key = event.data_type.to_string(); - let data_resolved = resolve_type(&event.data_type, &import_map); + let data_resolved = resolve_type_tokens(&event.data_type, &import_map); type_map.insert(data_key, data_resolved); // Also resolve the topic path (e.g., "events::PauseToggled::PAUSED") @@ -238,7 +249,7 @@ mod tests { let import_map = build_import_map(&imports); let ty = quote! { Deposit }; - let resolved = resolve_type(&ty, &import_map); + let resolved = resolve_type_tokens(&ty, &import_map); assert_eq!(resolved, "my_crate::Deposit"); } @@ -248,7 +259,7 @@ mod tests { let import_map = build_import_map(&imports); let ty = quote! { DSAddress }; - let resolved = resolve_type(&ty, &import_map); + let resolved = resolve_type_tokens(&ty, &import_map); assert_eq!(resolved, "my_crate::Address"); } @@ -258,7 +269,7 @@ mod tests { let import_map = build_import_map(&imports); let ty = quote! { events::PauseToggled }; - let resolved = resolve_type(&ty, &import_map); + let resolved = resolve_type_tokens(&ty, &import_map); assert_eq!(resolved, "my_crate::events::PauseToggled"); } @@ -268,7 +279,7 @@ mod tests { let import_map = build_import_map(&imports); let ty = quote! { Option }; - let resolved = resolve_type(&ty, &import_map); + let resolved = resolve_type_tokens(&ty, &import_map); assert_eq!(resolved, "Option"); } @@ -281,7 +292,7 @@ mod tests { let import_map = build_import_map(&imports); let ty = quote! { (Deposit, DSAddress) }; - let resolved = resolve_type(&ty, &import_map); + let resolved = resolve_type_tokens(&ty, &import_map); assert_eq!(resolved, "(my_crate::Deposit, my_crate::Address)"); } @@ -291,7 +302,7 @@ mod tests { let import_map = build_import_map(&imports); let ty = quote! { () }; - let resolved = resolve_type(&ty, &import_map); + let resolved = resolve_type_tokens(&ty, &import_map); assert_eq!(resolved, "()"); } @@ -301,7 +312,7 @@ mod tests { let import_map = build_import_map(&imports); let ty = quote! { u64 }; - let resolved = resolve_type(&ty, &import_map); + let resolved = resolve_type_tokens(&ty, &import_map); assert_eq!(resolved, "u64"); } } diff --git a/contract-macro/src/validate.rs b/contract-macro/src/validate.rs index 3328beb..57ec4be 100644 --- a/contract-macro/src/validate.rs +++ b/contract-macro/src/validate.rs @@ -6,7 +6,12 @@ //! Validation functions for contract macro. -use syn::{FnArg, ImplItem, ImplItemFn, ItemImpl, ReturnType, Type, Visibility}; +use quote::ToTokens; +use syn::visit::Visit; +use syn::{FnArg, ImplItem, ImplItemFn, ItemImpl, Lifetime, ReturnType, Type, Visibility}; + +use crate::data_driver::{handler_signature, handler_signature_display, pretty_tokens, role_name}; +use crate::{CustomDataDriverHandler, ImportInfo, resolve}; /// Validate that a public method has a supported signature for extern wrapper /// generation. @@ -362,6 +367,188 @@ pub(crate) fn trait_method( Ok(()) } +/// Validate a custom data-driver handler's signature. +/// +/// Handler functions registered via `#[contract(encode_input = "…")]`, +/// `#[contract(decode_input = "…")]`, or `#[contract(decode_output = "…")]` +/// are moved into the generated `data_driver` module and called directly by +/// the dispatch match arms. If the signature doesn't match what the dispatch +/// site expects, the downstream call site in macro-generated code fails with +/// a cryptic type error against code the user didn't write. +/// +/// This validation emits a clear `compile_error!` at the handler definition +/// naming the handler, the role, and the expected signature. +/// +/// Comparison pipeline: +/// - reject `'static` lifetimes outright — the generated dispatcher calls the +/// handler with a local-lifetime borrow and can't satisfy a `'static` one; +/// - run the user's argument / return types through [`resolve::resolve_type`] +/// so short-path idioms like `Vec` or `Error` (after a `use`) are +/// rewritten to their canonical fully-qualified form, then token-compare +/// against the role's expected signature. The import map is the single source +/// of truth for path equivalence — the same map that drives handler splicing +/// drives validation. +/// +/// Other reference lifetimes (elided or handler-generic via `fn<'a>(…)`) are +/// accepted: the resolver strips reference lifetimes, so they're irrelevant +/// after canonicalisation. +/// +/// The canonical per-role signature lives in `data_driver::handler_signature` +/// so the validator and the code that calls handlers can't drift apart. +pub(crate) fn custom_handler( + handler: &CustomDataDriverHandler, + imports: &[ImportInfo], +) -> Result<(), syn::Error> { + let role = handler.role; + let role_str = role_name(role); + let expected = handler_signature(role); + let expected_display = handler_signature_display(role); + let sig = &handler.func.sig; + let handler_name = &sig.ident; + + // Handlers are free functions, not methods. + if let Some(FnArg::Receiver(receiver)) = sig.inputs.first() { + return Err(syn::Error::new_spanned( + receiver, + format!( + "handler `{handler_name}` for `{role_str}` must be a free function, \ + not a method; expected signature: `{expected_display}`" + ), + )); + } + + // Exactly one argument. + let typed_args: Vec<&syn::PatType> = sig + .inputs + .iter() + .filter_map(|arg| match arg { + FnArg::Typed(pat_type) => Some(pat_type), + FnArg::Receiver(_) => None, + }) + .collect(); + + if typed_args.len() != 1 { + return Err(syn::Error::new_spanned( + &sig.inputs, + format!( + "handler `{handler_name}` for `{role_str}` must take exactly one \ + argument, got {}; expected signature: `{expected_display}`", + typed_args.len() + ), + )); + } + + let arg_ty = &typed_args[0].ty; + + // A handler that demands a `'static` borrow can't be called by the + // dispatcher — the input is a local borrow of the incoming bytes. Reject + // before canonicalisation so the user sees a lifetime-specific message, + // not a confusing "argument type doesn't match" after the resolver has + // silently stripped lifetimes from the expected form. + reject_static_lifetime(arg_ty, handler_name, role_str, &expected_display)?; + + // Canonicalise the user-written argument type through the import map and + // token-compare to the role's canonical form. + let resolved_arg = resolve::resolve_type(arg_ty, imports); + if !tokens_equal(&resolved_arg, &expected.arg_type.to_string()) { + let got_arg = pretty_tokens(&arg_ty.to_token_stream()); + let want_arg = pretty_tokens(&expected.arg_type); + return Err(syn::Error::new_spanned( + arg_ty, + format!( + "handler `{handler_name}` for `{role_str}` has argument type \ + `{got_arg}`, expected `{want_arg}`; full expected signature: \ + `{expected_display}`" + ), + )); + } + + // Return type must match the role's canonical return type. + let got_ret = match &sig.output { + ReturnType::Default => { + return Err(syn::Error::new_spanned( + sig, + format!( + "handler `{handler_name}` for `{role_str}` must return a \ + `Result`; expected signature: `{expected_display}`" + ), + )); + } + ReturnType::Type(_, ty) => ty, + }; + + reject_static_lifetime(got_ret, handler_name, role_str, &expected_display)?; + + let resolved_ret = resolve::resolve_type(got_ret, imports); + if !tokens_equal(&resolved_ret, &expected.return_type.to_string()) { + let got_ret_str = pretty_tokens(&got_ret.to_token_stream()); + let want_ret = pretty_tokens(&expected.return_type); + return Err(syn::Error::new_spanned( + &sig.output, + format!( + "handler `{handler_name}` for `{role_str}` has return type \ + `{got_ret_str}`, expected `{want_ret}`; full expected signature: \ + `{expected_display}`" + ), + )); + } + + Ok(()) +} + +/// Reject any `'static` lifetime appearing in the handler's signature. +/// +/// The generated dispatcher passes a local-lifetime borrow and cannot +/// satisfy a `'static` lifetime the handler promises to receive or return. +/// We check both arguments and return type; a clear message at the signature +/// site beats a mysterious lifetime mismatch deep inside macro-generated code. +fn reject_static_lifetime( + ty: &Type, + handler_name: &syn::Ident, + role_str: &str, + expected_display: &str, +) -> Result<(), syn::Error> { + let mut finder = StaticLifetimeFinder::default(); + finder.visit_type(ty); + if let Some(span_lifetime) = finder.first { + return Err(syn::Error::new_spanned( + span_lifetime, + format!( + "handler `{handler_name}` for `{role_str}` cannot bind a `'static` \ + lifetime; the dispatcher passes a local borrow. Drop the lifetime \ + or declare a handler-generic one (e.g. `fn {handler_name}<'a>(…)`). \ + Expected signature: `{expected_display}`" + ), + )); + } + Ok(()) +} + +#[derive(Default)] +struct StaticLifetimeFinder { + first: Option, +} + +impl<'ast> Visit<'ast> for StaticLifetimeFinder { + fn visit_lifetime(&mut self, lt: &'ast Lifetime) { + if self.first.is_none() && lt.ident == "static" { + self.first = Some(lt.clone()); + } + } +} + +/// Whitespace-insensitive token-string equality. +/// +/// `quote!`-produced token streams stringify with spaces around punctuation +/// (`& str`, `Result < T , E >`), while [`resolve::resolve_type`] emits +/// whitespace-free output (`&str`, `Result`). Both forms are +/// token-identical after whitespace is stripped — use that as the comparator +/// so the two sources of truth can coexist without re-parsing either side. +fn tokens_equal(a: &str, b: &str) -> bool { + let strip = |s: &str| -> String { s.chars().filter(|c| !c.is_whitespace()).collect() }; + strip(a) == strip(b) +} + /// Validate that a mutating method emits events. /// /// Public `&mut self` methods should emit events for observability. This @@ -890,4 +1077,384 @@ mod tests { }; assert!(method_emits_event(&method, false, false, false).is_ok()); } + + // ========================================================================= + // custom_handler tests + // ========================================================================= + // + // These exercise the validator the macro runs over each handler found in + // the annotated module — the same code path a real `#[contract]` + // expansion uses (see `extract::contract_data`). Signature shape is + // independent per role, so every role gets a positive regression and a + // tailored negative case. + + use crate::DataDriverRole; + + fn handler(role: DataDriverRole, func: syn::ItemFn) -> CustomDataDriverHandler { + CustomDataDriverHandler { + fn_name: "some_fn".to_string(), + role, + func, + } + } + + /// Canonical import map — mirrors what every real contract module + /// carries for data-driver handlers. Shared across tests so short-path + /// coverage matches the environment handlers actually compile in. + fn canonical_imports() -> Vec { + vec![ + ImportInfo { + name: "Vec".into(), + path: "alloc::vec::Vec".into(), + }, + ImportInfo { + name: "Error".into(), + path: "dusk_data_driver::Error".into(), + }, + ImportInfo { + name: "JsonValue".into(), + path: "dusk_data_driver::JsonValue".into(), + }, + ] + } + + #[test] + fn test_custom_handler_encode_input_ok() { + // Canonical encode_input handler — the shape the test-contract and + // docs both use. Must keep passing or we've broken existing users. + let func: syn::ItemFn = syn::parse_quote! { + fn my_encoder(json: &str) + -> Result, dusk_data_driver::Error> + { unimplemented!() } + }; + let h = handler(DataDriverRole::EncodeInput, func); + assert!(custom_handler(&h, &[]).is_ok()); + } + + #[test] + fn test_custom_handler_decode_input_ok() { + let func: syn::ItemFn = syn::parse_quote! { + fn my_decoder(rkyv: &[u8]) + -> Result + { unimplemented!() } + }; + let h = handler(DataDriverRole::DecodeInput, func); + assert!(custom_handler(&h, &[]).is_ok()); + } + + #[test] + fn test_custom_handler_decode_output_ok() { + let func: syn::ItemFn = syn::parse_quote! { + fn my_decoder(bytes: &[u8]) + -> Result + { unimplemented!() } + }; + let h = handler(DataDriverRole::DecodeOutput, func); + assert!(custom_handler(&h, &[]).is_ok()); + } + + #[test] + fn test_custom_handler_accepts_arbitrary_arg_name() { + // Argument name is not part of the signature contract — the dispatch + // site calls `handler(json)` / `handler(rkyv)` positionally, so any + // name must work. Regression guard against the validator accidentally + // pinning one specific name. + let func: syn::ItemFn = syn::parse_quote! { + fn my_encoder(anything: &str) + -> Result, dusk_data_driver::Error> + { unimplemented!() } + }; + let h = handler(DataDriverRole::EncodeInput, func); + assert!(custom_handler(&h, &[]).is_ok()); + } + + #[test] + fn test_custom_handler_short_paths_resolve_through_imports() { + // The import map is the single source of truth for path equivalence: + // with canonical imports in scope the resolver rewrites short names + // to canonical form, so short-path handlers match. (The end-to-end + // trybuild fixture proves the *splicer* agrees — this covers the + // validator's half of the contract.) + let func: syn::ItemFn = syn::parse_quote! { + fn my_encoder(json: &str) -> Result, Error> + { unimplemented!() } + }; + let h = handler(DataDriverRole::EncodeInput, func); + assert!(custom_handler(&h, &canonical_imports()).is_ok()); + } + + #[test] + fn test_custom_handler_short_paths_rejected_without_imports() { + // Absent the import map, `Vec` / `Error` resolve to themselves + // and don't match the canonical. This is the opposite regression of + // the positive test above: validates that resolution is what makes + // short paths pass. + let func: syn::ItemFn = syn::parse_quote! { + fn my_encoder(json: &str) -> Result, Error> + { unimplemented!() } + }; + let h = handler(DataDriverRole::EncodeInput, func); + assert!(custom_handler(&h, &[]).is_err()); + } + + #[test] + fn test_custom_handler_accepts_handler_generic_lifetime() { + // A handler that declares its own lifetime (`fn f<'a>(… &'a …)`) can + // still bind whatever borrow the dispatcher passes. The resolver + // strips reference lifetimes during canonicalisation, so the match + // is automatic — this test pins that behaviour down so it doesn't + // regress if the resolver ever starts preserving lifetimes. + let func: syn::ItemFn = syn::parse_quote! { + fn my_encoder<'a>(json: &'a str) + -> Result, dusk_data_driver::Error> + { unimplemented!() } + }; + let h = handler(DataDriverRole::EncodeInput, func); + assert!(custom_handler(&h, &[]).is_ok()); + } + + #[test] + fn test_custom_handler_encode_input_wrong_arg_type() { + // encode_input takes `&str`; this hands it `&[u8]` (the decoder + // shape). The validator must surface this before the downstream + // type error fires against macro-generated code. + let func: syn::ItemFn = syn::parse_quote! { + fn my_encoder(json: &[u8]) + -> Result, dusk_data_driver::Error> + { unimplemented!() } + }; + let h = handler(DataDriverRole::EncodeInput, func); + let err = custom_handler(&h, &[]).unwrap_err().to_string(); + assert!( + err.contains("my_encoder"), + "error should name the handler: {err}" + ); + assert!( + err.contains("encode_input"), + "error should name the role: {err}" + ); + assert!( + err.contains("fn(&str) -> Result, dusk_data_driver::Error>"), + "error should show the full expected signature: {err}" + ); + } + + #[test] + fn test_custom_handler_decode_input_wrong_return_type() { + // decode_input must return `Result`; this returns + // a raw `Vec`, which is the wrong role's return shape. + let func: syn::ItemFn = syn::parse_quote! { + fn my_decoder(rkyv: &[u8]) -> alloc::vec::Vec + { unimplemented!() } + }; + let h = handler(DataDriverRole::DecodeInput, func); + let err = custom_handler(&h, &[]).unwrap_err().to_string(); + assert!(err.contains("my_decoder"), "names the handler: {err}"); + assert!(err.contains("decode_input"), "names the role: {err}"); + assert!( + err.contains( + "fn(&[u8]) -> Result" + ), + "shows the expected signature: {err}" + ); + } + + #[test] + fn test_custom_handler_decode_output_wrong_arg_count() { + // decode_output takes exactly one argument; more than one signals a + // misunderstanding of the dispatch contract. + let func: syn::ItemFn = syn::parse_quote! { + fn my_decoder(a: &[u8], b: &[u8]) + -> Result + { unimplemented!() } + }; + let h = handler(DataDriverRole::DecodeOutput, func); + let err = custom_handler(&h, &[]).unwrap_err().to_string(); + assert!(err.contains("my_decoder"), "names the handler: {err}"); + assert!(err.contains("decode_output"), "names the role: {err}"); + assert!( + err.contains("exactly one argument"), + "error should explain the argument-count requirement: {err}" + ); + assert!( + err.contains( + "fn(&[u8]) -> Result" + ), + "shows the expected signature: {err}" + ); + } + + #[test] + fn test_custom_handler_no_return_type() { + // A handler with no return at all can't participate in dispatch — + // catch it early with a role-specific message instead of a downstream + // `()` vs `Result<...>` mismatch. + let func: syn::ItemFn = syn::parse_quote! { + fn my_encoder(json: &str) { } + }; + let h = handler(DataDriverRole::EncodeInput, func); + let err = custom_handler(&h, &[]).unwrap_err().to_string(); + assert!(err.contains("my_encoder"), "names the handler: {err}"); + assert!(err.contains("encode_input"), "names the role: {err}"); + assert!( + err.contains("must return a `Result`"), + "error should explain the return requirement: {err}" + ); + } + + #[test] + fn test_custom_handler_no_args() { + // Zero arguments also falls under the "exactly one" rule — the + // validator should say so explicitly. + let func: syn::ItemFn = syn::parse_quote! { + fn my_encoder() + -> Result, dusk_data_driver::Error> + { unimplemented!() } + }; + let h = handler(DataDriverRole::EncodeInput, func); + let err = custom_handler(&h, &[]).unwrap_err().to_string(); + assert!( + err.contains("exactly one argument"), + "error should explain the argument-count requirement: {err}" + ); + } + + #[test] + fn test_custom_handler_rejects_static_lifetime_on_argument() { + // The generated dispatcher passes a local-lifetime borrow; a handler + // that promises `'static` can't bind it. Catch it at the validator + // with a lifetime-specific message rather than letting a lifetime + // mismatch surface deep in macro-generated code. + let func: syn::ItemFn = syn::parse_quote! { + fn my_encoder(json: &'static str) + -> Result, dusk_data_driver::Error> + { unimplemented!() } + }; + let h = handler(DataDriverRole::EncodeInput, func); + let err = custom_handler(&h, &[]).unwrap_err().to_string(); + assert!( + err.contains("'static"), + "error should name the offending lifetime: {err}" + ); + assert!( + err.contains("my_encoder"), + "error should name the handler: {err}" + ); + assert!( + err.contains("encode_input"), + "error should name the role: {err}" + ); + } + + #[test] + fn test_custom_handler_rejects_static_lifetime_in_return_position() { + // `'static` anywhere in the return type is equally unworkable — the + // dispatcher can't supply a `'static` reference back either. Same + // reject path, exercised on the return side for symmetry. + let func: syn::ItemFn = syn::parse_quote! { + fn my_decoder(rkyv: &[u8]) + -> Result<&'static dusk_data_driver::JsonValue, dusk_data_driver::Error> + { unimplemented!() } + }; + let h = handler(DataDriverRole::DecodeInput, func); + let err = custom_handler(&h, &[]).unwrap_err().to_string(); + assert!( + err.contains("'static"), + "error should name the offending lifetime: {err}" + ); + } + + #[test] + fn test_custom_handler_rejects_different_error_prefix() { + // `foo::Error` shares the last segment with `dusk_data_driver::Error` + // but is a different type. No import map entry rewrites it, so the + // canonical-form comparison fails and the validator rejects. + let func: syn::ItemFn = syn::parse_quote! { + fn my_encoder(json: &str) + -> Result, foo::Error> + { unimplemented!() } + }; + let h = handler(DataDriverRole::EncodeInput, func); + let err = custom_handler(&h, &[]).unwrap_err().to_string(); + assert!( + err.contains("has return type"), + "error should identify the return type as the mismatch: {err}" + ); + assert!( + err.contains("foo::Error"), + "error should surface what the user wrote: {err}" + ); + } + + #[test] + fn test_custom_handler_rejects_different_error_name() { + // `MyError` isn't in the import map, resolves to itself, and differs + // from canonical `dusk_data_driver::Error`. The rejected type must + // surface in the message so the user can find the mismatch. + let func: syn::ItemFn = syn::parse_quote! { + fn my_encoder(json: &str) + -> Result, MyError> + { unimplemented!() } + }; + let h = handler(DataDriverRole::EncodeInput, func); + let err = custom_handler(&h, &[]).unwrap_err().to_string(); + assert!( + err.contains("MyError"), + "error should surface the user's wrong type: {err}" + ); + } + + #[test] + fn test_custom_handler_rejects_mut_reference() { + // `&mut str` has the wrong mutability — still a reference, still to + // `str`, but semantically different and would break the generated + // call site. Canonicalisation preserves mutability, so the compare + // catches this. + let func: syn::ItemFn = syn::parse_quote! { + fn my_encoder(json: &mut str) + -> Result, dusk_data_driver::Error> + { unimplemented!() } + }; + let h = handler(DataDriverRole::EncodeInput, func); + let err = custom_handler(&h, &[]).unwrap_err().to_string(); + assert!( + err.contains("has argument type"), + "error should identify the argument type as the mismatch: {err}" + ); + } + + // ========================================================================= + // tokens_equal tests + // ========================================================================= + // + // `quote!`-produced strings carry whitespace `rustc`'s type printer + // doesn't (`& str`, `Result < T , E >`), while `resolve::resolve_type` + // emits whitespace-free output. The comparator has to bridge the two + // without re-parsing — unit-test it directly so a regression surfaces + // here, not as a confusing "argument type doesn't match" from a handler + // that looked fine. + + #[test] + fn test_tokens_equal_ignores_whitespace_differences() { + // `quote!(&str).to_string()` gives `& str`; the resolver emits + // `&str`. Both must compare equal or short-path canonicalisation + // never matches canonical. + assert!(tokens_equal("& str", "&str")); + assert!(tokens_equal( + "Result < alloc :: vec :: Vec < u8 > , dusk_data_driver :: Error >", + "Result, dusk_data_driver::Error>", + )); + assert!(tokens_equal(" foo :: bar ", "foo::bar")); + assert!(tokens_equal("&[u8]", "& [u8]")); + } + + #[test] + fn test_tokens_equal_detects_real_differences() { + // Mutability, identifier, and path differences must not collapse + // under whitespace stripping. + assert!(!tokens_equal("&str", "&mut str")); + assert!(!tokens_equal("dusk_data_driver::Error", "foo::Error")); + assert!(!tokens_equal("Vec", "Vec")); + assert!(!tokens_equal("Error", "MyError")); + } } diff --git a/contract-macro/tests/compile-fail/handler_static_lifetime_rejected.rs b/contract-macro/tests/compile-fail/handler_static_lifetime_rejected.rs new file mode 100644 index 0000000..776eaed --- /dev/null +++ b/contract-macro/tests/compile-fail/handler_static_lifetime_rejected.rs @@ -0,0 +1,30 @@ +use dusk_forge_contract::contract; + +#[contract] +mod my_contract { + pub struct MyContract { + value: u64, + } + + impl MyContract { + pub const fn new() -> Self { + Self { value: 0 } + } + + pub fn get_value(&self) -> u64 { + self.value + } + } + + // The generated dispatcher calls this handler with a local-lifetime + // borrow of the incoming JSON — a handler that promises `'static` can't + // bind it. The validator must reject at the signature site rather than + // letting a lifetime mismatch surface inside macro-generated code the + // user didn't write. + #[contract(encode_input = "raw_id")] + fn encode_raw_id(json: &'static str) -> Result, dusk_data_driver::Error> { + unimplemented!() + } +} + +fn main() {} diff --git a/contract-macro/tests/compile-fail/handler_static_lifetime_rejected.stderr b/contract-macro/tests/compile-fail/handler_static_lifetime_rejected.stderr new file mode 100644 index 0000000..47190ee --- /dev/null +++ b/contract-macro/tests/compile-fail/handler_static_lifetime_rejected.stderr @@ -0,0 +1,5 @@ +error: handler `encode_raw_id` for `encode_input` cannot bind a `'static` lifetime; the dispatcher passes a local borrow. Drop the lifetime or declare a handler-generic one (e.g. `fn encode_raw_id<'a>(…)`). Expected signature: `fn(&str) -> Result, dusk_data_driver::Error>` + --> tests/compile-fail/handler_static_lifetime_rejected.rs:25:29 + | +25 | fn encode_raw_id(json: &'static str) -> Result, dusk_data_driver::Error> { + | ^^^^^^^ diff --git a/contract-macro/tests/compile-fail/handler_wrong_arg_count_decode_output.rs b/contract-macro/tests/compile-fail/handler_wrong_arg_count_decode_output.rs new file mode 100644 index 0000000..a8ee162 --- /dev/null +++ b/contract-macro/tests/compile-fail/handler_wrong_arg_count_decode_output.rs @@ -0,0 +1,31 @@ +use dusk_forge_contract::contract; + +#[contract] +mod my_contract { + pub struct MyContract { + value: u64, + } + + impl MyContract { + pub const fn new() -> Self { + Self { value: 0 } + } + + pub fn get_value(&self) -> u64 { + self.value + } + } + + // decode_output handlers take exactly one argument; this declares two, + // so the macro must reject it at expansion time rather than letting the + // downstream dispatch site fail with an arity mismatch on generated code. + #[contract(decode_output = "raw_id")] + fn decode_raw_id( + rkyv: &[u8], + extra: &[u8], + ) -> Result { + unimplemented!() + } +} + +fn main() {} diff --git a/contract-macro/tests/compile-fail/handler_wrong_arg_count_decode_output.stderr b/contract-macro/tests/compile-fail/handler_wrong_arg_count_decode_output.stderr new file mode 100644 index 0000000..41145e8 --- /dev/null +++ b/contract-macro/tests/compile-fail/handler_wrong_arg_count_decode_output.stderr @@ -0,0 +1,6 @@ +error: handler `decode_raw_id` for `decode_output` must take exactly one argument, got 2; expected signature: `fn(&[u8]) -> Result` + --> tests/compile-fail/handler_wrong_arg_count_decode_output.rs:24:9 + | +24 | / rkyv: &[u8], +25 | | extra: &[u8], + | |_____________________^ diff --git a/contract-macro/tests/compile-fail/handler_wrong_arg_encode_input.rs b/contract-macro/tests/compile-fail/handler_wrong_arg_encode_input.rs new file mode 100644 index 0000000..5ccc5f8 --- /dev/null +++ b/contract-macro/tests/compile-fail/handler_wrong_arg_encode_input.rs @@ -0,0 +1,28 @@ +use dusk_forge_contract::contract; + +#[contract] +mod my_contract { + pub struct MyContract { + value: u64, + } + + impl MyContract { + pub const fn new() -> Self { + Self { value: 0 } + } + + pub fn get_value(&self) -> u64 { + self.value + } + } + + // encode_input handlers take `&str` — this hands it `&[u8]`, so the + // macro must reject it before the generated dispatch site fails with a + // cryptic downstream type error. + #[contract(encode_input = "raw_id")] + fn encode_raw_id(bytes: &[u8]) -> Result, dusk_data_driver::Error> { + unimplemented!() + } +} + +fn main() {} diff --git a/contract-macro/tests/compile-fail/handler_wrong_arg_encode_input.stderr b/contract-macro/tests/compile-fail/handler_wrong_arg_encode_input.stderr new file mode 100644 index 0000000..babd43e --- /dev/null +++ b/contract-macro/tests/compile-fail/handler_wrong_arg_encode_input.stderr @@ -0,0 +1,5 @@ +error: handler `encode_raw_id` for `encode_input` has argument type `&[u8]`, expected `&str`; full expected signature: `fn(&str) -> Result, dusk_data_driver::Error>` + --> tests/compile-fail/handler_wrong_arg_encode_input.rs:23:29 + | +23 | fn encode_raw_id(bytes: &[u8]) -> Result, dusk_data_driver::Error> { + | ^^^^^ diff --git a/contract-macro/tests/compile-fail/handler_wrong_return_decode_input.rs b/contract-macro/tests/compile-fail/handler_wrong_return_decode_input.rs new file mode 100644 index 0000000..5ec211e --- /dev/null +++ b/contract-macro/tests/compile-fail/handler_wrong_return_decode_input.rs @@ -0,0 +1,29 @@ +use dusk_forge_contract::contract; + +#[contract] +mod my_contract { + pub struct MyContract { + value: u64, + } + + impl MyContract { + pub const fn new() -> Self { + Self { value: 0 } + } + + pub fn get_value(&self) -> u64 { + self.value + } + } + + // decode_input handlers must return `Result` — this + // returns a raw byte vector, which is the wrong role's shape. The + // validator must name the decode_input role (not a generic error) so + // the user knows which of their handlers to fix. + #[contract(decode_input = "raw_id")] + fn decode_raw_id(rkyv: &[u8]) -> alloc::vec::Vec { + unimplemented!() + } +} + +fn main() {} diff --git a/contract-macro/tests/compile-fail/handler_wrong_return_decode_input.stderr b/contract-macro/tests/compile-fail/handler_wrong_return_decode_input.stderr new file mode 100644 index 0000000..332086d --- /dev/null +++ b/contract-macro/tests/compile-fail/handler_wrong_return_decode_input.stderr @@ -0,0 +1,5 @@ +error: handler `decode_raw_id` for `decode_input` has return type `alloc::vec::Vec`, expected `Result`; full expected signature: `fn(&[u8]) -> Result` + --> tests/compile-fail/handler_wrong_return_decode_input.rs:24:35 + | +24 | fn decode_raw_id(rkyv: &[u8]) -> alloc::vec::Vec { + | ^^^^^^^^^^^^^^^^^^^^^^ diff --git a/contract-macro/tests/compile-pass-short-paths/Cargo.toml b/contract-macro/tests/compile-pass-short-paths/Cargo.toml new file mode 100644 index 0000000..60ce508 --- /dev/null +++ b/contract-macro/tests/compile-pass-short-paths/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "short-paths-compile-pass" +version = "0.0.0" +edition = "2024" +publish = false + +[workspace] + +[features] +default = ["data-driver"] +contract = [] +data-driver = ["dep:dusk-data-driver"] + +[dependencies] +dusk-forge = { path = "../../.." } +dusk-data-driver = { version = "0.3", optional = true } + +[lib] +path = "src/lib.rs" diff --git a/contract-macro/tests/compile-pass-short-paths/src/lib.rs b/contract-macro/tests/compile-pass-short-paths/src/lib.rs new file mode 100644 index 0000000..a0d08f5 --- /dev/null +++ b/contract-macro/tests/compile-pass-short-paths/src/lib.rs @@ -0,0 +1,53 @@ +#![no_std] + +// End-to-end coverage for handler re-emit: every failure mode Defect 3 +// produced had the validator accept short paths that the splicer +// couldn't actually resolve in the generated submodule. This fixture +// pins the round-trip — if the splicer regresses, `cargo check` fails +// here with `cannot find type …` or a missing-method error, not a +// downstream integration test surprise. +// +// The handlers below reference `Vec`, `Error`, and `JsonValue` as +// short paths in both their signatures *and* their bodies. With +// `data-driver` enabled (the crate's default), the `#[contract]` macro +// splices them into the generated `data_driver` submodule; re-emit +// carries the imports along so the short paths resolve there. + +extern crate alloc; + +#[dusk_forge::contract] +mod my_contract { + extern crate alloc; + + use alloc::string::String; + #[cfg(feature = "data-driver")] + use alloc::vec::Vec; + #[cfg(feature = "data-driver")] + use dusk_data_driver::{Error, JsonValue}; + + pub struct MyContract { + value: u64, + } + + impl MyContract { + pub const fn new() -> Self { + Self { value: 0 } + } + + pub fn get_value(&self) -> u64 { + self.value + } + } + + #[contract(encode_input = "raw_id")] + fn encode_raw_id(_json: &str) -> Result, Error> { + // Body references the imported short paths too — re-emit must + // cover the body, not just the signature. + Err(Error::Unsupported(String::new())) + } + + #[contract(decode_output = "raw_id")] + fn decode_raw_id(_bytes: &[u8]) -> Result { + Err(Error::Unsupported(String::new())) + } +} diff --git a/contract-macro/tests/compile_fail.rs b/contract-macro/tests/compile_fail.rs index 1ad9144..5283a7f 100644 --- a/contract-macro/tests/compile_fail.rs +++ b/contract-macro/tests/compile_fail.rs @@ -25,3 +25,31 @@ fn both_features_compile_fail() { "expected 'mutually exclusive' error, got:\n{stderr}" ); } + +/// End-to-end check that a contract with short-path handlers round-trips +/// through the macro and compiles. +/// +/// This is the specific failure mode Defect 3 exposed: validator-only unit +/// tests accepted `Vec` / `Error` but the splicer didn't re-emit the +/// user's `use` items into the generated `data_driver` submodule, so +/// expansion failed with `cannot find type 'Error' in this scope`. If the +/// re-emit logic (filtering, rename preservation, path emission) regresses, +/// this test fails with a compiler error pointing at the fixture — not a +/// silent success that defers the bug to downstream integration. +#[test] +fn short_paths_compile_pass() { + let output = std::process::Command::new("cargo") + .arg("check") + .current_dir(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/compile-pass-short-paths" + )) + .output() + .expect("failed to run cargo check"); + + assert!( + output.status.success(), + "short-path handler fixture failed to compile:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/test-contract/src/lib.rs b/tests/test-contract/src/lib.rs index ac76cb9..57dc94f 100644 --- a/tests/test-contract/src/lib.rs +++ b/tests/test-contract/src/lib.rs @@ -27,9 +27,19 @@ mod test_contract { use alloc::collections::BTreeMap; use alloc::string::String; + // `Vec` / `Error` / `JsonValue` are referenced as short paths by the + // custom-handler signatures below — the `#[contract]` macro re-emits + // these imports into the generated `data_driver` submodule so the + // spliced handlers resolve the same names they did here. Gating them on + // the data-driver feature also keeps the contract build warning-free, + // since no contract-side code uses these names directly. + #[cfg(feature = "data-driver")] + use alloc::vec::Vec; use dusk_core::abi; use dusk_core::signatures::bls::PublicKey; + #[cfg(feature = "data-driver")] + use dusk_data_driver::{Error, JsonValue}; use types::{Item, ItemId, Ownable, events, helpers}; // ========================================================================= @@ -281,19 +291,21 @@ mod test_contract { /// Custom encoder for the `raw_id` data-driver function. /// - /// Demonstrates custom data-driver functions that exist only in the - /// data-driver WASM, not as contract methods. + /// Written with short paths (`Vec`, `Error`) to exercise import re-emit + /// end-to-end: the signature and body both resolve against the re-emitted + /// `use dusk_data_driver::{Error, JsonValue};` / `use alloc::vec::Vec;` + /// inside the generated `data_driver` submodule. #[contract(encode_input = "raw_id")] - fn encode_raw_id(json: &str) -> Result, dusk_data_driver::Error> { + fn encode_raw_id(json: &str) -> Result, Error> { let id: u64 = serde_json::from_str(json)?; Ok(id.to_le_bytes().to_vec()) } /// Custom decoder for the `raw_id` data-driver function. #[contract(decode_output = "raw_id")] - fn decode_raw_id(bytes: &[u8]) -> Result { + fn decode_raw_id(bytes: &[u8]) -> Result { if bytes.len() != 8 { - return Err(dusk_data_driver::Error::Unsupported(alloc::format!( + return Err(Error::Unsupported(alloc::format!( "expected 8 bytes, got {}", bytes.len() )));