Skip to content

Add the Struct! and Enum! type-level shape macros - #276

Merged
soareschen merged 4 commits into
mainfrom
struct-enum-macros
Oct 6, 2026
Merged

soareschen merged 4 commits into
mainfrom
struct-enum-macros

Conversation

@soareschen

@soareschen soareschen commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

This PR adds two type-level macros, Struct! and Enum!, that let you write the shape of a struct or
enum the way you write its declaration. Struct! { name: String, age: u8 } is a type: the same type
that #[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 type
an associated type, Fields, that lists its fields at the type level. That type is the type's
shape. 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) of Field entries, and each entry pairs a field type with a
type-level tag naming the field:

// The shape of `struct Person { name: String, age: u8 }`, written by hand
Product![Field<Symbol!("name"), String>, Field<Symbol!("age"), u8>]

// The same type, written with this PR
Struct! { name: String, age: u8 }

The hand-written encoding is long, repeats every field name as a Symbol!, and does not resemble
the 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 the
entries keep their declaration order:

You write It is the type
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) u64
Struct! { value: u64 } Product![Field<Symbol!("value"), u64>]
Struct! {} Nil

Enum! accepts the body of an enum declaration. Each variant becomes an entry tagged by the variant
name, and the entries form a Sum! (a type-level choice) instead of a list. A variant's payload is
its fields, encoded by the Struct! rules:

Variant in Enum! { … } Its payload
Empty (or Empty(), Empty {}) Nil
Circle(f64) f64
Pair(u32, u32) Struct!(u32, u32)
Rect { width: f64, height: f64 } Struct! { width: f64, height: f64 }

So Enum! { Circle(f64), Square(f64) } is
Sum![Field<Symbol!("Circle"), f64>, Field<Symbol!("Square"), f64>], and Enum! {} is Void.

These behaviors are the ones a reader is most likely to need:

  • A shape is the derive's Fields, exactly. For any body, Struct! { … } and Enum! { … } are
    the same type as <T as HasFields>::Fields for a type T declared with that body. The macros run
    the derive's own encoder, so the two cannot disagree.
  • A single positional field becomes its bare type. Struct!(u64) is u64, not a one-element
    list, because the derive encodes a newtype such as struct Wrap(u64); that way. A trailing comma
    does not change this (Struct!(u64,) is also u64), and a single named field stays a list.
  • The outer delimiter is ignored. A procedural macro cannot see its own delimiter, so Struct!
    decides the form from the entries: name: Type entries make a named body, and bare types make a
    tuple body. Struct!(a: u8) therefore equals Struct! { a: u8 }. By convention, write braces for
    named fields and parentheses for a tuple body. Inside Enum!, a variant's own parentheses or braces
    keep their Rust meaning.
  • A shape is a type, not a value. Build a value with product!, take one from a struct with
    to_fields(), or select an enum variant with nested Either::Left/Either::Right. The parser
    rejects Struct! { a: 1 } with a message saying that it lists types, not values.
  • Parts that have no meaning in a shape are rejected. Attributes (including doc comments),
    visibility, the _ field name, duplicate names, a Struct! body that mixes named and positional
    entries, and Enum! discriminants each produce a spanned error.
  • A shape can appear wherever a type can. This includes bounds, argument and return types, the
    self type of an impl, associated types, wiring entries, and open dispatch 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 name and an
age:

#[derive(HasFields)]
pub struct Person {
    pub name: String,
    pub age: u8,
}

pub fn rebuild<T>(fields: Struct! { name: String, age: u8 }) -> T
where
    T: FromFields<Fields = Struct! { name: String, age: u8 }>,
{
    T::from_fields(fields)
}

let person: Person = rebuild(product!["Carol".to_owned().into(), 25u8.into()]);

Why now

cargo-cgp shortens long Field/Symbol! lists in
its diagnostics into a struct-body notation, but that notation was not valid Rust before this PR.
With Struct! and Enum! defined, the notation is real syntax, and the companion cargo-cgp
change prints exactly these macros. A reader can therefore copy a shape from an error message or
from cargo cgp expand into code.

Implementation

The macros reuse the derive's encoder instead of adding a second encoding. StructType holds the
syn::Fields that a struct declaration would hold, and EnumType holds its syn::Variant list.
Their eval methods call item_fields_to_product_type and variants_to_sum_type, the functions
that #[derive(HasFields)] runs, so the derive's newtype, empty-body, and raw-identifier rules
apply without any extra code.

Struct! detects its form by inspecting each entry before parsing it. It skips the entry's
attributes 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 syn also peeks Token![:] successfully on the first half of a ::. Without
the exclusion, core::marker::PhantomData<u8> would be read as a field named core. Every entry
must have the same form as the first one.

Enum! reads each variant with its own small parser instead of syn::Variant::parse. That parser
silently discards a pub and accepts a discriminant, but a shape must reject both. The variant's
fields then go through syn's struct-body parsers and the same validate_shape_fields check that
Struct! uses.

The generated paths (Cons, Field, Symbol, Chars, Index, Either, Nil, Void) are
emitted through the exports markers, so the macros work in a module that imports nothing from
cgp.

Changes

The implementation follows the crate's usual entrypoint and core split:

  • cgp-macro-core: types/shape/ holds StructType and EnumType. functions/shape/ holds
    parse_shape_fields and validate_shape_fields. reject_non_empty_attributes moved from
    delegate_component/validate_attributes.rs to functions/reject_attributes.rs so that both
    macro families share it.
  • cgp-macro-lib: The Struct and Enum entry functions.
  • cgp-macro: The Struct! and Enum! proc macros, with doc comments. This change also fixes
    the Symbol! doc comment, which named a nonexistent Char type and omitted the length
    parameter. It now shows Symbol<5, Chars<…>>.
  • cgp-core prelude: It re-exports Struct and Enum.
  • Agent notes: cgp-macro-core/AGENTS.md lists the new modules. crates/tests/AGENTS.md
    records shape_macros as 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_macros target in cgp-tests checks each expansion
against hand-written Product!/Sum! lists and against the derive. The cgp-macro-tests target
covers the parsers:

  • Derive parity: A local macro_rules! passes one body to both #[derive(HasFields)] and the
    shape 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 FieldsRef shape.
  • Encoding rules: These tests cover the newtype rule (with and without a trailing comma), a
    single named field staying a list, raw names, nesting, and compound field types. TypeId checks
    confirm that field order, named versus positional keys, and the newtype rule each change the type.
  • Form detection: These tests cover delimiter independence, path types starting with a:: or
    :: as positional fields, a named field whose type starts with ::, dyn and function-pointer
    types, Self, and fields passed through $ty:ty fragments in macro_rules!.
  • Usage: These tests cover round trips through ToFields/FromFields, shapes in bounds,
    signatures, impl headers, associated types, and UseType wiring, and Self rewriting inside
    #[cgp_component]/#[cgp_impl]. They also cover a shape as an open dispatch key and as a
    check_components! entry, and a module that uses the macros without any imports.
  • Rejections: parser_rejections/struct_macro.rs and parser_rejections/enum_macro.rs check
    each rejection message. shape_macro_parsing/ calls the parsers directly to check the form they
    detect.

All of these checks pass:

  • cargo +nightly fmt --all -- --check
  • both clippy invocations, with -D warnings
  • cargo nextest run -p cgp-tests -p cgp-macro-tests --all-features, the whole suite of 656 tests

Companion changes in sibling repositories

The documentation and tooling for the macros live in the sibling repositories:

  • cgp-knowledge-base:
    • Reference pages cgp/reference/macros/struct.md and enum.md.
    • Implementation pages cgp/implementation/entrypoints/struct.md and enum.md, and the
      asts/shape.md stack.
    • Cross-links from Product!, Sum!, HasFields, and the derive.
    • Updated catalogs and summary.md.
  • cgp-skills: The /cgp primer, its
    "Choosing a construct" table, and the macro-grammar, type-level-primitives, and
    extensible-data sub-skills.
  • cargo-cgp: Resugaring prints a field list as
    Struct!/Enum! in typed diagnostics, in the text fallback, and in cargo cgp expand. Each
    variant uses its shortest spelling. The tool keeps the Product! form when a list has no
    Struct! spelling: a single Index entry, mixed tag kinds, or tags that are not identifiers.
    The change also adds the length check that the typed decode_symbol was missing. New UI
    fixtures: ok/shape_macros, acceptable/wiring/missing-wiring/enum_variant_shapes_chain, and
    acceptable/resolution/fallback_shape_resugar.
  • Website (cgp-website, branch v0.8.0): The reference pages
    docs/reference/macros/struct.md and enum.md, linked from the Field and Index pages.

cargo-cgp's UI fixtures compile against ../cgp, and the website's example code depends on these
macros. Both need this PR on main before 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 bare Error inside Struct! { … } is left unchanged and
    fails with E0425. The #[use_type] substitution visitor does not visit macro tokens, so this
    affects every type-level macro. The issue is recorded under #[use_type] Known issues. The
    workaround is to write <Self as HasErrorType>::Error.
  • The Enum! literal check is missing. Enum! variant fields do not get the
    literal-in-type-position check. Enum! { V(1) } is still rejected, but with syn's generic
    "expected one of …" message instead of the dedicated one. The knowledge base's
    reference/macros/enum.md currently claims that Enum! applies every Struct! field rule.
  • Keyword field names get a generic error. A keyword field name written without r#, as in
    Struct! { type: u8 }, is detected as a positional entry. It also gets the generic message
    instead of a suggestion to write r#type.
  • The editor check is not done. No one has checked go-to-definition in rust-analyzer by hand.
    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.

soareschen and others added 4 commits October 6, 2026 15:54
`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 soareschen added the AI label Oct 6, 2026
@soareschen
soareschen merged commit d81e2c1 into main Oct 6, 2026
3 checks passed
@soareschen
soareschen deleted the struct-enum-macros branch October 6, 2026 14:05
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant