Skip to content

Unions - #124

Merged
philpax merged 2 commits into
mainfrom
feat/unions
Jul 26, 2026
Merged

Unions#124
philpax merged 2 commits into
mainfrom
feat/unions

Conversation

@philpax

@philpax philpax commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Closes #119.

A union is a set of competing readings of the same bytes. Two forms:

/// A value whose bytes have several competing readings.
#[copyable]
pub union Payload {
    /// Read as a signed integer.
    pub as_int: i32,
    pub as_float: f32,
    pub as_ptr: *mut void,
}

pub type Scratch {
    pub tag: u16,
    pub _reserved: unknown<0x6>,

    // inline; the field name supplies the generated item's name
    pub data: union {
        pub as_u64: u64,
        pub as_bytes: [u8; 8],
    },
}

Discriminant-driven member selection stays out of scope, per the issue. A union says what the bytes could be; the consumer decides which reading applies.

Design

A union is a new item kind, not a new kind of Region. Region carries no offset — a Vec<Region>'s order is the layout, and six independent accumulators recompute offsets by summing sizes (resolve.rs's Regions::push, the alignment sweep in type_definition/build.rs, generic folding in type_registry/aliases.rs, json/convert.rs, the LSP's field_offset, and the viewer's FieldSourceView). Teaching all six about overlapping regions is where the bugs would live. A self-contained UnionDefinition with its own size and alignment, held by a parent as one perfectly ordinary Region, leaves every one of those assumptions true — and being a distinct struct rather than TypeDefinition { is_union: true } turns each of the ~30 exhaustive match sites into a compile error rather than a silent fallthrough.

Inline unions become module-scope siblings named {Type}{Field}Union, mirroring the generated {Name}Vftable structs. A genuinely nested path isn't available: ResolutionContext::add_item resolves an item's parent directly against the module map, and declaring_module only learns nested paths through the grammar walks that populate item_scopes — so a generated nested item would be dropped from every module and reach no backend.

Alignment defaults differently from a type's. A type with no implied alignment falls back to the pointer size; a union of two u8s is genuinely 1-aligned, and widening it would inflate its size.

Lowering

  • Rust — a real union, every member in ManuallyDrop<T> unconditionally (so a member's spelling doesn't depend on whether its type happens to be #[copyable]), with hand-written Debug and, under #[defaultable], Default. A union can derive neither; emitting them anyway is what keeps a containing struct's own #[derive(Debug, Default)] working.
  • C++ — a native union, with alignas, #pragma pack, static_asserts on size and alignment, and deleted special members when #[pinned].
  • JSON — a Union item kind at schema v12, every member at offset: 0.

Rejections

#[base], vftable blocks, #[address] on a member, a member named _, an empty body, a member larger than #[size], and nested declarations inside an inline union (whose item is synthesised after the walks that would register them — declare it in the enclosing type, or name the union).

Generated-name collisions (second commit)

Reviewing the union work turned up that this isn't a union problem — {Name}Vftable has the same hazard and predates it. This compiles today:

pub type FooVftable { pub sentinel: [u8; 64], }
pub type Foo { vftable { pub fn f(&mut self); }, }

The registry is a map, so the generated table loses the insert. Foo::vftable() hands back a pointer to the user's 64-byte struct, and nothing anywhere says so. The union case at least failed to compile, via a size-check transmute.

The check now lives in ResolutionContext::add_generated_item, which both synthesis sites use, so it covers {Name}Vftable and {Type}{Field}Union alike — a declared item, another generated one, two fields whose names differ only in underscores (b_c/b__c), or two types meeting in the middle (A+b_c and AB+c both give ABCUnion). docs/language.md gains a Generated names section, since the rule is now uniform.

No pyxis-defs project trips it.

#[size]/#[min_size] asking for more room than any member needs adds a whole-width _padding member: a union has no tail to pad, and without it both backends would emit a size assertion they cannot satisfy. Rounding up to the alignment needs no such help — Rust and C++ do that for a union themselves.

Surfaces

Parser, tokenizer, semantic IR, all three backends, pretty-printer, doc-link resolver, tree-sitter grammar (pushed to ferrobrew/tree-sitter-pyxis as 48eb1b7, with the submodule and extension.toml re-pinned), LSP (completion, hover, outline, references, navigation), the viewer, and docs/language.md / rust_backend.md / cpp_backend.md / json_backend.md.

Verification

  • python test.py green, including the doxygen pass under nix-shell — the emitted Rust crate and C++ corpus both compile, cargo doc resolves doc links into union members, and pyxis fmt --check confirms the new corpus input is format-stable.
  • 24 semantic unit tests covering layout, every attribute, and every rejection path.
  • 23/23 tree-sitter corpus tests; parser regenerated at ABI 14, as Zed's bundled runtime requires.
  • tools/check-pyxis-defs.py green for both rust and cpp across all four pyxis-defs projects — no project uses unions yet, so it's a pure regression check that the new item kind didn't disturb existing output.

Known limitation, pre-existing

A field cannot reference a nested type by name, bare or qualified — pub header: Outer::Header fails for a plain type too, not just a union. The corpus input works around it. Worth its own issue.

philpax added 2 commits July 26, 2026 16:05
A union is a set of competing readings of the same bytes, in two forms:
a standalone `pub union Name { ... }` item, and an inline anonymous
union in field position, `pub payload: union { ... },`, where the field
name supplies the generated item's name.

A union is a new item kind rather than a new kind of Region. Region
carries no offset - a Vec<Region>'s order *is* the layout, and six
independent accumulators recompute offsets by summing sizes. Giving
unions their own self-contained ItemDefinition, held by a parent as one
ordinary Region, leaves every one of those assumptions true and turns
each site that must branch into a compile error.

Inline unions desugar to a module-scope sibling `{Type}{Field}Union`,
mirroring the generated `{Name}Vftable` structs. A nested path is not an
option: ResolutionContext::add_item resolves a parent directly against
the module map, and declaring_module only learns nested paths from the
grammar walks - so a generated nested item would reach no backend.

Rust lowers to a real `union` with every member in ManuallyDrop, plus
hand-written Debug (and Default under #[defaultable]), which a union
cannot derive; writing them out is what keeps a containing struct's own
derives working. C++ lowers to a native `union`. JSON gains a Union kind
at schema v12, with every member at offset 0.

Rejected inside a union: #[base], vftable blocks, #[address] on a
member, a member named `_`, an empty body, a member larger than #[size],
and nested declarations inside an *inline* union (whose item is
synthesised after the walks that would register them). A generated name
that collides - with a declared item, or with another generated one - is
an error rather than a silent overwrite, since the enclosing type has
already measured what it would replace.

#[size]/#[min_size] asking for more room than any member needs adds a
whole-width `_padding` member: a union has no tail to pad, and without
it both backends would emit a size assertion they cannot satisfy.
Rounding up to the alignment needs no help - Rust and C++ do that for a
union themselves.

Closes #119
The collision check added with unions was in the wrong place: it guarded
the inline-union registration site, but `{Name}Vftable` has the same
hazard and predates it. Declaring

    pub type FooVftable { pub sentinel: [u8; 64], }
    pub type Foo { vftable { pub fn f(&mut self); }, }

compiles today, and is silently wrong: the registry is a map, so the
generated table loses the insert, Foo::vftable() hands back a pointer to
the user's 64-byte struct, and nothing anywhere says so. The union case
at least failed to compile, via a size-check transmute.

Move the check into a new ResolutionContext::add_generated_item, which
both synthesis sites now use, and rename the error to
GeneratedNameCollision. The batch pre-check in build_inline_union goes
away with it - two fields generating one name now collide on the second
add, like everything else.

Documents the generated-name rules in one place, since they now apply
uniformly.
@philpax
philpax merged commit df0e5b6 into main Jul 26, 2026
4 checks passed
@philpax
philpax deleted the feat/unions branch July 26, 2026 14:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unions

1 participant