From 1d24f22bd0d7647f021eb0ceb65e8fec4b392176 Mon Sep 17 00:00:00 2001 From: Soares Chen Date: Tue, 6 Oct 2026 15:10:44 +0200 Subject: [PATCH 1/4] Add `Struct!` and `Enum!` type-level shape macros MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Struct!` and `Enum!` build the `HasFields` shape of a struct or enum from the body of its declaration. Each parses its body into the `syn` types the `#[derive(HasFields)]` encoder reads and runs that encoder unchanged, so `Struct! { … }` is by construction the type a derived `Fields` is. The `Struct!` form (named or positional) is read from the entries, since a proc macro never sees its invocation delimiter. Attributes, visibility, a `_` field name, duplicate names, discriminants, and a literal in a type position are rejected at parse time. `reject_non_empty_attributes` moves to `functions/` now that the shape macros share it with `delegate_components!`, and the `Symbol!` doc comment shows the current `Symbol>` expansion. Co-Authored-By: Claude Opus 5.5 --- .../cgp-macro-core/src/functions/mod.rs | 4 + .../src/functions/reject_attributes.rs | 19 ++++ .../cgp-macro-core/src/functions/shape/mod.rs | 5 + .../src/functions/shape/parse_fields.rs | 99 +++++++++++++++++++ .../src/functions/shape/validate_fields.rs | 47 +++++++++ .../delegate_component/validate_attributes.rs | 22 +---- crates/macros/cgp-macro-core/src/types/mod.rs | 1 + .../src/types/shape/enum_type.rs | 99 +++++++++++++++++++ .../cgp-macro-core/src/types/shape/mod.rs | 5 + .../src/types/shape/struct_type.rs | 32 ++++++ crates/macros/cgp-macro-lib/src/enum_type.rs | 13 +++ crates/macros/cgp-macro-lib/src/lib.rs | 4 + .../macros/cgp-macro-lib/src/struct_type.rs | 13 +++ crates/macros/cgp-macro/src/lib.rs | 80 ++++++++++++++- crates/main/cgp-core/src/prelude.rs | 8 +- 15 files changed, 421 insertions(+), 30 deletions(-) create mode 100644 crates/macros/cgp-macro-core/src/functions/reject_attributes.rs create mode 100644 crates/macros/cgp-macro-core/src/functions/shape/mod.rs create mode 100644 crates/macros/cgp-macro-core/src/functions/shape/parse_fields.rs create mode 100644 crates/macros/cgp-macro-core/src/functions/shape/validate_fields.rs create mode 100644 crates/macros/cgp-macro-core/src/types/shape/enum_type.rs create mode 100644 crates/macros/cgp-macro-core/src/types/shape/mod.rs create mode 100644 crates/macros/cgp-macro-core/src/types/shape/struct_type.rs create mode 100644 crates/macros/cgp-macro-lib/src/enum_type.rs create mode 100644 crates/macros/cgp-macro-lib/src/struct_type.rs diff --git a/crates/macros/cgp-macro-core/src/functions/mod.rs b/crates/macros/cgp-macro-core/src/functions/mod.rs index 4208f4817..85bb58bbc 100644 --- a/crates/macros/cgp-macro-core/src/functions/mod.rs +++ b/crates/macros/cgp-macro-core/src/functions/mod.rs @@ -7,6 +7,8 @@ mod implicits; mod is_provider_params; mod override_span; mod parse_internal; +mod reject_attributes; +mod shape; mod snake_case; mod strip; @@ -19,5 +21,7 @@ pub use implicits::*; pub use is_provider_params::*; pub use override_span::*; pub use parse_internal::*; +pub use reject_attributes::*; +pub use shape::*; pub use snake_case::*; pub use strip::*; diff --git a/crates/macros/cgp-macro-core/src/functions/reject_attributes.rs b/crates/macros/cgp-macro-core/src/functions/reject_attributes.rs new file mode 100644 index 000000000..e73874d6b --- /dev/null +++ b/crates/macros/cgp-macro-core/src/functions/reject_attributes.rs @@ -0,0 +1,19 @@ +use quote::ToTokens; +use syn::spanned::Spanned; +use syn::{Attribute, Error}; + +/// Error with a spanned "unsupported attribute" message if any attribute is +/// present, pointing at the first one. +pub fn reject_non_empty_attributes(attributes: &[Attribute]) -> syn::Result<()> { + if let Some(attribute) = attributes.first() { + Err(Error::new( + attribute.span(), + format!( + "unsupported attribute: {}", + attribute.path().to_token_stream() + ), + )) + } else { + Ok(()) + } +} diff --git a/crates/macros/cgp-macro-core/src/functions/shape/mod.rs b/crates/macros/cgp-macro-core/src/functions/shape/mod.rs new file mode 100644 index 000000000..bca2132d0 --- /dev/null +++ b/crates/macros/cgp-macro-core/src/functions/shape/mod.rs @@ -0,0 +1,5 @@ +mod parse_fields; +mod validate_fields; + +pub use parse_fields::*; +pub use validate_fields::*; diff --git a/crates/macros/cgp-macro-core/src/functions/shape/parse_fields.rs b/crates/macros/cgp-macro-core/src/functions/shape/parse_fields.rs new file mode 100644 index 000000000..c8bfc66e6 --- /dev/null +++ b/crates/macros/cgp-macro-core/src/functions/shape/parse_fields.rs @@ -0,0 +1,99 @@ +use syn::ext::IdentExt; +use syn::parse::ParseStream; +use syn::punctuated::Punctuated; +use syn::token::Comma; +use syn::{ + Attribute, Error, Field, Fields, FieldsNamed, FieldsUnnamed, Ident, Lit, Token, Visibility, +}; + +use crate::functions::validate_shape_fields; + +/// Parse the body of a `Struct!` into the `syn::Fields` a struct declaration would hold. +/// +/// A proc macro never sees the delimiter it was invoked with, so the form is read from the +/// entries: `name: Type` entries make a named body, and bare types make a tuple body. An empty +/// body is `Fields::Unit`. The fields are validated with [`validate_shape_fields`]. +pub fn parse_shape_fields(input: ParseStream) -> syn::Result { + let mut named_form = None; + let mut named = Punctuated::::new(); + let mut unnamed = Punctuated::::new(); + + while !input.is_empty() { + let entry_is_named = peek_named_entry(input)?; + + match named_form { + None => named_form = Some(entry_is_named), + Some(form) if form != entry_is_named => { + return Err(Error::new( + input.span(), + "cannot mix named and positional fields: write every entry as \ + `name: Type`, or every entry as a bare type", + )); + } + Some(_) => {} + } + + if entry_is_named { + named.push_value(Field::parse_named(input)?); + } else { + unnamed.push_value(Field::parse_unnamed(input)?); + } + + if input.is_empty() { + break; + } + + let comma: Comma = input.parse()?; + + if entry_is_named { + named.push_punct(comma); + } else { + unnamed.push_punct(comma); + } + } + + let fields = match named_form { + None => Fields::Unit, + Some(true) => Fields::Named(FieldsNamed { + brace_token: Default::default(), + named, + }), + Some(false) => Fields::Unnamed(FieldsUnnamed { + paren_token: Default::default(), + unnamed, + }), + }; + + validate_shape_fields(&fields)?; + + Ok(fields) +} + +/// Whether the next entry is a `name: Type` field, looking past any attributes and visibility +/// so that those reach their own rejection. A single `:` decides it: `syn` peeks `Token![:]` on +/// the first half of a `::` too, so a path type such as `core::marker::PhantomData` is ruled +/// out explicitly. Also rejects a literal where a type belongs, the likely mistake of reading +/// `Struct! { a: 1 }` as a struct literal. +fn peek_named_entry(input: ParseStream) -> syn::Result { + let fork = input.fork(); + fork.call(Attribute::parse_outer)?; + fork.parse::()?; + + let is_named = (fork.peek(Ident) || fork.peek(Token![_])) + && fork.peek2(Token![:]) + && !fork.peek2(Token![::]); + + if is_named { + fork.call(Ident::parse_any)?; + fork.parse::()?; + } + + if fork.peek(Lit) { + return Err(Error::new( + fork.span(), + "expected a type: a type-level shape lists field types, not values", + )); + } + + Ok(is_named) +} diff --git a/crates/macros/cgp-macro-core/src/functions/shape/validate_fields.rs b/crates/macros/cgp-macro-core/src/functions/shape/validate_fields.rs new file mode 100644 index 000000000..1b823a44c --- /dev/null +++ b/crates/macros/cgp-macro-core/src/functions/shape/validate_fields.rs @@ -0,0 +1,47 @@ +use syn::ext::IdentExt; +use syn::{Error, Fields, Visibility}; + +use crate::functions::reject_non_empty_attributes; + +/// Reject the parts of a struct or variant body that a type-level shape has no use for: field +/// attributes, field visibility, the `_` field name, and a field name given twice. +/// +/// `syn` parses all of these as part of an ordinary struct body, so a `Struct!` or `Enum!` body +/// gets through its parser and must be checked here. Names are compared with any `r#` removed, +/// since `r#a` and `a` produce the same `Symbol!("a")` tag. +pub fn validate_shape_fields(fields: &Fields) -> syn::Result<()> { + let mut seen = Vec::new(); + + for field in fields { + reject_non_empty_attributes(&field.attrs)?; + + if !matches!(field.vis, Visibility::Inherited) { + return Err(Error::new_spanned( + &field.vis, + "a field of a type-level shape has no visibility; remove it", + )); + } + + if let Some(ident) = &field.ident { + let name = ident.unraw().to_string(); + + if name == "_" { + return Err(Error::new( + ident.span(), + "a field of a type-level shape must be named; `_` is not allowed", + )); + } + + if seen.contains(&name) { + return Err(Error::new( + ident.span(), + format!("duplicate field `{name}`"), + )); + } + + seen.push(name); + } + } + + Ok(()) +} diff --git a/crates/macros/cgp-macro-core/src/types/delegate_component/validate_attributes.rs b/crates/macros/cgp-macro-core/src/types/delegate_component/validate_attributes.rs index 565326e68..13070dc7e 100644 --- a/crates/macros/cgp-macro-core/src/types/delegate_component/validate_attributes.rs +++ b/crates/macros/cgp-macro-core/src/types/delegate_component/validate_attributes.rs @@ -1,7 +1,4 @@ -use quote::ToTokens; -use syn::spanned::Spanned; -use syn::{Attribute, Error}; - +use crate::functions::reject_non_empty_attributes; use crate::types::delegate_component::{ DelegateEntries, DelegateKey, DelegateMapping, DelegateStatement, DelegateTable, DelegateValue, DelegateValueWithInnerTable, ForDelegateStatement, MultiDelegateKey, PathDelegateKey, @@ -17,23 +14,6 @@ pub trait ValidateAttributes { fn validate_attributes(&self) -> syn::Result<()>; } -/// Error with a spanned "unsupported attribute" message if any attribute is -/// present, pointing at the first one. -pub fn reject_non_empty_attributes(attributes: &[Attribute]) -> syn::Result<()> { - if !attributes.is_empty() { - let attribute = &attributes[0]; - Err(Error::new( - attribute.span(), - format!( - "unsupported attribute: {}", - attribute.path().to_token_stream() - ), - )) - } else { - Ok(()) - } -} - impl ValidateAttributes for SingleDelegateKey { fn validate_attributes(&self) -> syn::Result<()> { reject_non_empty_attributes(&self.attributes) diff --git a/crates/macros/cgp-macro-core/src/types/mod.rs b/crates/macros/cgp-macro-core/src/types/mod.rs index c23258121..abd1530bf 100644 --- a/crates/macros/cgp-macro-core/src/types/mod.rs +++ b/crates/macros/cgp-macro-core/src/types/mod.rs @@ -23,4 +23,5 @@ pub mod namespace; pub mod path; pub mod product; pub mod provider_impl; +pub mod shape; pub mod sum; diff --git a/crates/macros/cgp-macro-core/src/types/shape/enum_type.rs b/crates/macros/cgp-macro-core/src/types/shape/enum_type.rs new file mode 100644 index 000000000..37deb1d13 --- /dev/null +++ b/crates/macros/cgp-macro-core/src/types/shape/enum_type.rs @@ -0,0 +1,99 @@ +use proc_macro2::TokenStream; +use syn::ext::IdentExt; +use syn::parse::{Parse, ParseStream}; +use syn::punctuated::Punctuated; +use syn::token::{Brace, Comma, Paren}; +use syn::{Attribute, Error, Fields, Ident, Token, Type, Variant, Visibility}; + +use crate::functions::{reject_non_empty_attributes, validate_shape_fields}; +use crate::types::cgp_data::variants_to_sum_type; + +/// The `Enum!` macro: the body of an enum, held as the `syn::Variant` list the enum +/// declaration would have. +/// +/// As with [`StructType`](super::StructType), holding the derive's input form lets +/// [`eval`](Self::eval) run the `#[derive(HasFields)]` encoder unchanged. +pub struct EnumType { + pub variants: Punctuated, +} + +impl EnumType { + /// Encode the variants as the derive does: a sum of `Field` + /// entries ending in `Void`, each payload encoded by the `Struct!` rules. + pub fn eval(&self) -> syn::Result { + variants_to_sum_type(&self.variants, &TokenStream::new()) + } +} + +impl Parse for EnumType { + fn parse(input: ParseStream) -> syn::Result { + let mut variants = Punctuated::new(); + let mut seen = Vec::new(); + + while !input.is_empty() { + let variant = parse_variant(input)?; + + let name = variant.ident.unraw().to_string(); + if seen.contains(&name) { + return Err(Error::new( + variant.ident.span(), + format!("duplicate variant `{name}`"), + )); + } + seen.push(name); + + variants.push_value(variant); + + if input.is_empty() { + break; + } + + variants.push_punct(input.parse()?); + } + + Ok(Self { variants }) + } +} + +/// Parse one variant. `syn`'s own `Variant` parser silently discards a visibility and accepts +/// a discriminant, so the variant is read here, rejecting both, and its fields are parsed with +/// `syn`'s struct-body parsers. Unlike the `Struct!` body, a variant's delimiter is visible, so +/// braces hold named fields and parentheses positional ones, as in a Rust enum. +fn parse_variant(input: ParseStream) -> syn::Result { + let attrs = input.call(Attribute::parse_outer)?; + reject_non_empty_attributes(&attrs)?; + + if input.peek(Token![pub]) { + let visibility: Visibility = input.parse()?; + return Err(Error::new_spanned( + visibility, + "an `Enum!` variant has no visibility; remove it", + )); + } + + let ident: Ident = input.parse()?; + + let fields = if input.peek(Brace) { + Fields::Named(input.parse()?) + } else if input.peek(Paren) { + Fields::Unnamed(input.parse()?) + } else { + Fields::Unit + }; + + validate_shape_fields(&fields)?; + + if input.peek(Token![=]) { + return Err(Error::new( + input.span(), + "an `Enum!` variant has no discriminant; remove the `= …`", + )); + } + + Ok(Variant { + attrs: Vec::new(), + ident, + fields, + discriminant: None, + }) +} diff --git a/crates/macros/cgp-macro-core/src/types/shape/mod.rs b/crates/macros/cgp-macro-core/src/types/shape/mod.rs new file mode 100644 index 000000000..60eb53c01 --- /dev/null +++ b/crates/macros/cgp-macro-core/src/types/shape/mod.rs @@ -0,0 +1,5 @@ +mod enum_type; +mod struct_type; + +pub use enum_type::*; +pub use struct_type::*; diff --git a/crates/macros/cgp-macro-core/src/types/shape/struct_type.rs b/crates/macros/cgp-macro-core/src/types/shape/struct_type.rs new file mode 100644 index 000000000..38a3b965d --- /dev/null +++ b/crates/macros/cgp-macro-core/src/types/shape/struct_type.rs @@ -0,0 +1,32 @@ +use proc_macro2::TokenStream; +use syn::parse::{Parse, ParseStream}; +use syn::{Fields, Type}; + +use crate::functions::parse_shape_fields; +use crate::types::cgp_data::item_fields_to_product_type; + +/// The `Struct!` macro: the body of a named or tuple struct, held as the `syn::Fields` the +/// struct declaration would have. +/// +/// Holding the derive's own input form lets [`eval`](Self::eval) run the `#[derive(HasFields)]` +/// encoder unchanged, so `Struct! { … }` is by construction the type a derived `Fields` is. +pub struct StructType { + pub fields: Fields, +} + +impl StructType { + /// Encode the fields as the derive does: a product of `Field` entries, keyed by `Symbol!` + /// for named fields and by `Index` for positional ones, with a one-field tuple body + /// encoding to its bare type and an empty body to `Nil`. + pub fn eval(&self) -> syn::Result { + item_fields_to_product_type(&self.fields, &TokenStream::new()) + } +} + +impl Parse for StructType { + fn parse(input: ParseStream) -> syn::Result { + let fields = parse_shape_fields(input)?; + + Ok(Self { fields }) + } +} diff --git a/crates/macros/cgp-macro-lib/src/enum_type.rs b/crates/macros/cgp-macro-lib/src/enum_type.rs new file mode 100644 index 000000000..75c704183 --- /dev/null +++ b/crates/macros/cgp-macro-lib/src/enum_type.rs @@ -0,0 +1,13 @@ +use cgp_macro_core::types::shape::EnumType; +use proc_macro2::TokenStream; +use quote::ToTokens; +use syn::parse2; + +#[allow(non_snake_case)] +pub fn Enum(body: TokenStream) -> syn::Result { + let enum_type: EnumType = parse2(body)?; + + let evaluated = enum_type.eval()?; + + Ok(evaluated.to_token_stream()) +} diff --git a/crates/macros/cgp-macro-lib/src/lib.rs b/crates/macros/cgp-macro-lib/src/lib.rs index c627dc19a..0b44d2b4c 100644 --- a/crates/macros/cgp-macro-lib/src/lib.rs +++ b/crates/macros/cgp-macro-lib/src/lib.rs @@ -19,8 +19,10 @@ mod derive_extract_field; mod derive_from_variant; mod derive_has_field; mod derive_has_fields; +mod enum_type; mod path; mod product; +mod struct_type; mod sum; mod symbol; @@ -45,7 +47,9 @@ pub use derive_extract_field::*; pub use derive_from_variant::*; pub use derive_has_field::*; pub use derive_has_fields::*; +pub use enum_type::*; pub use path::*; pub use product::*; +pub use struct_type::*; pub use sum::*; pub use symbol::*; diff --git a/crates/macros/cgp-macro-lib/src/struct_type.rs b/crates/macros/cgp-macro-lib/src/struct_type.rs new file mode 100644 index 000000000..7bd6338ef --- /dev/null +++ b/crates/macros/cgp-macro-lib/src/struct_type.rs @@ -0,0 +1,13 @@ +use cgp_macro_core::types::shape::StructType; +use proc_macro2::TokenStream; +use quote::ToTokens; +use syn::parse2; + +#[allow(non_snake_case)] +pub fn Struct(body: TokenStream) -> syn::Result { + let struct_type: StructType = parse2(body)?; + + let evaluated = struct_type.eval()?; + + Ok(evaluated.to_token_stream()) +} diff --git a/crates/macros/cgp-macro/src/lib.rs b/crates/macros/cgp-macro/src/lib.rs index 4f428aa3a..b13d75107 100644 --- a/crates/macros/cgp-macro/src/lib.rs +++ b/crates/macros/cgp-macro/src/lib.rs @@ -732,11 +732,12 @@ pub fn blanket_trait(attr: TokenStream, item: TokenStream) -> TokenStream { The `Symbol!` macro is used to create a type-level string through the string literal given to the macro. - The macro constructs the type-level string through a chain of `Char` types and - terminated with the `Nil` type. In other words, it constructs a type-level list - of characters to represent them as a type-level string. + The macro constructs the type-level string as a chain of `Chars` types terminated with + the `Nil` type, wrapped in `Symbol` together with the string's byte length. In other + words, it constructs a type-level list of characters to represent them as a type-level + string. - Read more about type-level strings in the documentation for `Char`. + Read more about type-level strings in the documentation for `Symbol` and `Chars`. ## Example @@ -749,7 +750,7 @@ pub fn blanket_trait(attr: TokenStream, item: TokenStream) -> TokenStream { The following type would be generated: ```rust,ignore - type Hello = Char<'h', Char<'e', Char<'l', Char<'l', Char<'o', Nil>>>>>; + type Hello = Symbol<5, Chars<'h', Chars<'e', Chars<'l', Chars<'l', Chars<'o', Nil>>>>>>; ``` */ #[proc_macro] @@ -828,6 +829,75 @@ pub fn Sum(body: TokenStream) -> TokenStream { .into() } +/** + The `Struct!` macro builds the type-level shape of a struct from the body of a + struct declaration: the same type that `#[derive(HasFields)]` gives as `Fields`. + + The body is either named fields or a list of types, written as in a struct + declaration. The delimiter of the invocation is not significant: the form is + read from the entries. Named fields become `Field` entries keyed by `Symbol!`, + positional fields become `Field` entries keyed by `Index`, and the entries + are chained into a `Product!`. A body with one positional field is that field's + type, and an empty body is `Nil`. + + ## Example + + Given the following shape definitions: + + ```rust,ignore + type Person = Struct! { name: String, age: u8 }; + type Pair = Struct!(u64, String); + ``` + + The following types would be generated: + + ```rust,ignore + type Person = Product![Field, Field]; + type Pair = Product![Field, u64>, Field, String>]; + ``` +*/ +#[proc_macro] +#[allow(non_snake_case)] +pub fn Struct(body: TokenStream) -> TokenStream { + cgp_macro_lib::Struct(body.into()) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +/** + The `Enum!` macro builds the type-level shape of an enum from the body of an + enum declaration: the same type that `#[derive(HasFields)]` gives as `Fields`. + + Each variant becomes a `Field` entry keyed by the variant name as a `Symbol!`, + and the entries are chained into a `Sum!`. A variant's payload follows the + `Struct!` rules for its fields, so a unit variant carries `Nil` and a variant + with one positional field carries that field's type. An empty body is `Void`. + + ## Example + + Given the following shape definition: + + ```rust,ignore + type Shape = Enum! { Circle(f64), Rectangle { width: f64, height: f64 } }; + ``` + + The following type would be generated: + + ```rust,ignore + type Shape = Sum![ + Field, + Field, + ]; + ``` +*/ +#[proc_macro] +#[allow(non_snake_case)] +pub fn Enum(body: TokenStream) -> TokenStream { + cgp_macro_lib::Enum(body.into()) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + #[proc_macro] #[allow(non_snake_case)] pub fn Path(body: TokenStream) -> TokenStream { diff --git a/crates/main/cgp-core/src/prelude.rs b/crates/main/cgp-core/src/prelude.rs index 13892a0db..43727da3c 100644 --- a/crates/main/cgp-core/src/prelude.rs +++ b/crates/main/cgp-core/src/prelude.rs @@ -16,9 +16,9 @@ pub use cgp_field::traits::{ }; pub use cgp_field::types::{Chars, Cons, Either, Field, Index, Life, MRef, Nil, Symbol, Void}; pub use cgp_macro::{ - BuildField, CgpData, CgpRecord, CgpVariant, ExtractField, FromVariant, HasField, HasFields, - Path, Product, Sum, Symbol, cgp_auto_getter, cgp_component, cgp_fn, cgp_getter, cgp_impl, - cgp_namespace, cgp_new_provider, cgp_provider, cgp_type, check_components, - delegate_and_check_components, delegate_components, product, + BuildField, CgpData, CgpRecord, CgpVariant, Enum, ExtractField, FromVariant, HasField, + HasFields, Path, Product, Struct, Sum, Symbol, cgp_auto_getter, cgp_component, cgp_fn, + cgp_getter, cgp_impl, cgp_namespace, cgp_new_provider, cgp_provider, cgp_type, + check_components, delegate_and_check_components, delegate_components, product, }; pub use cgp_type::{HasType, TypeProvider, UseType}; From 073f5c6d69b9e7b90ad2cae64ddaedc54236bbd5 Mon Sep 17 00:00:00 2001 From: Soares Chen Date: Tue, 6 Oct 2026 15:15:50 +0200 Subject: [PATCH 2/4] Test the `Struct!` and `Enum!` shape macros A new `shape_macros` target in `cgp-tests` pins the two macros by type equality: the named and tuple encodings, the newtype and empty-body rules, the variant shapes and their `Struct!`-payload equivalences, and parity with `#[derive(HasFields)]` on the same body through a macro that feeds one token body to both. It also covers shapes in signatures, impl headers, wiring, `open` keys, forwarded `macro_rules!` fragments, and use without the prelude, plus run-time round trips through `ToFields`/`FromFields`. In `cgp-macro-tests`, a `shape_macro_parsing` target checks which form each body is read as, and the rejection cases pin every parse-time message. Co-Authored-By: Claude Opus 5.5 --- crates/tests/AGENTS.md | 1 + .../tests/parser_rejections/enum_macro.rs | 89 +++++++++++++ .../tests/parser_rejections/mod.rs | 2 + .../tests/parser_rejections/struct_macro.rs | 109 ++++++++++++++++ .../shape_macro_parsing/enum_variants.rs | 53 ++++++++ .../tests/shape_macro_parsing/mod.rs | 2 + .../tests/shape_macro_parsing/struct_form.rs | 68 ++++++++++ .../tests/shape_macro_parsing_tests.rs | 11 ++ .../tests/shape_macros/assertions.rs | 39 ++++++ .../tests/shape_macros/enum_basic.rs | 36 ++++++ .../tests/shape_macros/enum_derive_parity.rs | 69 +++++++++++ .../tests/shape_macros/enum_round_trip.rs | 64 ++++++++++ .../tests/shape_macros/enum_variant_shapes.rs | 55 ++++++++ .../shape_macros/enum_with_variant_derives.rs | 42 +++++++ .../tests/cgp-tests/tests/shape_macros/mod.rs | 29 +++++ .../tests/shape_macros/open_dispatch_key.rs | 59 +++++++++ .../shape_macros/struct_delimiter_agnostic.rs | 23 ++++ .../shape_macros/struct_derive_parity.rs | 106 ++++++++++++++++ .../tests/shape_macros/struct_hygiene.rs | 33 +++++ .../shape_macros/struct_in_signatures.rs | 117 ++++++++++++++++++ .../tests/shape_macros/struct_inequality.rs | 36 ++++++ .../tests/shape_macros/struct_named.rs | 102 +++++++++++++++ .../tests/shape_macros/struct_round_trip.rs | 59 +++++++++ .../struct_through_macro_rules.rs | 56 +++++++++ .../tests/shape_macros/struct_tuple.rs | 81 ++++++++++++ .../cgp-tests/tests/shape_macros_tests.rs | 17 +++ 26 files changed, 1358 insertions(+) create mode 100644 crates/tests/cgp-macro-tests/tests/parser_rejections/enum_macro.rs create mode 100644 crates/tests/cgp-macro-tests/tests/parser_rejections/struct_macro.rs create mode 100644 crates/tests/cgp-macro-tests/tests/shape_macro_parsing/enum_variants.rs create mode 100644 crates/tests/cgp-macro-tests/tests/shape_macro_parsing/mod.rs create mode 100644 crates/tests/cgp-macro-tests/tests/shape_macro_parsing/struct_form.rs create mode 100644 crates/tests/cgp-macro-tests/tests/shape_macro_parsing_tests.rs create mode 100644 crates/tests/cgp-tests/tests/shape_macros/assertions.rs create mode 100644 crates/tests/cgp-tests/tests/shape_macros/enum_basic.rs create mode 100644 crates/tests/cgp-tests/tests/shape_macros/enum_derive_parity.rs create mode 100644 crates/tests/cgp-tests/tests/shape_macros/enum_round_trip.rs create mode 100644 crates/tests/cgp-tests/tests/shape_macros/enum_variant_shapes.rs create mode 100644 crates/tests/cgp-tests/tests/shape_macros/enum_with_variant_derives.rs create mode 100644 crates/tests/cgp-tests/tests/shape_macros/mod.rs create mode 100644 crates/tests/cgp-tests/tests/shape_macros/open_dispatch_key.rs create mode 100644 crates/tests/cgp-tests/tests/shape_macros/struct_delimiter_agnostic.rs create mode 100644 crates/tests/cgp-tests/tests/shape_macros/struct_derive_parity.rs create mode 100644 crates/tests/cgp-tests/tests/shape_macros/struct_hygiene.rs create mode 100644 crates/tests/cgp-tests/tests/shape_macros/struct_in_signatures.rs create mode 100644 crates/tests/cgp-tests/tests/shape_macros/struct_inequality.rs create mode 100644 crates/tests/cgp-tests/tests/shape_macros/struct_named.rs create mode 100644 crates/tests/cgp-tests/tests/shape_macros/struct_round_trip.rs create mode 100644 crates/tests/cgp-tests/tests/shape_macros/struct_through_macro_rules.rs create mode 100644 crates/tests/cgp-tests/tests/shape_macros/struct_tuple.rs create mode 100644 crates/tests/cgp-tests/tests/shape_macros_tests.rs diff --git a/crates/tests/AGENTS.md b/crates/tests/AGENTS.md index 48d385fae..6ead3abba 100644 --- a/crates/tests/AGENTS.md +++ b/crates/tests/AGENTS.md @@ -109,6 +109,7 @@ genuinely distinct variants) in its owning target, and nowhere else: | `cgp_namespace!` | `namespaces` | | `#[blanket_trait]` | `blanket_traits` | | `#[derive(HasField)]` / `HasFields` / `CgpData` | `field_access` / `extensible_records` / `extensible_variants` | +| `Struct!` / `Enum!` | `shape_macros` (type-equality tests only: each emits a single type, so neither has a snapshot) | | `#[cgp_computer]` / `#[cgp_producer]` | `handlers` | | `#[cgp_auto_dispatch]` | `auto_dispatch` | diff --git a/crates/tests/cgp-macro-tests/tests/parser_rejections/enum_macro.rs b/crates/tests/cgp-macro-tests/tests/parser_rejections/enum_macro.rs new file mode 100644 index 000000000..267044122 --- /dev/null +++ b/crates/tests/cgp-macro-tests/tests/parser_rejections/enum_macro.rs @@ -0,0 +1,89 @@ +//! `Enum!` rejects the parts of an enum body a type-level shape has no use for: +//! variant attributes, variant visibility, discriminants, and a variant name +//! given twice. A variant's fields go through the same checks as a `Struct!` +//! body, so a rejected field is rejected inside a variant too. +//! +//! See cgp-knowledge-base/cgp/implementation/entrypoints/enum.md. + +use quote::quote; + +use super::assert_macro_rejects_with; + +#[test] +fn rejects_a_variant_attribute() { + assert_macro_rejects_with( + "Enum! with a variant attribute", + "unsupported attribute: default", + || { + cgp_macro_lib::Enum(quote!( + #[default] + Empty, + Full(u8) + )) + }, + ); +} + +#[test] +fn rejects_visibility_on_a_variant() { + assert_macro_rejects_with( + "Enum! with a `pub` variant", + "an `Enum!` variant has no visibility; remove it", + || cgp_macro_lib::Enum(quote!(pub Circle(f64))), + ); +} + +#[test] +fn rejects_a_discriminant() { + assert_macro_rejects_with( + "Enum! with a discriminant", + "an `Enum!` variant has no discriminant; remove the `= …`", + || cgp_macro_lib::Enum(quote!(A = 1, B = 2)), + ); +} + +#[test] +fn rejects_a_duplicate_variant_name() { + assert_macro_rejects_with( + "Enum! with a duplicate variant", + "duplicate variant `Circle`", + || cgp_macro_lib::Enum(quote!(Circle(f64), Circle(u32))), + ); +} + +#[test] +fn rejects_a_bad_field_inside_a_variant() { + assert_macro_rejects_with( + "Enum! with a `pub` named field", + "a field of a type-level shape has no visibility; remove it", + || cgp_macro_lib::Enum(quote!(Rect { pub width: f64 })), + ); + + assert_macro_rejects_with( + "Enum! with a `pub` positional field", + "a field of a type-level shape has no visibility; remove it", + || cgp_macro_lib::Enum(quote!(Pair(pub u8, u16))), + ); + + assert_macro_rejects_with( + "Enum! with a duplicate variant field", + "duplicate field `width`", + || { + cgp_macro_lib::Enum(quote!(Rect { + width: f64, + width: f64 + })) + }, + ); + + assert_macro_rejects_with( + "Enum! with a variant field attribute", + "unsupported attribute: doc", + || { + cgp_macro_lib::Enum(quote!(Rect { + /// The width. + width: f64 + })) + }, + ); +} diff --git a/crates/tests/cgp-macro-tests/tests/parser_rejections/mod.rs b/crates/tests/cgp-macro-tests/tests/parser_rejections/mod.rs index bd2b0e44e..aa5b5309d 100644 --- a/crates/tests/cgp-macro-tests/tests/parser_rejections/mod.rs +++ b/crates/tests/cgp-macro-tests/tests/parser_rejections/mod.rs @@ -63,6 +63,8 @@ pub mod delegate_and_check_components; pub mod delegate_components; pub mod derive_cgp_data; pub mod derive_from_variant; +pub mod enum_macro; pub mod getters; +pub mod struct_macro; pub mod use_provider; pub mod use_type; diff --git a/crates/tests/cgp-macro-tests/tests/parser_rejections/struct_macro.rs b/crates/tests/cgp-macro-tests/tests/parser_rejections/struct_macro.rs new file mode 100644 index 000000000..6ad5f20a5 --- /dev/null +++ b/crates/tests/cgp-macro-tests/tests/parser_rejections/struct_macro.rs @@ -0,0 +1,109 @@ +//! `Struct!` rejects the parts of a struct body a type-level shape has no use +//! for, each with its own message: attributes (doc comments included), +//! visibility, the `_` field name, a field name given twice, entries that mix +//! the named and positional forms, and a value where a type belongs. +//! +//! See cgp-knowledge-base/cgp/implementation/entrypoints/struct.md. + +use quote::quote; + +use super::assert_macro_rejects_with; + +#[test] +fn rejects_a_field_attribute() { + assert_macro_rejects_with( + "Struct! with an attribute", + "unsupported attribute: serde", + || cgp_macro_lib::Struct(quote!(#[serde(rename = "n")] name: String)), + ); +} + +#[test] +fn rejects_a_doc_comment() { + assert_macro_rejects_with( + "Struct! with a doc comment", + "unsupported attribute: doc", + || { + cgp_macro_lib::Struct(quote!( + /// The name. + name: String + )) + }, + ); +} + +#[test] +fn rejects_visibility_on_a_named_field() { + assert_macro_rejects_with( + "Struct! with `pub`", + "a field of a type-level shape has no visibility; remove it", + || cgp_macro_lib::Struct(quote!(pub name: String)), + ); + + assert_macro_rejects_with( + "Struct! with `pub(crate)`", + "a field of a type-level shape has no visibility; remove it", + || cgp_macro_lib::Struct(quote!(pub(crate) name: String)), + ); +} + +#[test] +fn rejects_visibility_on_a_positional_field() { + assert_macro_rejects_with( + "Struct! with a `pub` positional field", + "a field of a type-level shape has no visibility; remove it", + || cgp_macro_lib::Struct(quote!(pub u64, String)), + ); +} + +#[test] +fn rejects_an_underscore_field_name() { + assert_macro_rejects_with( + "Struct! with a `_` field", + "a field of a type-level shape must be named; `_` is not allowed", + || cgp_macro_lib::Struct(quote!(_: u8)), + ); +} + +#[test] +fn rejects_a_duplicate_field_name() { + assert_macro_rejects_with( + "Struct! with a duplicate field", + "duplicate field `a`", + || cgp_macro_lib::Struct(quote!(a: u8, a: u16)), + ); + + // `r#a` and `a` produce the same `Symbol!("a")` tag. + assert_macro_rejects_with( + "Struct! with a duplicate raw field", + "duplicate field `a`", + || cgp_macro_lib::Struct(quote!(r#a: u8, a: u16)), + ); +} + +#[test] +fn rejects_mixed_named_and_positional_entries() { + let message = "cannot mix named and positional fields: write every entry as \ + `name: Type`, or every entry as a bare type"; + + assert_macro_rejects_with("Struct! named then positional", message, || { + cgp_macro_lib::Struct(quote!(a: u8, u16)) + }); + + assert_macro_rejects_with("Struct! positional then named", message, || { + cgp_macro_lib::Struct(quote!(u8, b: u16)) + }); +} + +#[test] +fn rejects_a_value_where_a_type_belongs() { + let message = "expected a type: a type-level shape lists field types, not values"; + + assert_macro_rejects_with("Struct! with a named value", message, || { + cgp_macro_lib::Struct(quote!(a: 1)) + }); + + assert_macro_rejects_with("Struct! with a positional value", message, || { + cgp_macro_lib::Struct(quote!("text", 2)) + }); +} diff --git a/crates/tests/cgp-macro-tests/tests/shape_macro_parsing/enum_variants.rs b/crates/tests/cgp-macro-tests/tests/shape_macro_parsing/enum_variants.rs new file mode 100644 index 000000000..f1a49fdd7 --- /dev/null +++ b/crates/tests/cgp-macro-tests/tests/shape_macro_parsing/enum_variants.rs @@ -0,0 +1,53 @@ +//! How `EnumType` reads each variant: a variant's delimiter is visible inside +//! the body, so braces hold named fields and parentheses positional ones, as in +//! a Rust enum, and a bare name is a unit variant. +//! +//! See cgp-knowledge-base/cgp/implementation/asts/shape.md. + +use cgp_macro_core::types::shape::EnumType; +use quote::quote; +use syn::Fields; + +#[test] +fn each_variant_keeps_its_declared_shape() { + let enum_type: EnumType = syn::parse2(quote!( + Empty, + Circle(u32), + Rectangle(u32, u32), + Triangle { + base: u32, + height: u32 + }, + )) + .unwrap(); + + let shapes: Vec<(String, &str, usize)> = enum_type + .variants + .iter() + .map(|variant| { + let shape = match &variant.fields { + Fields::Named(_) => "named", + Fields::Unnamed(_) => "unnamed", + Fields::Unit => "unit", + }; + (variant.ident.to_string(), shape, variant.fields.len()) + }) + .collect(); + + assert_eq!( + shapes, + vec![ + ("Empty".to_owned(), "unit", 0), + ("Circle".to_owned(), "unnamed", 1), + ("Rectangle".to_owned(), "unnamed", 2), + ("Triangle".to_owned(), "named", 2), + ] + ); +} + +#[test] +fn an_empty_body_has_no_variants() { + let enum_type: EnumType = syn::parse2(quote!()).unwrap(); + + assert!(enum_type.variants.is_empty()); +} diff --git a/crates/tests/cgp-macro-tests/tests/shape_macro_parsing/mod.rs b/crates/tests/cgp-macro-tests/tests/shape_macro_parsing/mod.rs new file mode 100644 index 000000000..a126777d4 --- /dev/null +++ b/crates/tests/cgp-macro-tests/tests/shape_macro_parsing/mod.rs @@ -0,0 +1,2 @@ +pub mod enum_variants; +pub mod struct_form; diff --git a/crates/tests/cgp-macro-tests/tests/shape_macro_parsing/struct_form.rs b/crates/tests/cgp-macro-tests/tests/shape_macro_parsing/struct_form.rs new file mode 100644 index 000000000..d44d857bb --- /dev/null +++ b/crates/tests/cgp-macro-tests/tests/shape_macro_parsing/struct_form.rs @@ -0,0 +1,68 @@ +//! Which form `StructType` reads a body as: `name: Type` entries make a named +//! body, bare types make a tuple body, and an empty body is a unit body. A +//! single `:` decides it, so a path type whose `::` begins with a `:` is still a +//! positional field. +//! +//! See cgp-knowledge-base/cgp/implementation/asts/shape.md. + +use cgp_macro_core::types::shape::StructType; +use proc_macro2::TokenStream; +use quote::quote; +use syn::Fields; + +#[derive(Debug, PartialEq)] +enum Form { + Named(Vec), + Unnamed(usize), + Unit, +} + +#[track_caller] +fn form_of(body: TokenStream) -> Form { + let struct_type: StructType = syn::parse2(body).expect("the body should parse"); + + match struct_type.fields { + Fields::Named(fields) => Form::Named( + fields + .named + .iter() + .map(|field| field.ident.as_ref().unwrap().to_string()) + .collect(), + ), + Fields::Unnamed(fields) => Form::Unnamed(fields.unnamed.len()), + Fields::Unit => Form::Unit, + } +} + +#[test] +fn name_type_entries_are_named() { + assert_eq!( + form_of(quote!(name: String, age: u8)), + Form::Named(vec!["name".to_owned(), "age".to_owned()]) + ); + assert_eq!( + form_of(quote!(r#type: u8)), + Form::Named(vec!["r#type".to_owned()]) + ); + assert_eq!( + form_of(quote!(value: core::marker::PhantomData,)), + Form::Named(vec!["value".to_owned()]) + ); +} + +#[test] +fn bare_types_are_positional() { + assert_eq!(form_of(quote!(u64, String)), Form::Unnamed(2)); + assert_eq!(form_of(quote!(u64,)), Form::Unnamed(1)); + assert_eq!(form_of(quote!(a::B, ::a::B)), Form::Unnamed(2)); + assert_eq!(form_of(quote!(Self, dyn Fn(u8) -> u8)), Form::Unnamed(2)); + assert_eq!( + form_of(quote!(fn(u8), [u8; 4], (u8, u16))), + Form::Unnamed(3) + ); +} + +#[test] +fn an_empty_body_is_a_unit_body() { + assert_eq!(form_of(quote!()), Form::Unit); +} diff --git a/crates/tests/cgp-macro-tests/tests/shape_macro_parsing_tests.rs b/crates/tests/cgp-macro-tests/tests/shape_macro_parsing_tests.rs new file mode 100644 index 000000000..f38af931a --- /dev/null +++ b/crates/tests/cgp-macro-tests/tests/shape_macro_parsing_tests.rs @@ -0,0 +1,11 @@ +//! Entrypoint for parser tests of the `Struct!` and `Enum!` shape macros in +//! `cgp-macro-core`. +//! +//! These call the `StructType` and `EnumType` parsers directly to pin which +//! form each body is read as. A proc macro never sees the delimiter it was +//! invoked with, so the named or tuple form of a `Struct!` body is decided from +//! its entries, and a misread form would otherwise surface only as an opaque +//! type mismatch downstream. +#![allow(dead_code)] + +pub mod shape_macro_parsing; diff --git a/crates/tests/cgp-tests/tests/shape_macros/assertions.rs b/crates/tests/cgp-tests/tests/shape_macros/assertions.rs new file mode 100644 index 000000000..b8ba2fe1b --- /dev/null +++ b/crates/tests/cgp-tests/tests/shape_macros/assertions.rs @@ -0,0 +1,39 @@ +//! Type-equality assertions shared by the `shape_macros` tests. + +use core::any::{TypeId, type_name}; + +/// Implemented by a type only for itself, which is what lets +/// [`assert_same_type`] compare two types through a trait bound. +pub trait SameTypeAs {} + +impl SameTypeAs for T {} + +/// Compiles only if `A` and `B` are the same type. +/// +/// A mismatch is a compile error naming both types, as the unsatisfied bound +/// `A: SameTypeAs`. +pub fn assert_same_type() +where + A: ?Sized + SameTypeAs, + B: ?Sized, +{ +} + +/// Panics if `A` and `B` are the same type. +/// +/// The compiler cannot assert that two types differ, so this compares their +/// `TypeId`s at run time, which needs both types to be `'static`. +#[track_caller] +pub fn assert_type_ne() +where + A: ?Sized + 'static, + B: ?Sized + 'static, +{ + assert_ne!( + TypeId::of::(), + TypeId::of::(), + "expected `{}` and `{}` to be different types", + type_name::(), + type_name::(), + ); +} diff --git a/crates/tests/cgp-tests/tests/shape_macros/enum_basic.rs b/crates/tests/cgp-tests/tests/shape_macros/enum_basic.rs new file mode 100644 index 000000000..e414b6301 --- /dev/null +++ b/crates/tests/cgp-tests/tests/shape_macros/enum_basic.rs @@ -0,0 +1,36 @@ +//! `Enum!` expands to a `Sum!` of `Field` entries, one per variant, keyed by the +//! variant name as a `Symbol!` and ending in `Void`. +//! +//! See cgp-knowledge-base/cgp/implementation/entrypoints/enum.md. + +use cgp::prelude::*; + +use crate::shape_macros::assertions::assert_same_type; + +#[test] +fn variants_expand_to_a_symbol_keyed_sum() { + assert_same_type::< + Enum! { Circle(f64), Square(f64) }, + Sum![Field, Field], + >(); + + assert_same_type::< + Enum! { Circle(f64), Square(f64) }, + Either, Either, Void>>, + >(); +} + +#[test] +fn an_empty_body_is_void() { + assert_same_type::(); +} + +#[test] +fn a_trailing_comma_is_accepted() { + assert_same_type::(); +} + +#[test] +fn a_raw_variant_name_is_tagged_by_its_logical_name() { + assert_same_type::]>(); +} diff --git a/crates/tests/cgp-tests/tests/shape_macros/enum_derive_parity.rs b/crates/tests/cgp-tests/tests/shape_macros/enum_derive_parity.rs new file mode 100644 index 000000000..828f910cb --- /dev/null +++ b/crates/tests/cgp-tests/tests/shape_macros/enum_derive_parity.rs @@ -0,0 +1,69 @@ +//! `Enum!` is the same type `#[derive(HasFields)]` gives as `Fields` for the +//! same body, because the macro runs the derive's own encoder. +//! +//! The `assert_enum_parity!` macro below takes one body, declares an enum with +//! `#[derive(HasFields)]` from it, and asserts that its `Fields` equals `Enum!` +//! over the same tokens. Generic enums and the borrowed `FieldsRef` shape are +//! checked by hand. +//! +//! See cgp-knowledge-base/cgp/implementation/entrypoints/enum.md. + +use cgp::prelude::*; + +use crate::shape_macros::assertions::assert_same_type; + +macro_rules! assert_enum_parity { + ($name:ident { $($body:tt)* }) => {{ + // The raw-identifier case declares lowercase variants. + #[allow(non_camel_case_types)] + #[derive(HasFields)] + enum $name { $($body)* } + + assert_same_type::<<$name as HasFields>::Fields, Enum! { $($body)* }>(); + }}; +} + +#[test] +fn enum_bodies_match_the_derive() { + assert_enum_parity!(Shape { + Empty, + Circle(u32), + Rectangle(u32, u32), + Triangle { base: u32, height: u32 }, + }); + assert_enum_parity!(Single { Only(String) }); + assert_enum_parity!(Raw { r#match(u8), r#type { r#in: u16 } }); + assert_enum_parity!(EmptyPayloads { NoParens, Parens(), Braces {} }); + assert_enum_parity!(Never {}); +} + +#[derive(HasFields)] +pub enum Choice { + Left(L), + Right(R), +} + +#[derive(HasFields)] +pub enum Text<'a> { + Borrowed(&'a str), + Owned(String), +} + +fn generic_enums_match<'a: 'b, 'b, L: 'b, R: 'b>() { + assert_same_type::< as HasFields>::Fields, Enum! { Left(L), Right(R) }>(); + + assert_same_type::< + as HasFieldsRef>::FieldsRef<'b>, + Enum! { Left(&'b L), Right(&'b R) }, + >(); + + assert_same_type::< + as HasFieldsRef>::FieldsRef<'b>, + Enum! { Borrowed(&'b &'a str), Owned(&'b String) }, + >(); +} + +#[test] +fn generic_enums_match_the_derive() { + generic_enums_match::<'static, 'static, u8, String>(); +} diff --git a/crates/tests/cgp-tests/tests/shape_macros/enum_round_trip.rs b/crates/tests/cgp-tests/tests/shape_macros/enum_round_trip.rs new file mode 100644 index 000000000..34cee7fa7 --- /dev/null +++ b/crates/tests/cgp-tests/tests/shape_macros/enum_round_trip.rs @@ -0,0 +1,64 @@ +//! A value of an `Enum!` type moves in and out of the enum it describes through +//! `ToFields` and `FromFields`, and a value built by hand from `Either` and +//! `Field` rebuilds the enum. +//! +//! See cgp-knowledge-base/cgp/implementation/entrypoints/enum.md. + +use cgp::prelude::*; + +#[derive(Debug, PartialEq, HasFields)] +pub enum Shape { + Circle(f64), + Rectangle { width: f64, height: f64 }, + Empty, +} + +type ShapeFields = Enum! { + Circle(f64), + Rectangle { width: f64, height: f64 }, + Empty, +}; + +#[test] +fn an_enum_round_trips_through_its_shape() { + let fields: ShapeFields = Shape::Rectangle { + width: 3.0, + height: 4.0, + } + .to_fields(); + + let Either::Right(Either::Left(rectangle)) = &fields else { + panic!("expected the `Rectangle` variant"); + }; + let Cons(width, Cons(height, Nil)) = &rectangle.value; + assert_eq!((width.value, height.value), (3.0, 4.0)); + + assert_eq!( + Shape::from_fields(fields), + Shape::Rectangle { + width: 3.0, + height: 4.0, + } + ); +} + +#[test] +fn a_value_built_by_hand_rebuilds_the_enum() { + let circle: ShapeFields = Either::Left(Field::from(2.0)); + assert_eq!(Shape::from_fields(circle), Shape::Circle(2.0)); + + let rectangle: ShapeFields = Either::Right(Either::Left(Field::from(product![ + Field::from(5.0), + Field::from(6.0), + ]))); + assert_eq!( + Shape::from_fields(rectangle), + Shape::Rectangle { + width: 5.0, + height: 6.0, + } + ); + + let empty: ShapeFields = Either::Right(Either::Right(Either::Left(Field::from(Nil)))); + assert_eq!(Shape::from_fields(empty), Shape::Empty); +} diff --git a/crates/tests/cgp-tests/tests/shape_macros/enum_variant_shapes.rs b/crates/tests/cgp-tests/tests/shape_macros/enum_variant_shapes.rs new file mode 100644 index 000000000..0a3074fd7 --- /dev/null +++ b/crates/tests/cgp-tests/tests/shape_macros/enum_variant_shapes.rs @@ -0,0 +1,55 @@ +//! Each `Enum!` variant's payload is encoded by the `Struct!` rules for its +//! fields, so the four variant shapes map as `#[derive(HasFields)]` maps them: a +//! unit variant carries `Nil`, one positional field carries its bare type, +//! several positional fields carry an `Index`-keyed product, and named fields +//! carry a `Symbol!`-keyed product. +//! +//! The equivalences below follow from that rule, and cargo-cgp relies on them +//! when it prints a variant list: it chooses the shortest of several spellings +//! that are all the same type. +//! +//! See cgp-knowledge-base/cgp/implementation/entrypoints/enum.md. + +use cgp::prelude::*; + +use crate::shape_macros::assertions::{assert_same_type, assert_type_ne}; + +#[test] +fn each_variant_shape_has_its_payload() { + assert_same_type::< + Enum! { + Empty, + Circle(u32), + Rectangle(u32, u32), + Triangle { base: u32, height: u32 }, + }, + Sum![ + Field, + Field, + Field, u32>, Field, u32>]>, + Field< + Symbol!("Triangle"), + Product![Field, Field], + >, + ], + >(); +} + +#[test] +fn a_unit_variant_is_any_empty_payload() { + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); +} + +#[test] +fn a_variant_body_is_a_struct_shape_payload() { + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); +} + +#[test] +fn positional_and_named_payloads_differ() { + assert_type_ne::(); +} diff --git a/crates/tests/cgp-tests/tests/shape_macros/enum_with_variant_derives.rs b/crates/tests/cgp-tests/tests/shape_macros/enum_with_variant_derives.rs new file mode 100644 index 000000000..04672eacc --- /dev/null +++ b/crates/tests/cgp-tests/tests/shape_macros/enum_with_variant_derives.rs @@ -0,0 +1,42 @@ +//! An `Enum!` names the shape of an enum that derives the whole extensible +//! variant family with `#[derive(CgpData)]`, whose variants each carry one +//! positional payload. Generic code bounded on that shape converts a value +//! built in the shape into the enum. +//! +//! See cgp-knowledge-base/cgp/implementation/entrypoints/enum.md. + +use cgp::prelude::*; + +#[derive(Debug, PartialEq, CgpData)] +pub enum Value { + Int(u64), + Text(String), +} + +pub fn from_shape(fields: Enum! { Int(u64), Text(String) }) -> T +where + T: FromFields, +{ + T::from_fields(fields) +} + +#[test] +fn a_shape_value_converts_into_the_enum() { + assert_eq!( + from_shape::(Either::Left(Field::from(7))), + Value::Int(7) + ); + + assert_eq!( + from_shape::(Either::Right(Either::Left(Field::from("seven".to_owned())))), + Value::Text("seven".to_owned()) + ); +} + +#[test] +fn the_variant_derives_agree_with_the_shape() { + assert_eq!( + Value::from_variant(PhantomData::, "eight".to_owned()), + from_shape::(Either::Right(Either::Left(Field::from("eight".to_owned())))), + ); +} diff --git a/crates/tests/cgp-tests/tests/shape_macros/mod.rs b/crates/tests/cgp-tests/tests/shape_macros/mod.rs new file mode 100644 index 000000000..acdd2a3b9 --- /dev/null +++ b/crates/tests/cgp-tests/tests/shape_macros/mod.rs @@ -0,0 +1,29 @@ +//! One unit test per file. Each file is self-contained: it defines its own +//! structs, enums, and contexts at module scope so that the type-level items of +//! one test never leak into another. + +// The type-equality assertions the tests below share. +pub mod assertions; + +// `Struct!`: the named and tuple forms, the encoding rules, and the parity with +// `#[derive(HasFields)]` on the same body. +pub mod struct_delimiter_agnostic; +pub mod struct_derive_parity; +pub mod struct_hygiene; +pub mod struct_in_signatures; +pub mod struct_inequality; +pub mod struct_named; +pub mod struct_round_trip; +pub mod struct_through_macro_rules; +pub mod struct_tuple; + +// `Enum!`: the variant shapes, their equivalences with `Struct!` payloads, and +// the parity with `#[derive(HasFields)]` on the same body. +pub mod enum_basic; +pub mod enum_derive_parity; +pub mod enum_round_trip; +pub mod enum_variant_shapes; +pub mod enum_with_variant_derives; + +// A shape used as an `open` dispatch key in `delegate_components!`. +pub mod open_dispatch_key; diff --git a/crates/tests/cgp-tests/tests/shape_macros/open_dispatch_key.rs b/crates/tests/cgp-tests/tests/shape_macros/open_dispatch_key.rs new file mode 100644 index 000000000..44bec2cad --- /dev/null +++ b/crates/tests/cgp-tests/tests/shape_macros/open_dispatch_key.rs @@ -0,0 +1,59 @@ +//! A shape can key an `open` dispatch entry, so a component dispatched per type +//! can choose a provider by the structure of a record. The brace form follows +//! the `@Component.` prefix directly, where the path grammar also accepts its +//! own `{ … }` grouping, so this pins that the macro is read as one type. +//! +//! See cgp-knowledge-base/cgp/implementation/entrypoints/struct.md. + +use cgp::prelude::*; + +#[cgp_component(ShapeDescriber)] +pub trait CanDescribeShape { + fn describe_shape(&self) -> &'static str; +} + +#[cgp_impl(new DescribePoint)] +impl ShapeDescriber { + fn describe_shape(&self) -> &'static str { + "point" + } +} + +#[cgp_impl(new DescribePair)] +impl ShapeDescriber { + fn describe_shape(&self) -> &'static str { + "pair" + } +} + +pub struct App; + +delegate_components! { + App { + open ShapeDescriberComponent; + + @ShapeDescriberComponent.Struct! { x: f64, y: f64 }: DescribePoint, + @ShapeDescriberComponent.Struct!(u8, u16): DescribePair, + } +} + +check_components! { + App { + ShapeDescriberComponent: [ + Struct! { x: f64, y: f64 }, + Struct!(u8, u16), + ], + } +} + +#[test] +fn a_shape_keys_an_open_dispatch_entry() { + assert_eq!( + CanDescribeShape::::describe_shape(&App), + "point" + ); + assert_eq!( + CanDescribeShape::::describe_shape(&App), + "pair" + ); +} diff --git a/crates/tests/cgp-tests/tests/shape_macros/struct_delimiter_agnostic.rs b/crates/tests/cgp-tests/tests/shape_macros/struct_delimiter_agnostic.rs new file mode 100644 index 000000000..da29c991c --- /dev/null +++ b/crates/tests/cgp-tests/tests/shape_macros/struct_delimiter_agnostic.rs @@ -0,0 +1,23 @@ +//! The delimiter a `Struct!` is invoked with does not matter: a proc macro +//! never sees it, so the named or tuple form is read from the entries. The +//! conventional spelling is braces for named fields and parentheses for a tuple +//! body, and that is the form cargo-cgp prints, but each body gives the same +//! type under any delimiter. +//! +//! See cgp-knowledge-base/cgp/implementation/entrypoints/struct.md. + +use cgp::prelude::*; + +use crate::shape_macros::assertions::assert_same_type; + +#[test] +fn a_named_body_is_the_same_type_under_any_delimiter() { + assert_same_type::(); + assert_same_type::(); +} + +#[test] +fn a_tuple_body_is_the_same_type_under_any_delimiter() { + assert_same_type::(); + assert_same_type::(); +} diff --git a/crates/tests/cgp-tests/tests/shape_macros/struct_derive_parity.rs b/crates/tests/cgp-tests/tests/shape_macros/struct_derive_parity.rs new file mode 100644 index 000000000..c0c58ce80 --- /dev/null +++ b/crates/tests/cgp-tests/tests/shape_macros/struct_derive_parity.rs @@ -0,0 +1,106 @@ +//! `Struct!` is the same type `#[derive(HasFields)]` gives as `Fields` for the +//! same body, because the macro runs the derive's own encoder. +//! +//! The `assert_struct_parity!` macro below takes one body, declares a struct +//! with `#[derive(HasFields)]` from it, and asserts that its `Fields` equals +//! `Struct!` over the same tokens, so the two are compared on identical input. +//! Generic structs and the borrowed `FieldsRef` shape are checked by hand, +//! since their generics cannot be written inside a `Struct!` body. +//! +//! See cgp-knowledge-base/cgp/implementation/entrypoints/struct.md. + +use cgp::prelude::*; + +use crate::shape_macros::assertions::assert_same_type; + +macro_rules! assert_struct_parity { + ($name:ident { $($body:tt)* }) => {{ + #[derive(HasFields)] + struct $name { $($body)* } + + assert_same_type::<<$name as HasFields>::Fields, Struct! { $($body)* }>(); + }}; + ($name:ident ( $($body:tt)* )) => {{ + #[derive(HasFields)] + struct $name ( $($body)* ); + + assert_same_type::<<$name as HasFields>::Fields, Struct!( $($body)* )>(); + }}; +} + +#[test] +fn named_bodies_match_the_derive() { + assert_struct_parity!(Person { + name: String, + age: u8 + }); + assert_struct_parity!(Single { value: u64 }); + assert_struct_parity!(Trailing { a: u8, b: u16 }); + assert_struct_parity!(Raw { + r#type: u8, + r#match: u16 + }); + assert_struct_parity!(EmptyNamed {}); +} + +#[test] +fn tuple_bodies_match_the_derive() { + assert_struct_parity!(Pair(u64, String)); + assert_struct_parity!(Triple(u8, u16, u32)); + assert_struct_parity!(Newtype(u64)); + assert_struct_parity!(NewtypeTrailing(u64,)); + assert_struct_parity!(Paths(core::marker::PhantomData, ::core::primitive::u8)); + assert_struct_parity!(EmptyTuple()); +} + +#[derive(HasFields)] +pub struct Unit; + +#[test] +fn a_unit_struct_matches_an_empty_body() { + assert_same_type::<::Fields, Struct! {}>(); +} + +#[derive(HasFields)] +pub struct Wrapper { + pub value: T, + pub count: usize, +} + +#[derive(HasFields)] +pub struct GenericPair(pub A, pub B); + +fn generic_structs_match() { + assert_same_type::< as HasFields>::Fields, Struct! { value: A, count: usize }>(); + assert_same_type::< as HasFields>::Fields, Struct!(A, B)>(); +} + +#[test] +fn generic_structs_match_the_derive() { + generic_structs_match::(); +} + +#[derive(HasFields)] +pub struct Borrowed<'a> { + pub text: &'a str, + pub count: u32, +} + +// `FieldsRef<'b>` borrows each field for `'b`, so a field that is already a +// reference gains a second layer. +fn borrowed_shapes_match<'a: 'b, 'b, T: 'b>() { + assert_same_type::< + as HasFieldsRef>::FieldsRef<'b>, + Struct! { value: &'b T, count: &'b usize }, + >(); + + assert_same_type::< + as HasFieldsRef>::FieldsRef<'b>, + Struct! { text: &'b &'a str, count: &'b u32 }, + >(); +} + +#[test] +fn borrowed_shapes_match_the_derive() { + borrowed_shapes_match::<'static, 'static, u8>(); +} diff --git a/crates/tests/cgp-tests/tests/shape_macros/struct_hygiene.rs b/crates/tests/cgp-tests/tests/shape_macros/struct_hygiene.rs new file mode 100644 index 000000000..923e0aff5 --- /dev/null +++ b/crates/tests/cgp-tests/tests/shape_macros/struct_hygiene.rs @@ -0,0 +1,33 @@ +//! `Struct!` and `Enum!` emit every CGP name they need through its fully +//! qualified path, so a module that imports nothing from `cgp` can still use +//! them by path. The `without_prelude` module below has no `use` items at all. +//! +//! See cgp-knowledge-base/cgp/implementation/entrypoints/struct.md. + +use cgp::prelude::*; + +use crate::shape_macros::assertions::assert_same_type; + +mod without_prelude { + pub type Point = cgp::prelude::Struct! { x: f64, y: f64 }; + + pub type Pair = cgp::prelude::Struct!(u8, u16); + + pub type Choice = cgp::prelude::Enum! { Left(u8), Right }; +} + +#[test] +fn the_shape_macros_need_no_imports() { + assert_same_type::< + without_prelude::Point, + Product![Field, Field], + >(); + + assert_same_type::, u8>, Field, u16>]>( + ); + + assert_same_type::< + without_prelude::Choice, + Sum![Field, Field], + >(); +} diff --git a/crates/tests/cgp-tests/tests/shape_macros/struct_in_signatures.rs b/crates/tests/cgp-tests/tests/shape_macros/struct_in_signatures.rs new file mode 100644 index 000000000..5f02fafe4 --- /dev/null +++ b/crates/tests/cgp-tests/tests/shape_macros/struct_in_signatures.rs @@ -0,0 +1,117 @@ +//! A `Struct!` is an ordinary type, so it can stand anywhere a type can: an +//! associated-type binding, a `where` clause, an argument or return type, the +//! self type of an impl, an associated type, and a wiring entry. The brace form +//! is the case worth pinning in an impl header, where its body sits directly +//! before the impl's own braces. +//! +//! Inside `#[cgp_component]` and `#[cgp_impl]`, `Self` written in a shape's body +//! is rewritten to the context like any other `Self`, because that rewrite +//! reaches into macro tokens. +//! +//! See cgp-knowledge-base/cgp/implementation/entrypoints/struct.md. + +use cgp::prelude::*; + +use crate::shape_macros::assertions::assert_same_type; + +#[derive(Debug, PartialEq, HasFields)] +pub struct Person { + pub name: String, + pub age: u8, +} + +pub trait Describe { + fn describe() -> &'static str; +} + +impl Describe for Struct! { name: String, age: u8 } { + fn describe() -> &'static str { + "a person's shape" + } +} + +impl Describe for Struct!(u8, u16) { + fn describe() -> &'static str { + "a pair's shape" + } +} + +pub fn describe_shape_of() -> &'static str +where + T: HasFields, +{ + ::describe() +} + +pub fn rebuild(fields: Struct! { name: String, age: u8 }) -> T +where + T: FromFields, +{ + T::from_fields(fields) +} + +pub fn empty_shape() -> Struct! {} { + Nil +} + +pub trait HasShape { + type Shape; +} + +impl HasShape for Person { + type Shape = Struct! { name: String, age: u8 }; +} + +#[test] +fn a_shape_is_usable_in_bounds_and_signatures() { + assert_eq!(describe_shape_of::(), "a person's shape"); + assert_eq!(::describe(), "a pair's shape"); + + let person: Person = rebuild(product!["Carol".to_owned().into(), 25u8.into()]); + assert_eq!(person.age, 25); + + assert_eq!(empty_shape(), Nil); + + assert_same_type::<::Shape, ::Fields>(); +} + +#[cgp_type] +pub trait HasPointType { + type Point; +} + +#[cgp_component(ShapeMarker)] +pub trait CanMarkShape { + fn shape_marker(&self) -> PhantomData, size: usize }>; +} + +#[cgp_impl(new MarkShape)] +impl ShapeMarker { + fn shape_marker(&self) -> PhantomData, size: usize }> { + PhantomData + } +} + +pub struct App; + +delegate_components! { + App { + PointTypeProviderComponent: UseType, + ShapeMarkerComponent: MarkShape, + } +} + +check_components! { + App { + PointTypeProviderComponent, + ShapeMarkerComponent, + } +} + +#[test] +fn a_shape_is_usable_in_wiring_and_providers() { + assert_same_type::<::Point, Struct! { x: f64, y: f64 }>(); + + let _marker: PhantomData, size: usize }> = + App.shape_marker(); +} diff --git a/crates/tests/cgp-tests/tests/shape_macros/struct_inequality.rs b/crates/tests/cgp-tests/tests/shape_macros/struct_inequality.rs new file mode 100644 index 000000000..50d3452ab --- /dev/null +++ b/crates/tests/cgp-tests/tests/shape_macros/struct_inequality.rs @@ -0,0 +1,36 @@ +//! Shapes that look alike are distinct types. Field order is part of the type, +//! a positional field and a named field are keyed differently, a tuple body is +//! not the plain list of its types, and only a single *positional* field is +//! unwrapped to its bare type. +//! +//! The compiler cannot assert that two types differ, so these compare `TypeId`s +//! at run time. +//! +//! See cgp-knowledge-base/cgp/implementation/entrypoints/struct.md. + +use cgp::prelude::*; + +use crate::shape_macros::assertions::assert_type_ne; + +#[test] +fn field_order_is_part_of_the_type() { + assert_type_ne::(); + assert_type_ne::(); +} + +#[test] +fn positional_and_named_fields_are_keyed_differently() { + assert_type_ne::(); + assert_type_ne::(); +} + +#[test] +fn a_tuple_body_is_not_the_plain_list_of_its_types() { + assert_type_ne::(); +} + +#[test] +fn only_a_single_positional_field_is_unwrapped() { + assert_type_ne::(); + assert_type_ne::(); +} diff --git a/crates/tests/cgp-tests/tests/shape_macros/struct_named.rs b/crates/tests/cgp-tests/tests/shape_macros/struct_named.rs new file mode 100644 index 000000000..41813a88c --- /dev/null +++ b/crates/tests/cgp-tests/tests/shape_macros/struct_named.rs @@ -0,0 +1,102 @@ +//! `Struct!` with a named-field body expands to a `Product!` of `Field` entries +//! keyed by `Symbol!`, in declaration order. +//! +//! Besides the plain expansion, this pins the corner cases of the named form: an +//! empty body is `Nil`, a single named field stays a one-element list (only a +//! single *positional* field is unwrapped), a raw identifier is tagged by its +//! logical name, and field types that contain commas, parentheses, or brackets +//! are kept whole. +//! +//! See cgp-knowledge-base/cgp/implementation/entrypoints/struct.md. + +use std::collections::HashMap; + +use cgp::prelude::*; + +use crate::shape_macros::assertions::assert_same_type; + +#[test] +fn named_fields_expand_to_a_symbol_keyed_product() { + assert_same_type::< + Struct! { name: String, age: u8 }, + Product![Field, Field], + >(); + + assert_same_type::< + Struct! { name: String, age: u8 }, + Cons, Cons, Nil>>, + >(); +} + +#[test] +fn an_empty_body_is_nil() { + assert_same_type::(); +} + +#[test] +fn a_trailing_comma_is_accepted() { + assert_same_type::(); +} + +#[test] +fn a_single_named_field_stays_a_list() { + assert_same_type::]>(); +} + +#[test] +fn a_raw_identifier_is_tagged_by_its_logical_name() { + assert_same_type::< + Struct! { r#type: u8, r#match: u16 }, + Product![Field, Field], + >(); +} + +#[test] +fn a_shape_nests_inside_a_field() { + assert_same_type::< + Struct! { inner: Struct! { x: u8 } }, + Product![Field]>], + >(); +} + +#[test] +fn compound_field_types_are_kept_whole() { + assert_same_type::< + Struct! { + map: HashMap, + callback: fn(u8, u16) -> u8, + pair: (u8, u16), + bytes: [u8; 4], + text: &'static str, + }, + Product![ + Field>, + Field u8>, + Field, + Field, + Field, + ], + >(); +} + +pub trait HasAssoc { + type Assoc; +} + +impl HasAssoc for u8 { + type Assoc = u16; +} + +// Generic parameters and associated-type projections pass through as field +// types unchanged. +fn generic_field_types() { + assert_same_type::< + Struct! { plain: T, assoc: ::Assoc }, + Product![Field, Field], + >(); +} + +#[test] +fn generic_field_types_pass_through() { + generic_field_types::(); +} diff --git a/crates/tests/cgp-tests/tests/shape_macros/struct_round_trip.rs b/crates/tests/cgp-tests/tests/shape_macros/struct_round_trip.rs new file mode 100644 index 000000000..0d9e78f8f --- /dev/null +++ b/crates/tests/cgp-tests/tests/shape_macros/struct_round_trip.rs @@ -0,0 +1,59 @@ +//! A value of a `Struct!` type moves in and out of the struct it describes +//! through `ToFields` and `FromFields`, and a value built by hand with +//! `product!` rebuilds the struct, which is what makes the macro usable for +//! naming a shape in code that converts values. +//! +//! See cgp-knowledge-base/cgp/implementation/entrypoints/struct.md. + +use cgp::prelude::*; + +#[derive(Debug, PartialEq, HasFields)] +pub struct Person { + pub name: String, + pub age: u8, +} + +#[derive(Debug, PartialEq, HasFields)] +pub struct Pair(pub u64, pub String); + +#[test] +fn a_named_struct_round_trips_through_its_shape() { + let person = Person { + name: "Alice".to_owned(), + age: 30, + }; + + let fields: Struct! { name: String, age: u8 } = person.to_fields(); + + let Cons(name, Cons(age, Nil)) = &fields; + assert_eq!(name.value, "Alice"); + assert_eq!(age.value, 30); + + assert_eq!( + Person::from_fields(fields), + Person { + name: "Alice".to_owned(), + age: 30, + } + ); +} + +#[test] +fn a_tuple_struct_round_trips_through_its_shape() { + let fields: Struct!(u64, String) = Pair(7, "seven".to_owned()).to_fields(); + + assert_eq!(Pair::from_fields(fields), Pair(7, "seven".to_owned())); +} + +#[test] +fn a_value_built_by_hand_rebuilds_the_struct() { + let fields: Struct! { name: String, age: u8 } = product!["Bob".to_owned().into(), 40u8.into()]; + + assert_eq!( + Person::from_fields(fields), + Person { + name: "Bob".to_owned(), + age: 40, + } + ); +} diff --git a/crates/tests/cgp-tests/tests/shape_macros/struct_through_macro_rules.rs b/crates/tests/cgp-tests/tests/shape_macros/struct_through_macro_rules.rs new file mode 100644 index 000000000..42d809f5e --- /dev/null +++ b/crates/tests/cgp-tests/tests/shape_macros/struct_through_macro_rules.rs @@ -0,0 +1,56 @@ +//! A `Struct!` or `Enum!` written by another macro receives its fields as +//! forwarded fragments. A `$ty:ty` fragment arrives wrapped in a token group +//! with no visible delimiter, so this pins that the named and tuple forms are +//! still read correctly from such input. +//! +//! See cgp-knowledge-base/cgp/implementation/entrypoints/struct.md. + +use core::marker::PhantomData; + +use cgp::prelude::*; + +use crate::shape_macros::assertions::assert_same_type; + +macro_rules! named_shape { + ($($name:ident : $ty:ty),* $(,)?) => { + Struct! { $($name: $ty),* } + }; +} + +macro_rules! tuple_shape { + ($($ty:ty),* $(,)?) => { + Struct!( $($ty),* ) + }; +} + +macro_rules! variant_shape { + ($($variant:ident ( $ty:ty )),* $(,)?) => { + Enum! { $($variant($ty)),* } + }; +} + +#[test] +fn forwarded_named_fields_stay_named() { + assert_same_type::< + named_shape!(width: f64, height: Vec), + Product![Field, Field>], + >(); +} + +#[test] +fn forwarded_types_stay_positional() { + assert_same_type::< + tuple_shape!(u8, core::marker::PhantomData), + Product![Field, u8>, Field, PhantomData>], + >(); + + assert_same_type::), Vec>(); +} + +#[test] +fn forwarded_variants_keep_their_payloads() { + assert_same_type::< + variant_shape!(Circle(f64), Square(core::marker::PhantomData)), + Sum![Field, Field>], + >(); +} diff --git a/crates/tests/cgp-tests/tests/shape_macros/struct_tuple.rs b/crates/tests/cgp-tests/tests/shape_macros/struct_tuple.rs new file mode 100644 index 000000000..7072e3fb8 --- /dev/null +++ b/crates/tests/cgp-tests/tests/shape_macros/struct_tuple.rs @@ -0,0 +1,81 @@ +//! `Struct!` with a tuple body expands to a `Product!` of `Field` entries keyed +//! by `Index`, mirroring a tuple struct's `HasFields` shape. +//! +//! The derive's newtype rule applies: a body with exactly one positional field +//! is that field's type, with no `Field` wrapper and no list. A trailing comma +//! does not change this, so `Struct!(T,)` is `T` too, unlike the tuple type +//! `(T,)`. The form is read from the entries rather than the delimiter, so this +//! file also pins that path types such as `core::marker::PhantomData`, +//! whose `::` begins with a `:`, are read as positional fields. +//! +//! See cgp-knowledge-base/cgp/implementation/entrypoints/struct.md. + +use core::fmt::Debug; +use core::marker::PhantomData; + +use cgp::prelude::*; + +use crate::shape_macros::assertions::assert_same_type; + +#[test] +fn positional_fields_expand_to_an_index_keyed_product() { + assert_same_type::, u64>, Field, String>]>( + ); + + assert_same_type::< + Struct!(u8, u16, u32), + Product![ + Field, u8>, + Field, u16>, + Field, u32> + ], + >(); +} + +#[test] +fn an_empty_tuple_body_is_nil() { + assert_same_type::(); +} + +#[test] +fn one_positional_field_is_its_bare_type() { + assert_same_type::(); + assert_same_type::(); +} + +#[test] +fn path_types_are_positional_fields() { + assert_same_type::< + Struct!(core::marker::PhantomData, ::core::primitive::u8), + Product![Field, PhantomData>, Field, u8>], + >(); + + assert_same_type::, u8>, Field, bool>]>(); +} + +#[test] +fn trait_object_and_function_types_are_positional_fields() { + assert_same_type::< + Struct!(Box u8>, &'static dyn Debug), + Product![ + Field, Box u8>>, + Field, &'static dyn Debug> + ], + >(); +} + +pub struct Holder; + +impl Holder { + // `Self` is a keyword rather than a field name, so it starts a positional + // field. + fn self_is_a_positional_field() { + assert_same_type::, Holder>, Field, u8>]>( + ); + } +} + +#[test] +fn self_is_a_positional_field() { + Holder::self_is_a_positional_field(); +} diff --git a/crates/tests/cgp-tests/tests/shape_macros_tests.rs b/crates/tests/cgp-tests/tests/shape_macros_tests.rs new file mode 100644 index 000000000..4d888001b --- /dev/null +++ b/crates/tests/cgp-tests/tests/shape_macros_tests.rs @@ -0,0 +1,17 @@ +//! Entrypoint for the `shape_macros` concept. +//! +//! Covers the `Struct!` and `Enum!` type-level shape macros, which build the +//! `HasFields` shape of a struct or enum from the body of its declaration. Most +//! tests are type equalities: against the hand-written `Product!`/`Sum!` list, +//! and against the `Fields` that `#[derive(HasFields)]` gives the same body. +//! Neither macro has a snapshot, since each emits a single type. +//! +//! See cgp-knowledge-base/cgp/reference/macros/struct.md, +//! cgp-knowledge-base/cgp/reference/macros/enum.md, and +//! cgp-knowledge-base/cgp/implementation/entrypoints/struct.md. +#![allow(dead_code)] +// Clippy measures a shape by its expansion, where each field name is a deep +// `Symbol>` chain, so `type_complexity` fires on ordinary shapes. +#![allow(clippy::type_complexity)] + +pub mod shape_macros; From 3fb2b2499f55567fd1e82cbe3c0ecf436d647b5b Mon Sep 17 00:00:00 2001 From: Soares Chen Date: Tue, 6 Oct 2026 15:36:18 +0200 Subject: [PATCH 3/4] List the shape modules in the cgp-macro-core module map Co-Authored-By: Claude Opus 5.5 --- crates/macros/cgp-macro-core/AGENTS.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/macros/cgp-macro-core/AGENTS.md b/crates/macros/cgp-macro-core/AGENTS.md index a88d05e35..c00530e7d 100644 --- a/crates/macros/cgp-macro-core/AGENTS.md +++ b/crates/macros/cgp-macro-core/AGENTS.md @@ -32,12 +32,15 @@ and the crate hierarchy. - **[src/types/](src/types/)** — the bulk of the crate. One submodule per user-facing macro (`cgp_component`, `cgp_impl`, `cgp_provider`, `cgp_fn`, `cgp_getter`, `cgp_auto_getter`, `cgp_type`, `cgp_data`, `delegate_component`, `check_components`, - `delegate_and_check_components`, `namespace`, `product`, `sum`), plus shared building-block types: + `delegate_and_check_components`, `namespace`, `product`, `sum`, and `shape` for `Struct!`/`Enum!`), + plus shared building-block types: `attributes/` (parsing of `#[uses]`, `#[use_type]`, `#[use_provider]`, `#[derive_delegate]`, `#[default_impl]`), `generics/`, `field/`, `getter/`, `implicits/`, `ident/`, `path/`, `keyword`. - **[src/functions/](src/functions/)** — free helper functions: identifier case conversion (`camel_case`/`snake_case`), `parse_internal`, generics merging, delegated-impl synthesis, - field/getter/implicit-argument parsing, `strip`. + field/getter/implicit-argument parsing, `strip`, the `Struct!`/`Enum!` body parsing and + validation in `shape/`, and `reject_non_empty_attributes`, shared by the macros that accept no + attributes. - **[src/visitors/](src/visitors/)** — `syn` `VisitMut` passes that rewrite ASTs. `replace_self` (receiver/type/value) is the heart of `#[cgp_impl]`: it rewrites `self`/`Self` into the explicit `context`/`Context`. Also `replace_provider`, `remove_self_path`, `self_assoc_type`, and From fee3b9ca7eeb7afae54eeb5470ab005ea01f9280 Mon Sep 17 00:00:00 2001 From: Soares Chen Date: Tue, 6 Oct 2026 15:37:32 +0200 Subject: [PATCH 4/4] Pin a named field whose type starts with `::` Co-Authored-By: Claude Opus 5.5 --- .../tests/shape_macro_parsing/struct_form.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/tests/cgp-macro-tests/tests/shape_macro_parsing/struct_form.rs b/crates/tests/cgp-macro-tests/tests/shape_macro_parsing/struct_form.rs index d44d857bb..9cfa767b2 100644 --- a/crates/tests/cgp-macro-tests/tests/shape_macro_parsing/struct_form.rs +++ b/crates/tests/cgp-macro-tests/tests/shape_macro_parsing/struct_form.rs @@ -48,6 +48,12 @@ fn name_type_entries_are_named() { form_of(quote!(value: core::marker::PhantomData,)), Form::Named(vec!["value".to_owned()]) ); + // The field's `:` is followed by a type that itself starts with `::`, so the entry is named + // even though a `::` comes right after the colon. + assert_eq!( + form_of(quote!(value: ::core::primitive::u8)), + Form::Named(vec!["value".to_owned()]) + ); } #[test]