Repository navigation
Add the Struct! and Enum! type-level shape macros - #276
Merged
Merged
Conversation
`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<LEN, Chars<…>>` expansion.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
soareschen
added a commit
that referenced
this pull request
Oct 6, 2026
This PR improves the error messages of the `Struct!` and `Enum!` shape macros (#276) for three mistakes a user is likely to make. A keyword used as a field name now gets a message that says so and suggests the raw form. A field inside an `Enum!` variant now goes through exactly the checks and messages a `Struct!` field does. A variant whose fields contradict its brackets says which form the brackets require. No input that compiled before changes its meaning. Every case below was already rejected, and only the message changes. ## Background: what the shape macros do `Struct!` and `Enum!` write the type-level **shape** of a struct or enum as the body of its declaration. A shape is the `Fields` type that `#[derive(HasFields)]` generates: a list of `Field` entries, one per field or variant, each tagged by its name. Generic code names a shape in bounds such as `T: FromFields<Fields = …>`, and the macros let that code write the shape in the form a Rust programmer already reads: ```rust Struct! { name: String, age: u8 } // Product![Field<Symbol!("name"), String>, Field<Symbol!("age"), u8>] Enum! { Circle(f64), Rect { w: f64 } } // Sum![Field<Symbol!("Circle"), f64>, Field<Symbol!("Rect"), Struct! { w: f64 }>] ``` Each macro reads its body much as `rustc` reads a declaration, then rejects the parts a shape has no use for (attributes, visibility, `_`, duplicate names, discriminants) with its own message. This PR closes three gaps in that parsing. ## What changes for the user **A keyword field name says what is wrong.** A keyword is not an identifier, so `Struct!` read `type: u8` as a positional entry, and `type` then failed to parse as a type. The message listed every token a type may start with and never mentioned the name. A keyword that has no raw form, such as `self`, failed with an unrelated "expected `,`". | Input | Before | After | | --- | --- | --- | | `Struct! { type: u8 }` | ``expected one of: `for`, parentheses, `fn`, …`` | `` `type` is a keyword: write the field name as `r#type` `` | | `Struct! { self: u8 }` | ``expected `,` `` | `` `self` cannot be a field name `` | `self`, `Self`, `super`, and `crate` get the second message, since `r#self` and the others are not valid Rust. **An `Enum!` variant's fields follow the `Struct!` rules, with the same messages.** `Enum!` read a variant's fields with `syn`'s own struct-body parsers, which skip the literal and keyword checks that `Struct!` runs. The same mistake therefore got a different message inside a variant: | Input | Before | After | | --- | --- | --- | | `Enum! { V(1) }` | ``expected one of: `for`, parentheses, `fn`, …`` | `expected a type: a type-level shape lists field types, not values` | | `Enum! { V { a: 1 } }` | ``expected one of: `for`, parentheses, `fn`, …`` | `expected a type: a type-level shape lists field types, not values` | | `Enum! { V { type: u8 } }` | ``expected identifier, found keyword `type` `` | `` `type` is a keyword: write the field name as `r#type` `` | **A variant whose fields contradict its brackets says which form the brackets require.** Inside `Enum!`, a variant's brackets keep their Rust meaning: braces hold named fields and parentheses hold bare types. This differs from the brackets around the whole `Struct!` or `Enum!` invocation, which a procedural macro cannot see and which therefore do not matter. A mismatch used to surface as a missing punctuation token: | Input | Before | After | | --- | --- | --- | | `Enum! { V { u8 } }` | ``expected `:` `` | ``a variant in braces holds named fields: write each entry as `name: Type` `` | | `Enum! { V(a: u8) }` | ``expected `,` `` | ``a variant in parentheses holds bare types: write named fields in braces, as in `Variant { name: Type }` `` | ## How it works The changes are in [`functions/shape/parse_fields.rs`](https://github.com/contextgeneric/cgp/blob/main/crates/macros/cgp-macro-core/src/functions/shape/parse_fields.rs), with a small change to `parse_variant` in [`types/shape/enum_type.rs`](https://github.com/contextgeneric/cgp/blob/main/crates/macros/cgp-macro-core/src/types/shape/enum_type.rs). **Keyword detection sits in the entry classifier.** `peek_named_entry` decides whether an entry is `name: Type` or a bare type before parsing it. Its `peek(Ident)` test, which `syn` defines to exclude keywords, stays as it was. When that test fails, the classifier now also peeks with `Ident::peek_any`, and a keyword followed by a single `:` (not `::`) is reported at the keyword's span. The existing checks are unchanged. A raw identifier still counts as a name, `_` still reaches its own rejection, and a path type such as `crate::Foo` or `Self::Assoc` is still a positional field, because its `:` begins a `::`. **`Enum!` variants now share the `Struct!` loop.** The loop that reads a `Struct!` body is factored into a private `parse_entries`, behind two entry points: - `parse_shape_fields` reads a `Struct!` body. The first entry decides the form, and a later entry of the other form is reported as mixed, exactly as before. - `parse_variant_fields` reads the inside of a variant's braces or parentheses. The delimiter fixes the form, and an entry of the other form is reported against that delimiter. Every entry in both paths goes through `peek_named_entry` and then `validate_shape_fields`, so a field rule and its message cannot differ between a struct body and a variant. An empty variant list keeps its delimiter's form (`V()` parses as empty positional fields, `V {}` as empty named fields), as `syn`'s parsers did before. All three spellings, `V`, `V()`, and `V {}`, still encode to the same `Nil` payload. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR adds two type-level macros,
Struct!andEnum!, that let you write the shape of a struct orenum the way you write its declaration.
Struct! { name: String, age: u8 }is a type: the same typethat
#[derive(HasFields)]produces for a struct with those fields.Enum! { Circle(f64), Square(f64) }is the equivalent for an enum. Both macros are exported from
cgp::prelude.What a shape is
CGP's extensible data types let generic code build and read a struct or enum by its named fields and
variants, without naming the concrete type.
#[derive(HasFields)]provides this by giving each typean associated type,
Fields, that lists its fields at the type level. That type is the type'sshape. Generic code names a shape whenever it needs a particular structure, for example in
T: FromFields<Fields = …>to accept any type that has a given set of fields.Before this PR, the only way to write a shape by hand was to spell out its encoding. A struct's shape
is a
Product!(a type-level list) ofFieldentries, and each entry pairs a field type with atype-level tag naming the field:
The hand-written encoding is long, repeats every field name as a
Symbol!, and does not resemblethe struct it describes. The new macros let the reader see the struct body directly.
How the macros behave
Struct!accepts the body of a struct declaration, in either the named form or the tuple form.Named fields are tagged by their names, positional fields by their positions as
Index<N>, and theentries keep their declaration order:
Struct! { name: String, age: u8 }Product![Field<Symbol!("name"), String>, Field<Symbol!("age"), u8>]Struct!(u64, String)Product![Field<Index<0>, u64>, Field<Index<1>, String>]Struct!(u64)u64Struct! { value: u64 }Product![Field<Symbol!("value"), u64>]Struct! {}NilEnum!accepts the body of an enum declaration. Each variant becomes an entry tagged by the variantname, and the entries form a
Sum!(a type-level choice) instead of a list. A variant's payload isits fields, encoded by the
Struct!rules:Enum! { … }Empty(orEmpty(),Empty {})NilCircle(f64)f64Pair(u32, u32)Struct!(u32, u32)Rect { width: f64, height: f64 }Struct! { width: f64, height: f64 }So
Enum! { Circle(f64), Square(f64) }isSum![Field<Symbol!("Circle"), f64>, Field<Symbol!("Square"), f64>], andEnum! {}isVoid.These behaviors are the ones a reader is most likely to need:
Fields, exactly. For any body,Struct! { … }andEnum! { … }arethe same type as
<T as HasFields>::Fieldsfor a typeTdeclared with that body. The macros runthe derive's own encoder, so the two cannot disagree.
Struct!(u64)isu64, not a one-elementlist, because the derive encodes a newtype such as
struct Wrap(u64);that way. A trailing commadoes not change this (
Struct!(u64,)is alsou64), and a single named field stays a list.Struct!decides the form from the entries:
name: Typeentries make a named body, and bare types make atuple body.
Struct!(a: u8)therefore equalsStruct! { a: u8 }. By convention, write braces fornamed fields and parentheses for a tuple body. Inside
Enum!, a variant's own parentheses or braceskeep their Rust meaning.
product!, take one from a struct withto_fields(), or select an enum variant with nestedEither::Left/Either::Right. The parserrejects
Struct! { a: 1 }with a message saying that it lists types, not values.visibility, the
_field name, duplicate names, aStruct!body that mixes named and positionalentries, and
Enum!discriminants each produce a spanned error.self type of an impl, associated types, wiring entries, and
opendispatch keys such as@ShapeDescriberComponent.Struct! { x: f64, y: f64 }: DescribePoint.This example shows a typical use. The function rebuilds any type whose fields are a
nameand anage:Why now
cargo-cgpshortens longField/Symbol!lists inits diagnostics into a struct-body notation, but that notation was not valid Rust before this PR.
With
Struct!andEnum!defined, the notation is real syntax, and the companioncargo-cgpchange prints exactly these macros. A reader can therefore copy a shape from an error message or
from
cargo cgp expandinto code.Implementation
The macros reuse the derive's encoder instead of adding a second encoding.
StructTypeholds thesyn::Fieldsthat a struct declaration would hold, andEnumTypeholds itssyn::Variantlist.Their
evalmethods callitem_fields_to_product_typeandvariants_to_sum_type, the functionsthat
#[derive(HasFields)]runs, so the derive's newtype, empty-body, and raw-identifier rulesapply without any extra code.
Struct!detects its form by inspecting each entry before parsing it. It skips the entry'sattributes and visibility, so those reach their own error messages, and then treats the entry as
named when an identifier or
_is followed by a single:. The check must exclude::explicitly, because
synalso peeksToken![:]successfully on the first half of a::. Withoutthe exclusion,
core::marker::PhantomData<u8>would be read as a field namedcore. Every entrymust have the same form as the first one.
Enum!reads each variant with its own small parser instead ofsyn::Variant::parse. That parsersilently discards a
puband accepts a discriminant, but a shape must reject both. The variant'sfields then go through
syn's struct-body parsers and the samevalidate_shape_fieldscheck thatStruct!uses.The generated paths (
Cons,Field,Symbol,Chars,Index,Either,Nil,Void) areemitted through the
exportsmarkers, so the macros work in a module that imports nothing fromcgp.Changes
The implementation follows the crate's usual entrypoint and core split:
cgp-macro-core:types/shape/holdsStructTypeandEnumType.functions/shape/holdsparse_shape_fieldsandvalidate_shape_fields.reject_non_empty_attributesmoved fromdelegate_component/validate_attributes.rstofunctions/reject_attributes.rsso that bothmacro families share it.
cgp-macro-lib: TheStructandEnumentry functions.cgp-macro: TheStruct!andEnum!proc macros, with doc comments. This change also fixesthe
Symbol!doc comment, which named a nonexistentChartype and omitted the lengthparameter. It now shows
Symbol<5, Chars<…>>.cgp-coreprelude: It re-exportsStructandEnum.cgp-macro-core/AGENTS.mdlists the new modules.crates/tests/AGENTS.mdrecords
shape_macrosas the owning test target.Tests
Each macro emits a single type rather than items, so the tests check type equality instead of
using expansion snapshots. The new
shape_macrostarget incgp-testschecks each expansionagainst hand-written
Product!/Sum!lists and against the derive. Thecgp-macro-teststargetcovers the parsers:
macro_rules!passes one body to both#[derive(HasFields)]and theshape macro, then asserts that the types match. The cases include named, tuple, newtype, raw,
empty, and every-variant-shape bodies, plus generic types and the borrowed
FieldsRefshape.single named field staying a list, raw names, nesting, and compound field types.
TypeIdchecksconfirm that field order, named versus positional keys, and the newtype rule each change the type.
a::or::as positional fields, a named field whose type starts with::,dynand function-pointertypes,
Self, and fields passed through$ty:tyfragments inmacro_rules!.ToFields/FromFields, shapes in bounds,signatures, impl headers, associated types, and
UseTypewiring, andSelfrewriting inside#[cgp_component]/#[cgp_impl]. They also cover a shape as anopendispatch key and as acheck_components!entry, and a module that uses the macros without any imports.parser_rejections/struct_macro.rsandparser_rejections/enum_macro.rscheckeach rejection message.
shape_macro_parsing/calls the parsers directly to check the form theydetect.
All of these checks pass:
cargo +nightly fmt --all -- --check-D warningscargo nextest run -p cgp-tests -p cgp-macro-tests --all-features, the whole suite of 656 testsCompanion changes in sibling repositories
The documentation and tooling for the macros live in the sibling repositories:
cgp-knowledge-base:cgp/reference/macros/struct.mdandenum.md.cgp/implementation/entrypoints/struct.mdandenum.md, and theasts/shape.mdstack.Product!,Sum!,HasFields, and the derive.summary.md.cgp-skills: The/cgpprimer, its"Choosing a construct" table, and the
macro-grammar,type-level-primitives, andextensible-datasub-skills.cargo-cgp: Resugaring prints a field list asStruct!/Enum!in typed diagnostics, in the text fallback, and incargo cgp expand. Eachvariant uses its shortest spelling. The tool keeps the
Product!form when a list has noStruct!spelling: a singleIndexentry, mixed tag kinds, or tags that are not identifiers.The change also adds the length check that the typed
decode_symbolwas missing. New UIfixtures:
ok/shape_macros,acceptable/wiring/missing-wiring/enum_variant_shapes_chain, andacceptable/resolution/fallback_shape_resugar.cgp-website, branchv0.8.0): The reference pagesdocs/reference/macros/struct.mdandenum.md, linked from theFieldandIndexpages.cargo-cgp's UI fixtures compile against../cgp, and the website's example code depends on thesemacros. Both need this PR on
mainbefore their CI passes.Known gaps
These issues do not block the PR. The relevant documents and follow-ups record them:
#[use_type]aliases are not rewritten inside the macro body. In an item with#[use_type(HasErrorType.Error)], a bareErrorinsideStruct! { … }is left unchanged andfails with
E0425. The#[use_type]substitution visitor does not visit macro tokens, so thisaffects every type-level macro. The issue is recorded under
#[use_type]Known issues. Theworkaround is to write
<Self as HasErrorType>::Error.Enum!literal check is missing.Enum!variant fields do not get theliteral-in-type-position check.
Enum! { V(1) }is still rejected, but withsyn's generic"expected one of …" message instead of the dedicated one. The knowledge base's
reference/macros/enum.mdcurrently claims thatEnum!applies everyStruct!field rule.r#, as inStruct! { type: u8 }, is detected as a positional entry. It also gets the generic messageinstead of a suggestion to write
r#type.The risk looks low: the resolvable paths are emitted at the call-site span, and only punctuation
and literals take the span of the user's field name.