diff --git a/Cargo.lock b/Cargo.lock index 6d779b8..532ac42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -449,6 +449,14 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "leaf_provider" +version = "0.0.0" +dependencies = [ + "magic_map", + "strum", +] + [[package]] name = "libc" version = "0.2.186" @@ -472,6 +480,7 @@ name = "magic_map" version = "0.3.0" dependencies = [ "chrono", + "leaf_provider", "magic_map", "magic_map_macros", "rust_decimal", diff --git a/Cargo.toml b/Cargo.toml index f64ef47..2460381 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["magic_map", "magic_map_macros"] +members = ["magic_map", "magic_map_macros", "leaf_provider"] # `compat/*` are standalone feature-configuration guards built directly in CI, # kept out of the workspace so feature unification doesn't enable `validate` on # them (which would defeat their purpose). diff --git a/README.md b/README.md index ffa97c4..31e8f7e 100644 --- a/README.md +++ b/README.md @@ -267,46 +267,63 @@ This is what lets mappers live in a crate that owns neither side — a services layer between a `*_db` crate and a `*_dtos` crate, with no dependency edge between the two. -#### Leaves need no declaration +#### Reaching your leaves -Nothing to configure — not the built-in leaves, not your `map_identity!` / -`map_display!` / `map_parse!` declarations, not a `MapFrom` impl you wrote by -hand, not a generic wrapper like a `Patch` update field. Declaring a mapping -is the only way anything is ever registered. +Built-in leaves — primitives, `String`, `Uuid`, `chrono`, `Decimal`, +`serde_json::Value`, the integer widenings — are always present. -That falls out of how the tiers are built. Field resolution probes two of them -by autoref, and the fn form emits **concrete** tier-1 impls — one per declared -pair. A leaf pair therefore has no tier-1 candidate at all, so probing derefs -straight to the tier-2 blanket over `MapFrom` and finds it there. - -The obvious alternative does not work, which is worth knowing if you ever touch -this code. A *blanket* tier 1 — +Your own come from the crates that own them. A crate declares its leaves once, +in its root, with `magic_map_leaves!`: ```rust -impl> ProbeLocal for &mut &mut MapProbe { .. } +// leaf-owning crate, e.g. your db crate +magic_map::magic_map_leaves! { + identity: [crate::enums::Species], + display: [crate::enums::Species], + parse: [crate::enums::Species], + // A pair whose impl you wrote by hand. The impl stays where it is; only + // the pair is registered, because a macro cannot see an impl. + custom: [crate::wire::Fahrenheit => String], +} ``` -— matches every pair structurally, so the compiler commits to it and then -reports the unsatisfied bound rather than falling through: +and every consumer names the **crate**, never a type: -```text -error[E0277]: the trait bound `u16: LocalMapFrom` is not satisfied +```rust +magic_map::magic_map_scope!(from: [my_db, my_commons]); ``` -Method probing selects a candidate by receiver shape and does **not** retry a -lower tier when a where-bound fails. Concrete impls are what make the fallback -real. - -#### Why the trait has to be local +Add an enum to that block and it reaches every consumer with no edit on their +side. Write your own types as `crate::…` — those paths are republished as +`$crate::` so a consumer resolves them against the declaring crate; anything +else (`String`, `::chrono::DateTime<..>`) passes through verbatim. -The shortcut — one blanket forwarding every existing `MapFrom` into the local -trait — is not expressible either: +For a one-off pair whose crate has no block, `leaves:` takes it inline — a bare +type is its identity, `Src => Dest` one direction. A wrapper whose own `MapFrom` +impl is generic goes in `generic_leaves`, `;`-separated so the `where` clause's +commas stay unambiguous: ```rust -// rejected by coherence -impl> LocalMapFrom for D { .. } +magic_map::magic_map_scope! { + from: [my_db], + leaves: [Celsius, Celsius => String], + generic_leaves: { + Patch => Patch where D: ::magic_map::MapFrom; + }, +} ``` +A pair that never arrives fails at the mapping that needed it, naming both +types: ``the trait bound `String: LocalMapFrom` is not satisfied``. + +#### Why the trait is a closed world + +Two shortcuts look obvious and neither is available. + +A blanket bridge forwarding every existing `MapFrom` into the local trait +overlaps the per-pair impls, and coherence cannot rule the overlap out because +either upstream crate could add the conflicting impl later: + ```text error[E0119]: conflicting implementations of trait `LocalMapFrom
` for type `AddressResponse` @@ -314,9 +331,18 @@ error[E0119]: conflicting implementations of trait `LocalMapFrom
` `MapFrom
` for type `AddressResponse` in future versions ``` -It overlaps the per-pair impls and the compiler cannot rule that out, because -either upstream crate could add the impl later. Hence a local trait carrying -only declared pairs, and the probe reaching `MapFrom` for everything else. +Nor can a second, `MapFrom`-backed tier sit underneath to catch leaves. +Autoref tiering needs the tiers told apart by receiver *shape*, as +`MapFieldOpt`/`MapFieldVal`/`MapFieldWrap` are; a tier separated only by a +where-bound hard-errors rather than falling through. And a concrete per-pair +tier is worse than useless: with the destination type still open, probing +matches on the source alone and unifies the destination to whatever that impl +produces — so a type with both a declared mapping and a leaf (an enum with a DTO +twin *and* a `map_display!` to `String`) silently resolves to the wrong one, and +the expected type does not override it. + +Hence one funnel, leaves delegated in, and `magic_map_leaves!` so that no +consumer ever transcribes them. ### `let` preludes @@ -669,9 +695,9 @@ impl magic_map::MapFrom for chrono::DateTime { } ``` -These automap everywhere — impl form and fn form alike. A fn-form crate needs -[`magic_map_scope!()`](#magic_map_scope--the-fn-forms-crate-local-funnel) in -its root, but nothing about your leaves has to be repeated there. +These automap everywhere the impl form is used. To reach them from a **fn-form** +mapping too, declare them with [`magic_map_leaves!`](#reaching-your-leaves) +instead — same impls, plus the published list a consumer's scope replays. [strum]: https://crates.io/crates/strum @@ -708,14 +734,11 @@ decision for the handler that owns the batch, not for the conversion. crate-root export — rename one or use `#[magic_map(export = "...")]`. - Destination types must have named fields (or unit variants); tuple structs are not supported as destinations. -- The fn form needs [`magic_map_scope!()`](#magic_map_scope--the-fn-forms-crate-local-funnel) - in the crate root — once, no arguments. Coherence allows no automatic bridge - from `MapFrom`, so the local trait cannot simply be derived; see that section - for the compiler's own reasoning. -- A declared pair nested inside a *custom generic wrapper* (`Patch
` → - `Patch`, as opposed to `Patch` → `Patch`) - has no tier-1 candidate; give that field an explicit override. Wrappers over - leaves, and `Option`/`Vec` over declared pairs, are handled. +- The fn form needs [`magic_map_scope!`](#magic_map_scope--the-fn-forms-crate-local-funnel) + in the crate root, naming the crates whose leaves it uses. Coherence allows no + automatic bridge from `MapFrom` — see that section for the compiler's own + reasoning — so leaves are delegated in, and `magic_map_leaves!` keeps that from + becoming per-consumer bookkeeping. ## License diff --git a/leaf_provider/Cargo.toml b/leaf_provider/Cargo.toml new file mode 100644 index 0000000..fdab6c6 --- /dev/null +++ b/leaf_provider/Cargo.toml @@ -0,0 +1,13 @@ +# Not published — a dev-dependency of `magic_map` so the cross-crate half of +# `magic_map_leaves!` / `leaves_from` is exercised for real. Declaring the block +# and replaying it inside one crate would prove nothing: the whole point is that +# a consumer resolves the paths, which only a crate boundary can test. +[package] +name = "leaf_provider" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +magic_map = { path = "../magic_map", features = ["full"] } +strum = { version = "0.27", features = ["derive"] } diff --git a/leaf_provider/src/lib.rs b/leaf_provider/src/lib.rs new file mode 100644 index 0000000..b335aca --- /dev/null +++ b/leaf_provider/src/lib.rs @@ -0,0 +1,41 @@ +//! A leaf-owning crate, as a consumer of `magic_map` would write one. + +// One block, in the crate root. Emits the `MapFrom` impls and publishes the +// pair list; nothing downstream restates any of it. +magic_map::magic_map_leaves! { + identity: [crate::enums::Species], + display: [crate::enums::Species], + parse: [crate::enums::Species], + custom: [crate::wire::Fahrenheit => String], +} + +pub mod enums { + #[derive( + Clone, Copy, Debug, PartialEq, strum::Display, strum::EnumString, magic_map::MagicMap, + )] + pub enum Species { + Cat, + Lion, + } +} + +pub mod wire { + #[derive(Clone, Copy, Debug, PartialEq)] + pub struct Fahrenheit(pub i32); + + // Hand-written, so no macro can see it — the pair is registered in the + // block above instead. + impl magic_map::MapFrom for String { + fn map_from(src: Fahrenheit) -> Result { + Ok(format!("{}F", src.0)) + } + } +} + +/// A model carrying both leaves, for a downstream mapper to convert. +#[derive(magic_map::MagicMap)] +pub struct Reading { + pub species: enums::Species, + pub species_label: enums::Species, + pub temp: wire::Fahrenheit, +} diff --git a/magic_map/Cargo.toml b/magic_map/Cargo.toml index 744fcbb..df59053 100644 --- a/magic_map/Cargo.toml +++ b/magic_map/Cargo.toml @@ -28,6 +28,7 @@ validate = ["dep:validator", "magic_map_macros/validate"] full = ["chrono", "decimal", "json", "uuid", "validate"] [dev-dependencies] +leaf_provider = { path = "../leaf_provider" } # Integration tests exercise every leaf; enabling `full` on ourselves keeps # `cargo test` meaningful without --all-features. magic_map = { path = ".", features = ["full"] } diff --git a/magic_map/src/lib.rs b/magic_map/src/lib.rs index 61abebb..f10b731 100644 --- a/magic_map/src/lib.rs +++ b/magic_map/src/lib.rs @@ -82,7 +82,7 @@ use std::error::Error; use std::fmt; -pub use magic_map_macros::{magic_map, MagicMap}; +pub use magic_map_macros::{magic_map, magic_map_leaves, MagicMap}; #[doc(hidden)] pub use magic_map_macros::__magic_map_expand; @@ -447,53 +447,105 @@ macro_rules! map_parse { // had nothing to resolve against: `Vec
` → `Vec` needs // `AddressResponse: MapFrom
`, and that impl cannot exist anywhere. // -// `magic_map_scope!()` plants a trait in the *calling* crate. The orphan rule -// is satisfied by a local trait just as well as by a local type, so +// `magic_map_scope!` plants a trait in the *calling* crate. The orphan rule is +// satisfied by a local trait just as well as by a local type, so // `impl LocalMapFrom
for AddressResponse` is legal there even though -// both types are foreign — and the fn form emits one for every mapping it -// declares. +// both types are foreign, and the fn form emits one per mapping it declares. // -// Leaves need no declaration. Field resolution probes two tiers by autoref: -// the fn form emits CONCRETE tier-1 impls, one per declared pair, so a leaf -// pair has no tier-1 candidate at all and probing derefs to the tier-2 blanket -// over `MapFrom`. That is the whole trick — a *blanket* tier 1 would match -// every pair structurally and then hard-error on its unsatisfied bound instead -// of falling through, which is why the tiers cannot be written the obvious way. -// One consequence worth stating: there is exactly one way to register a -// conversion, which is to declare it. Nothing else needs configuring, ever. +// The trait is a CLOSED WORLD: everything the fn form funnels through has a +// `LocalMapFrom` impl, leaves included. Two dead ends forced that, both worth +// knowing before anyone tries to "simplify" this: +// +// * A blanket bridge `impl> LocalMapFrom for D` overlaps +// the per-pair impls and coherence rejects it — "upstream crates may add a +// new impl of `MapFrom
` for `AddressResponse` in future versions". +// +// * Nor can a second, `MapFrom`-backed tier sit underneath to catch leaves. +// Autoref tiering needs the tiers told apart by receiver SHAPE (as +// `MapFieldOpt`/`MapFieldVal`/`MapFieldWrap` are); a tier separated only by +// a where-bound hard-errors rather than falling through. A concrete +// per-pair tier is worse: with the destination still open, probing matches +// on the source alone and unifies the destination to whatever that impl +// produces, so a source carrying both a declared mapping and a leaf — an +// enum with a DTO twin *and* a `map_display!` to `String` — resolves to the +// wrong one, and the expected type does not override it. +// +// So leaves are delegated in, and `magic_map_leaves!` keeps that from becoming +// per-consumer bookkeeping: a crate declares its leaves once and +// `leaves_from: [that_crate]` replays the list. /// Declares the crate-local mapping funnel that the fn form of `magic_map!` /// resolves nested fields through. Call it **once, in the crate root** -/// (`lib.rs` / `main.rs`) of any crate that declares a fn-form mapping: +/// (`lib.rs` / `main.rs`) of any crate that declares a fn-form mapping. /// /// ```ignore /// // src/lib.rs -/// magic_map::magic_map_scope!(); +/// magic_map::magic_map_scope! { +/// leaves_from: [quickedge_commons, quickedge_db], +/// } /// ``` /// -/// It takes no configuration. Leaves — the built-in ones, your -/// [`map_identity!`] / [`map_display!`] / [`map_parse!`] declarations, and any -/// `MapFrom` impl you wrote by hand — are found without being named. -/// /// Needed only for the fn form. A crate whose mappings are all impl form /// (`magic_map!(Src => Dest)`, where one side is local) never calls it — those -/// funnel through `MapFrom` as they always have. +/// funnel through `MapFrom` as they always have. Missing it reads as +/// ``could not find `__magic_map_scope` in the crate root``. +/// +/// # Reaching your leaves /// -/// Missing it reads as ``could not find `__magic_map_scope` in the crate root`` -/// at the first fn-form mapping. +/// Built-in leaves (primitives, `String`, `Uuid`, `chrono`, `Decimal`, +/// `serde_json::Value`, the integer widenings) are always present. Your own +/// arrive through `leaves_from`, which replays the [`magic_map_leaves!`] block +/// of every crate named — so adding an enum there needs no edit in any +/// consumer. +/// +/// `leaves: [..]` is the escape hatch for a one-off pair whose crate has no +/// block: a bare type is its identity, `Src => Dest` delegates that direction. +/// A generic wrapper goes in `generic_leaves`, `;`-separated so the `where` +/// clause's commas stay unambiguous: +/// +/// ```ignore +/// magic_map::magic_map_scope! { +/// leaves_from: [quickedge_db], +/// leaves: [Celsius, Celsius => String], +/// generic_leaves: { +/// Patch => Patch where D: ::magic_map::MapFrom; +/// }, +/// } +/// ``` +/// +/// A pair that never arrives fails at the mapping that needed it, naming both +/// types: ``the trait bound `String: LocalMapFrom` is not satisfied``. #[macro_export] macro_rules! magic_map_scope { - () => { + () => { $crate::magic_map_scope!(from: [], leaves: [], generic_leaves: {}); }; + (from: [ $($from:ident),* $(,)? ] $(,)?) => { + $crate::magic_map_scope!(from: [ $($from),* ], leaves: [], generic_leaves: {}); + }; + (leaves: [ $($leaf:tt)* ] $(,)?) => { + $crate::magic_map_scope!(from: [], leaves: [ $($leaf)* ], generic_leaves: {}); + }; + (from: [ $($from:ident),* $(,)? ], leaves: [ $($leaf:tt)* ] $(,)?) => { + $crate::magic_map_scope!(from: [ $($from),* ], leaves: [ $($leaf)* ], generic_leaves: {}); + }; + (from: [ $($from:ident),* $(,)? ], generic_leaves: { $($g:tt)* } $(,)?) => { + $crate::magic_map_scope!(from: [ $($from),* ], leaves: [], generic_leaves: { $($g)* }); + }; + ( + from: [ $($from:ident),* $(,)? ], + leaves: [ $($leaf:tt)* ], + generic_leaves: { $($generic:tt)* } $(,)? + ) => { #[doc(hidden)] pub mod __magic_map_scope { //! Generated by `magic_map::magic_map_scope!`. The fn form of - //! `magic_map!` resolves its fields through the traits here. + //! `magic_map!` resolves its fields through `LocalMapFrom` here. #[allow(unused_imports)] use super::*; - /// Crate-local twin of [`magic_map::MapFrom`], implemented by the - /// fn form for pairs the orphan rule keeps off `MapFrom`. + /// Crate-local twin of [`magic_map::MapFrom`]. The fn form + /// implements it for pairs the orphan rule keeps off `MapFrom`; + /// leaves are delegated in below. pub trait LocalMapFrom: Sized { fn local_map_from(src: S) -> ::core::result::Result; } @@ -505,9 +557,11 @@ macro_rules! magic_map_scope { src: ::core::option::Option, ) -> ::core::result::Result { match src { - ::core::option::Option::Some(s) => ::core::result::Result::Ok( - ::core::option::Option::Some(D::local_map_from(s)?), - ), + ::core::option::Option::Some(s) => { + ::core::result::Result::Ok(::core::option::Option::Some( + D::local_map_from(s)?, + )) + } ::core::option::Option::None => { ::core::result::Result::Ok(::core::option::Option::None) } @@ -523,54 +577,23 @@ macro_rules! magic_map_scope { } } - // ── Plain field resolution: two tiers ─────────────────────────── - // - // Tier 1 is populated only by the concrete impls the fn form emits - // per declared pair (see `__magic_map_declare_local!`). A leaf has - // no candidate here, so probing derefs to tier 2. - - pub trait ProbeLocal { - fn magic_probe(self) -> ::core::result::Result; - } - - /// Tier 2 — every conversion that already has a `MapFrom` impl: - /// built-in leaves, your own leaf declarations, hand-written impls, - /// and the `Option`/`Vec` blankets over them. - pub trait ProbeGlobal { - fn magic_probe(self) -> ::core::result::Result; - } - impl> ProbeGlobal for &mut $crate::MapProbe { - fn magic_probe(self) -> ::core::result::Result { - D::map_from(self.0.take().expect("magic_map field consumed twice")) - } - } - - // ── `..Default::default()` field resolution: six tiers ────────── - // - // The three shapes of the `MapFrom` version, doubled: local first, - // then global. All six share a method name and are told apart by - // autoref depth, so the local ones win where they have a candidate - // and leaves fall straight through to the global three. + $crate::__magic_map_scope_leaves!(); + $($from::__magic_map_leaves!();)* + $crate::__magic_map_scope_extra_leaves!( $($leaf)* ); + $crate::__magic_map_scope_generic_leaves!( $($generic)* ); + // `..Default::default()` field machinery over the local trait. + // Three autoref tiers told apart by receiver shape, mirroring + // `magic_map::MapPair`'s. pub trait LocalFieldOpt { - fn magic_field(self) -> ::core::result::Result; - } - pub trait LocalFieldVal { - fn magic_field(self) -> ::core::result::Result; + fn local_map_field_or(self) -> ::core::result::Result; } - pub trait LocalFieldWrap { - fn magic_field(self) -> ::core::result::Result; - } - - pub trait GlobalFieldOpt { - fn magic_field(self) -> ::core::result::Result; - } - impl> GlobalFieldOpt + impl> LocalFieldOpt for &mut &mut &mut $crate::MapPair<::core::option::Option, D> { - fn magic_field(self) -> ::core::result::Result { + fn local_map_field_or(self) -> ::core::result::Result { match self.0.take().expect("magic_map field consumed twice") { - ::core::option::Option::Some(s) => D::map_from(s), + ::core::option::Option::Some(s) => D::local_map_from(s), ::core::option::Option::None => ::core::result::Result::Ok( self.1.take().expect("magic_map fallback consumed twice"), ), @@ -578,129 +601,253 @@ macro_rules! magic_map_scope { } } - pub trait GlobalFieldVal { - fn magic_field(self) -> ::core::result::Result; + pub trait LocalFieldVal { + fn local_map_field_or(self) -> ::core::result::Result; } - impl> GlobalFieldVal for &mut &mut $crate::MapPair { - fn magic_field(self) -> ::core::result::Result { - D::map_from(self.0.take().expect("magic_map field consumed twice")) + impl> LocalFieldVal for &mut &mut $crate::MapPair { + fn local_map_field_or(self) -> ::core::result::Result { + D::local_map_from(self.0.take().expect("magic_map field consumed twice")) } } - pub trait GlobalFieldWrap { - fn magic_field(self) -> ::core::result::Result; + pub trait LocalFieldWrap { + fn local_map_field_or(self) -> ::core::result::Result; } - impl> GlobalFieldWrap<::core::option::Option> + impl> LocalFieldWrap<::core::option::Option> for &mut $crate::MapPair> { - fn magic_field( + fn local_map_field_or( self, ) -> ::core::result::Result<::core::option::Option, $crate::MappingError> { let src = self.0.take().expect("magic_map field consumed twice"); - ::core::result::Result::Ok(::core::option::Option::Some(U::map_from(src)?)) + ::core::result::Result::Ok(::core::option::Option::Some(U::local_map_from( + src, + )?)) } } } }; } -/// Registers one declared pair in the crate-local funnel. Emitted by the fn -/// form of `magic_map!` — the concrete tier-1 impls that let a leaf fall -/// through to `MapFrom` while a declared pair does not. +/// Delegates one `MapFrom` pair into the local trait. Used by +/// `magic_map_scope!` for both the built-in leaves and the `leaves: [...]` +/// list; the body is a plain call, so a missing `MapFrom` impl fails here and +/// names the pair. #[doc(hidden)] #[macro_export] -macro_rules! __magic_map_declare_local { - // `$scope` is the caller's `crate::__magic_map_scope`, passed in by the - // proc macro rather than spelled `crate::` here — inside a macro_rules - // definition that would read as this crate, which is not what is meant. - ($scope:path, $fn_name:ident, $src:ty, $dest:ty) => { - const _: () = { - use $scope::*; - - impl LocalMapFrom<$src> for $dest { - fn local_map_from(src: $src) -> ::core::result::Result { - $fn_name(src) - } +macro_rules! __magic_map_scope_delegate { + ($src:ty => $dest:ty) => { + impl LocalMapFrom<$src> for $dest { + fn local_map_from(src: $src) -> ::core::result::Result { + <$dest as $crate::MapFrom<$src>>::map_from(src) } + } + }; +} - // Plain resolution, and the two wrappers a DTO field actually uses. - impl ProbeLocal<$dest> for &mut &mut $crate::MapProbe<$src, $dest> { - fn magic_probe(self) -> ::core::result::Result<$dest, $crate::MappingError> { - $fn_name(self.0.take().expect("magic_map field consumed twice")) - } - } - impl ProbeLocal<::std::vec::Vec<$dest>> - for &mut &mut $crate::MapProbe<::std::vec::Vec<$src>, ::std::vec::Vec<$dest>> - { - fn magic_probe( - self, - ) -> ::core::result::Result<::std::vec::Vec<$dest>, $crate::MappingError> { - <::std::vec::Vec<$dest> as LocalMapFrom<::std::vec::Vec<$src>>>::local_map_from( - self.0.take().expect("magic_map field consumed twice"), - ) - } - } - impl ProbeLocal<::core::option::Option<$dest>> - for &mut &mut $crate::MapProbe< - ::core::option::Option<$src>, - ::core::option::Option<$dest>, - > - { - fn magic_probe( - self, - ) -> ::core::result::Result<::core::option::Option<$dest>, $crate::MappingError> - { - <::core::option::Option<$dest> as LocalMapFrom< - ::core::option::Option<$src>, - >>::local_map_from( - self.0.take().expect("magic_map field consumed twice") - ) - } - } +/// The `leaves: [...]` entries. A bare type is its identity. +#[doc(hidden)] +#[macro_export] +macro_rules! __magic_map_scope_extra_leaves { + () => {}; + ($src:ty => $dest:ty, $($rest:tt)*) => { + $crate::__magic_map_scope_delegate!($src => $dest); + $crate::__magic_map_scope_extra_leaves!($($rest)*); + }; + ($src:ty => $dest:ty $(,)?) => { + $crate::__magic_map_scope_delegate!($src => $dest); + }; + ($t:ty, $($rest:tt)*) => { + $crate::__magic_map_scope_delegate!($t => $t); + $crate::__magic_map_scope_extra_leaves!($($rest)*); + }; + ($t:ty $(,)?) => { + $crate::__magic_map_scope_delegate!($t => $t); + }; +} - // `..Default::default()` shapes. - impl LocalFieldOpt<$dest> - for &mut &mut &mut &mut &mut &mut $crate::MapPair< - ::core::option::Option<$src>, - $dest, - > - { - fn magic_field(self) -> ::core::result::Result<$dest, $crate::MappingError> { - match self.0.take().expect("magic_map field consumed twice") { - ::core::option::Option::Some(s) => $fn_name(s), - ::core::option::Option::None => ::core::result::Result::Ok( - self.1.take().expect("magic_map fallback consumed twice"), - ), - } - } - } - impl LocalFieldVal<$dest> for &mut &mut &mut &mut &mut $crate::MapPair<$src, $dest> { - fn magic_field(self) -> ::core::result::Result<$dest, $crate::MappingError> { - $fn_name(self.0.take().expect("magic_map field consumed twice")) - } +/// `generic_leaves { .. }` entries — one delegating impl per `;`-separated +/// declaration, generics and bounds passed through verbatim. +#[doc(hidden)] +#[macro_export] +macro_rules! __magic_map_scope_generic_leaves { + () => {}; + ( + < $($gen:tt),* $(,)? > $src:ty => $dest:ty where $($bound:tt)* + ) => { + $crate::__magic_map_scope_generic_one!( < $($gen),* > $src => $dest where $($bound)* ); + }; + ( + < $($gen:tt),* $(,)? > $src:ty => $dest:ty ; $($rest:tt)* + ) => { + impl< $($gen),* > LocalMapFrom<$src> for $dest { + fn local_map_from(src: $src) -> ::core::result::Result { + <$dest as $crate::MapFrom<$src>>::map_from(src) } - impl LocalFieldWrap<::core::option::Option<$dest>> - for &mut &mut &mut &mut $crate::MapPair<$src, ::core::option::Option<$dest>> - { - fn magic_field( - self, - ) -> ::core::result::Result<::core::option::Option<$dest>, $crate::MappingError> - { - let src = self.0.take().expect("magic_map field consumed twice"); - ::core::result::Result::Ok(::core::option::Option::Some($fn_name(src)?)) - } + } + $crate::__magic_map_scope_generic_leaves!( $($rest)* ); + }; +} + +/// Terminal arm: splits a `where` clause off at its trailing `;`. +#[doc(hidden)] +#[macro_export] +macro_rules! __magic_map_scope_generic_one { + ( + < $($gen:tt),* > $src:ty => $dest:ty where $($bound:tt)* + ) => { + $crate::__magic_map_scope_generic_split!( + [ $($gen),* ] [ $src ] [ $dest ] [] $($bound)* + ); + }; +} + +/// Walks the `where` clause token by token until the `;` that ends this +/// declaration, then emits the impl and recurses on what follows. +#[doc(hidden)] +#[macro_export] +macro_rules! __magic_map_scope_generic_split { + ( [ $($gen:tt),* ] [ $src:ty ] [ $dest:ty ] [ $($bound:tt)* ] ; $($rest:tt)* ) => { + impl< $($gen),* > LocalMapFrom<$src> for $dest where $($bound)* { + fn local_map_from(src: $src) -> ::core::result::Result { + <$dest as $crate::MapFrom<$src>>::map_from(src) } - }; + } + $crate::__magic_map_scope_generic_leaves!( $($rest)* ); + }; + ( [ $($gen:tt),* ] [ $src:ty ] [ $dest:ty ] [ $($bound:tt)* ] $next:tt $($rest:tt)* ) => { + $crate::__magic_map_scope_generic_split!( + [ $($gen),* ] [ $src ] [ $dest ] [ $($bound)* $next ] $($rest)* + ); + }; +} + +/// The built-in leaf set, delegated into a scope's local trait. Mirrors the +/// `leaf_identity!` / `leaf_widen!` / `*_leaves` impls above one for one — +/// `scope_covers_every_builtin_leaf` in the test suite fails if the two drift. +/// +/// The feature-gated groups are separate macros rather than `#[cfg]` arms in +/// the expansion: an emitted `#[cfg(feature = "uuid")]` is evaluated against +/// the *calling* crate's features, which has no `uuid` feature and would drop +/// every Uuid leaf on the floor. Defining each group's macro under the cfg +/// here evaluates it against this crate's features, where it means something. +#[doc(hidden)] +#[macro_export] +macro_rules! __magic_map_scope_leaves { + () => { + $crate::__magic_map_scope_extra_leaves!( + bool, char, i8, i16, i32, i64, i128, isize, + u8, u16, u32, u64, u128, usize, f32, f64, + ::std::string::String, + ); + // Lossless widenings. + $crate::__magic_map_scope_extra_leaves!( + u8 => u16, u8 => u32, u8 => u64, u8 => i16, u8 => i32, u8 => i64, + u16 => u32, u16 => u64, u16 => i32, u16 => i64, + u32 => u64, u32 => i64, + i8 => i16, i8 => i32, i8 => i64, + i16 => i32, i16 => i64, + i32 => i64, + f32 => f64, + ); + $crate::__magic_map_scope_uuid_leaves!(); + $crate::__magic_map_scope_decimal_leaves!(); + $crate::__magic_map_scope_chrono_leaves!(); + $crate::__magic_map_scope_json_leaves!(); }; } +#[cfg(feature = "uuid")] #[doc(hidden)] -pub struct MapProbe(pub Option, pub core::marker::PhantomData); +#[macro_export] +macro_rules! __magic_map_scope_uuid_leaves { + () => { + $crate::__magic_map_scope_extra_leaves!( + ::uuid::Uuid, + ::std::string::String => ::uuid::Uuid, + ::uuid::Uuid => ::std::string::String, + ); + }; +} +#[cfg(not(feature = "uuid"))] +#[doc(hidden)] +#[macro_export] +macro_rules! __magic_map_scope_uuid_leaves { + () => {}; +} -impl MapProbe { - #[doc(hidden)] - pub fn new(src: S) -> Self { - MapProbe(Some(src), core::marker::PhantomData) - } +#[cfg(feature = "decimal")] +#[doc(hidden)] +#[macro_export] +macro_rules! __magic_map_scope_decimal_leaves { + () => { + $crate::__magic_map_scope_extra_leaves!( + ::rust_decimal::Decimal, + ::rust_decimal::Decimal => f64, + f64 => ::rust_decimal::Decimal, + ::std::string::String => ::rust_decimal::Decimal, + ::rust_decimal::Decimal => ::std::string::String, + ); + }; +} +#[cfg(not(feature = "decimal"))] +#[doc(hidden)] +#[macro_export] +macro_rules! __magic_map_scope_decimal_leaves { + () => {}; +} + +#[cfg(feature = "chrono")] +#[doc(hidden)] +#[macro_export] +macro_rules! __magic_map_scope_chrono_leaves { + () => { + $crate::__magic_map_scope_extra_leaves!( + ::chrono::DateTime<::chrono::Utc>, + ::chrono::NaiveDate, + ::chrono::NaiveDateTime, + ::chrono::NaiveTime, + ::std::string::String => ::chrono::DateTime<::chrono::Utc>, + ::chrono::DateTime<::chrono::Utc> => ::std::string::String, + ::std::string::String => ::chrono::NaiveDate, + ::chrono::NaiveDate => ::std::string::String, + ); + }; +} +#[cfg(not(feature = "chrono"))] +#[doc(hidden)] +#[macro_export] +macro_rules! __magic_map_scope_chrono_leaves { + () => {}; +} + +#[cfg(feature = "json")] +#[doc(hidden)] +#[macro_export] +macro_rules! __magic_map_scope_json_leaves { + () => { + $crate::__magic_map_scope_extra_leaves!(::serde_json::Value); + }; +} +#[cfg(not(feature = "json"))] +#[doc(hidden)] +#[macro_export] +macro_rules! __magic_map_scope_json_leaves { + () => {}; +} + +/// One `LocalMapFrom` impl delegating to an existing `MapFrom` pair. Emitted by +/// a crate's replayed leaf list and by `magic_map_scope!`'s own `leaves`; the +/// bare `LocalMapFrom` binds to whichever scope module it lands in. +#[doc(hidden)] +#[macro_export] +macro_rules! __magic_map_leaf_impl { + ($src:ty => $dest:ty) => { + impl LocalMapFrom<$src> for $dest { + fn local_map_from(src: $src) -> ::core::result::Result { + <$dest as $crate::MapFrom<$src>>::map_from(src) + } + } + }; } diff --git a/magic_map/tests/magic_map.rs b/magic_map/tests/magic_map.rs index 9339efb..705e43f 100644 --- a/magic_map/tests/magic_map.rs +++ b/magic_map/tests/magic_map.rs @@ -5,7 +5,17 @@ // Integration tests are their own crate, so the scope lives here rather than // in a lib.rs — same rule: once, at the crate root. -magic_map::magic_map_scope!(); +magic_map::magic_map_scope! { + // Everything `leaf_provider` declares, replayed without restating a type. + from: [leaf_provider], + // Escape hatches: a one-off pair whose crate has no block, and a generic + // wrapper whose own MapFrom impl is generic. + leaves: [custom_leaf::Celsius, custom_leaf::Celsius => String, + shared_source::db::Kind => String], + generic_leaves: { + custom_leaf::Wrap => custom_leaf::Wrap where D: magic_map::MapFrom; + }, +} use magic_map::magic_map; use magic_map::{MapFrom, MapInto, MappingError}; use rust_decimal::Decimal; @@ -856,94 +866,70 @@ fn fn_form_none_stays_none() { assert!(dto.neighborhoods.is_empty()); } -/// Leaves are found without being declared. Tier 1 of the probe holds only -/// the concrete pairs the fn form emitted, so none of these has a candidate -/// there and every one falls through to the `MapFrom` blanket. A regression -/// that made tier 1 blanket-shaped would match these structurally and fail on -/// the bound instead — which is exactly the bug this guards. +/// Regression guard for the bug that killed the two-tier probe design. +/// +/// `ApplicationType` here stands for any enum with BOTH a declared +/// foreign→foreign mapping (to its DTO twin) and a leaf conversion to a +/// different destination (`map_display!` to `String`, for a proto). Under a +/// probe keyed on the source type alone, the declared pair won and the field +/// silently resolved to the wrong destination — `expected String, found +/// ApplicationTypeDto`. One funnel cannot make that mistake: each pair is a +/// distinct `LocalMapFrom` impl. #[test] -fn leaves_resolve_through_the_probe_without_registration() { - use __magic_map_scope::{ProbeGlobal as _, ProbeLocal as _}; - use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc}; - - macro_rules! probe { - ($src:expr, $ty:ty) => {{ - let out: $ty = (&mut &mut magic_map::MapProbe::new($src)) - .magic_probe() - .expect("leaf must resolve without registration"); - out - }}; - } - - probe!(true, bool); - probe!('x', char); - probe!(1i8, i8); - probe!(1i16, i16); - probe!(1i32, i32); - probe!(1i64, i64); - probe!(1i128, i128); - probe!(1isize, isize); - probe!(1u8, u8); - probe!(1u16, u16); - probe!(1u32, u32); - probe!(1u64, u64); - probe!(1u128, u128); - probe!(1usize, usize); - probe!(1f32, f32); - probe!(1f64, f64); - probe!(String::from("s"), String); - - // widenings - probe!(1u8, u16); - probe!(1i32, i64); - probe!(1f32, f64); - - // feature leaves - let id = Uuid::from_u128(0x0192_3f4b_5c6d_7e8f_9012_3456_789a_bcde); - probe!(id, Uuid); - probe!(id, String); - probe!(id.to_string(), Uuid); - - let d = Decimal::new(1995, 2); - probe!(d, Decimal); - probe!(d, f64); - probe!(d, String); - probe!(String::from("19.95"), Decimal); - probe!(19.95f64, Decimal); - - let now: DateTime = Utc::now(); - probe!(now, DateTime); - probe!(now, String); - probe!(now.to_rfc3339(), DateTime); - let day = NaiveDate::from_ymd_opt(2026, 8, 9).unwrap(); - probe!(day, NaiveDate); - probe!(day, String); - probe!(day.to_string(), NaiveDate); - probe!(now.naive_utc(), NaiveDateTime); - probe!(now.time(), NaiveTime); - - probe!(serde_json::Value::Null, serde_json::Value); - - // wrappers over leaves, still tier 2 - probe!(vec![1i32, 2i32], Vec); - probe!(Some(id), Option); - - // …and a declared pair resolves through tier 1 in the very same scope, so - // the fallthrough above is a real fallthrough, not tier 1 being absent. - let state: scoped::dtos::StateResponse = - (&mut &mut magic_map::MapProbe::new(scoped::db::State { - name: "Yucatán".into(), - })) - .magic_probe() - .expect("declared pair must resolve through tier 1"); - assert_eq!(state.name, "Yucatán"); +fn shared_source_reaches_both_its_declared_pair_and_its_leaf() { + use shared_source::{db, dtos, mappers}; + + let dto = mappers::row_to_dto(db::Row { + kind: db::Kind::Mobile, + label: db::Kind::Mobile, + }) + .unwrap(); + // same source type, two destinations, both correct + assert_eq!(dto.kind, dtos::KindDto::Mobile); + assert_eq!(dto.label, "Mobile"); +} + +mod shared_source { + pub mod db { + #[derive(Clone, Copy, PartialEq, strum::Display, magic_map::MagicMap)] + pub enum Kind { + Mobile, + Fixed, + } + magic_map::map_display!(Kind); + + #[derive(magic_map::MagicMap)] + #[magic_map(export = "SharedRow")] + pub struct Row { + pub kind: Kind, + pub label: Kind, + } + } + pub mod dtos { + #[derive(Debug, Clone, Copy, PartialEq, magic_map::MagicMap)] + pub enum KindDto { + Mobile, + Fixed, + } + #[derive(magic_map::MagicMap)] + pub struct RowResponse { + pub kind: KindDto, + pub label: String, + } + } + pub mod mappers { + use super::{db, dtos}; + use magic_map::magic_map; + magic_map!(pub fn kind_to_dto: db::Kind => dtos::KindDto); + magic_map!(pub fn row_to_dto: db::Row => dtos::RowResponse); + } } // ── custom leaves ─────────────────────────────────────────────────────────── mod custom_leaf { - /// A leaf declared the normal way, in the crate that owns it: `MapFrom` - /// impls the local trait cannot see until `leaves: [...]` names them. + /// A leaf declared the normal way, in the crate that owns it — reached + /// through the scope's `leaves:` escape hatch rather than the registry. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Celsius(pub i32); @@ -1002,3 +988,34 @@ fn custom_and_generic_leaves_need_no_declaration() { assert_eq!(dto.temp, "21C"); assert_eq!(dto.note, custom_leaf::Wrap(7i64)); } + +/// The cross-crate half of the registry: `leaf_provider` declared these leaves +/// in its own crate root and this crate names only the crate, never a type. +/// Adding a leaf there must reach here with no edit in this file — which is the +/// entire reason the registry exists. +#[test] +fn leaves_arrive_from_a_provider_crate() { + mod dtos { + #[derive(Debug, PartialEq, magic_map::MagicMap)] + #[magic_map(export = "ProviderReadingResponse")] + pub struct ReadingResponse { + pub species: leaf_provider::enums::Species, // identity + pub species_label: String, // display + pub temp: String, // hand-written MapFrom + } + } + + magic_map::magic_map!(pub fn reading_to_dto: + leaf_provider::Reading => dtos::ReadingResponse); + + let dto = reading_to_dto(leaf_provider::Reading { + species: leaf_provider::enums::Species::Lion, + species_label: leaf_provider::enums::Species::Lion, + temp: leaf_provider::wire::Fahrenheit(72), + }) + .unwrap(); + + assert_eq!(dto.species, leaf_provider::enums::Species::Lion); + assert_eq!(dto.species_label, "Lion"); + assert_eq!(dto.temp, "72F"); +} diff --git a/magic_map/tests/readme.rs b/magic_map/tests/readme.rs index 19f3341..c052912 100644 --- a/magic_map/tests/readme.rs +++ b/magic_map/tests/readme.rs @@ -9,7 +9,7 @@ // The fn-form examples below need the crate-local funnel; see the README's // `magic_map_scope!` section. Once, at the crate root, no arguments. -magic_map::magic_map_scope!(); +magic_map::magic_map_scope!(from: [leaf_provider]); use magic_map::{MapInto, MappingError}; diff --git a/magic_map_macros/src/lib.rs b/magic_map_macros/src/lib.rs index 28f1094..504bbd8 100644 --- a/magic_map_macros/src/lib.rs +++ b/magic_map_macros/src/lib.rs @@ -100,3 +100,31 @@ pub fn __magic_map_expand(input: TokenStream) -> TokenStream { magic_map::expand(raw, parse_macro_input!(input as magic_map::ExpandInput)) .unwrap_or_else(|e| e.to_compile_error().into()) } + +/// `magic_map_leaves!` — declare a crate's leaf conversions once, and publish +/// the list so consumers never restate it. +/// +/// Call it once, in your crate root. It emits the `MapFrom` impls (the same +/// ones `map_identity!` / `map_display!` / `map_parse!` produce) and a hidden +/// macro that `magic_map_scope!`'s `from:` replays — so adding a type here +/// reaches every downstream crate with no edit on their side. +/// +/// ```ignore +/// magic_map::magic_map_leaves! { +/// identity: [crate::enums::Species], +/// display: [crate::enums::Species], +/// parse: [crate::enums::Species], +/// // A pair whose impl you wrote by hand. The impl stays where it is; +/// // only the pair is registered, because a macro cannot see an impl. +/// custom: [crate::wire::Fahrenheit => String], +/// } +/// ``` +/// +/// Write your own types as `crate::…`. Those paths are rewritten to `$crate::` +/// in the published list, so a consumer resolves them against *this* crate. +/// Anything else (`String`, `::chrono::DateTime<..>`) passes through verbatim. +#[proc_macro] +pub fn magic_map_leaves(input: TokenStream) -> TokenStream { + magic_map::leaves(parse_macro_input!(input as magic_map::LeavesInput)) + .unwrap_or_else(|e| e.to_compile_error().into()) +} diff --git a/magic_map_macros/src/magic_map.rs b/magic_map_macros/src/magic_map.rs index e8e1489..6866aa2 100644 --- a/magic_map_macros/src/magic_map.rs +++ b/magic_map_macros/src/magic_map.rs @@ -639,41 +639,38 @@ pub fn expand(raw: TokenStream2, input: ExpandInput) -> Result Result for #dest { + fn local_map_from( + src: #src, + ) -> ::core::result::Result { + #name(src) + } + } }, None => quote! { impl ::magic_map::MapFrom<#src> for #dest { @@ -725,3 +726,150 @@ pub fn expand(raw: TokenStream2, input: ExpandInput) -> Result, + display: Vec, + parse: Vec, + custom: Vec<(syn::Type, syn::Type)>, +} + +impl Parse for LeavesInput { + fn parse(input: ParseStream) -> syn::Result { + let (mut identity, mut display, mut parse, mut custom) = + (Vec::new(), Vec::new(), Vec::new(), Vec::new()); + while !input.is_empty() { + let key: Ident = input.parse()?; + input.parse::()?; + let content; + syn::bracketed!(content in input); + match key.to_string().as_str() { + "identity" | "display" | "parse" => { + let paths = Punctuated::::parse_terminated(&content)?; + let target = match key.to_string().as_str() { + "identity" => &mut identity, + "display" => &mut display, + _ => &mut parse, + }; + target.extend(paths); + } + "custom" => { + while !content.is_empty() { + let src: syn::Type = content.parse()?; + content.parse::]>()?; + let dest: syn::Type = content.parse()?; + custom.push((src, dest)); + if content.peek(Token![,]) { + content.parse::()?; + } + } + } + other => { + return Err(syn::Error::new( + key.span(), + format!( + "`{other}` is not a leaf kind — expected \ + `identity`, `display`, `parse` or `custom`" + ), + )) + } + } + if input.peek(Token![,]) { + input.parse::()?; + } + } + Ok(Self { + identity, + display, + parse, + custom, + }) + } +} + +/// Rewrites a leading `crate::` to `$crate::` so the published pair resolves +/// against the declaring crate rather than whoever replays it. Everything else +/// is left alone. +fn republish(tokens: TokenStream2) -> TokenStream2 { + let text = tokens.to_string(); + if let Some(rest) = text.strip_prefix("crate ::") { + let rest: TokenStream2 = rest.parse().unwrap_or_default(); + quote! { $crate::#rest } + } else { + tokens + } +} + +pub fn leaves(input: LeavesInput) -> Result { + let LeavesInput { + identity, + display, + parse, + custom, + } = input; + + // Impls land in this crate, where the paths are written as the user wrote + // them — `crate::` means this crate here, which is correct. + let impls = quote! { + #(::magic_map::map_identity!(#identity);)* + #(::magic_map::map_display!(#display);)* + #(::magic_map::map_parse!(#parse);)* + }; + + // The published list is expanded in *another* crate, so own-crate paths + // have to travel as `$crate::`. + let mut pairs: Vec<(TokenStream2, TokenStream2)> = Vec::new(); + for p in &identity { + let t = republish(quote! { #p }); + pairs.push((t.clone(), t)); + } + for p in &display { + pairs.push((republish(quote! { #p }), quote! { ::std::string::String })); + } + for p in &parse { + pairs.push((quote! { ::std::string::String }, republish(quote! { #p }))); + } + for (src, dest) in &custom { + pairs.push((republish(quote! { #src }), republish(quote! { #dest }))); + } + + let replays = pairs.iter().map(|(src, dest)| { + quote! { + impl LocalMapFrom<#src> for #dest { + fn local_map_from( + src: #src, + ) -> ::core::result::Result { + <#dest as ::magic_map::MapFrom<#src>>::map_from(src) + } + } + } + }); + + Ok(quote! { + #impls + + /// This crate's leaf pairs, as `LocalMapFrom` impls. Expanded by + /// `magic_map_scope!`'s `from:` inside the scope module, where + /// `LocalMapFrom` is in scope — `macro_rules!` is not hygienic for item + /// names, so the bare trait name binds at the expansion site. + #[doc(hidden)] + #[macro_export] + macro_rules! __magic_map_leaves { + () => { #(#replays)* }; + } + } + .into()) +}