Skip to main content

tagged_enum

Macro tagged_enum 

Source
macro_rules! tagged_enum {
    (write_only :: $($t:tt)*) => { ... };
    (read_only $($t:tt)*) => { ... };
    (write_only $($t:tt)*) => { ... };
    ($($t:tt)*) => { ... };
}
Expand description

Declare an enum, for every format.

A variant that carries nothing is written as its name. A variant that carries a value is written as an object of one member, keyed by that name: the tagged-union form, with the tag being the name rather than a position, so adding or reordering variants does not change what a document means.

That is external tagging, where the name wraps the payload, and it is what a declaration with no tag clause gets. Adding as tag "..." moves the name inside the payload’s object instead, as a member beside the payload’s own; see Internal tagging below.

Mark a variant that carries a value with (_). The payload’s type is not repeated here; it is already on the enum, and stating it twice would be a second place to keep in step.

#[derive(Default, PartialEq, Debug)]
struct Circle { radius: f64 }
structio::object!(Circle { radius });

#[derive(Default, PartialEq, Debug)]
enum Shape {
    #[default]
    Empty,
    Circle(Circle),
    Sides(u32),
}

structio::tagged_enum!(Shape {
    Empty,
    Circle(_),
    Sides(_),
});

assert_eq!(structio::to_string(&Shape::Empty), "\"Empty\"");
assert_eq!(
    structio::to_string(&Shape::Circle(Circle { radius: 2.0 })),
    r#"{"Circle":{"radius":2}}"#
);
assert_eq!(
    structio::from_str::<Shape>(r#"{"Sides":3}"#).unwrap(),
    Shape::Sides(3)
);

Names are renamed the same way a field is, they take aliases the same way with Variant | "other_name", they take a case rule the same way, and generics go in brackets before the type, exactly as for object!:

structio::tagged_enum!([T: structio::ReadWrite + Default] Message<T> {
    "ping" => Ping,
    "data" => Data(_),
});

§One payload, of a type you already declared

A variant carries at most one value, which is the shape a std::variant<A, B, C> has and the one that composes: the payload is an ordinary type, declared with object! or array! or built in, and the enum adds only the tag. A Rust variant with several fields, or with named fields, is not accepted; give it a struct or a tuple instead. Neither is object!’s #[required] marker, which has nothing to say here: a variant declared as carrying a value can be read only from the object form that holds one, so its payload is required by the declaration itself.

A payload type needs Default, for the reason an Option’s payload does: reading a variant the destination is not already holding has to build one before it can read into it.

structio::tagged_enum!(Span { None, Range(_) });
assert_eq!(structio::to_string(&Span::Range((1, 5))), r#"{"Range":[1,5]}"#);

The requirement stops at the payloads. The enum itself needs no Default, which is worth saying because the shape that most wants a tagged enum is the one that cannot derive it: a message union whose every variant carries something. #[derive(Default)] reaches an enum only through a variant marked #[default], and that variant has to be a unit one, so an enum like the one below would have to nominate a message as the stand-in for no message to get the derive. It does not have to. Declaring it, writing it, and reading into a value the caller already holds ask the payloads for their Default and the enum for nothing.

// The payloads carry their own `Default`, which is the part that is
// required. The enum below carries none, and is not asked for one.
#[derive(Default, PartialEq, Debug)]
struct Frame { len: u32 }
#[derive(Default, PartialEq, Debug)]
struct Ack { seq: u32 }

#[derive(PartialEq, Debug)]
enum Packet {
    Data(Frame),
    Ack(Ack),
}

structio::tagged_enum!(Packet { Data(_), Ack(_) });

let mut packet = Packet::Data(Frame { len: 0 });
structio::read_into(&mut packet, r#"{"Ack":{"seq":9}}"#).unwrap();
assert_eq!(packet, Packet::Ack(Ack { seq: 9 }));

What does ask for one is a position that constructs the enum rather than filling one that is there: from_str, which is handed nothing but a document, and a growing container, whose new elements have to exist before they can be read into. A Vec<Packet> therefore wants Packet: Default and there is no spelling of the read that avoids it, while a Box<Packet> or a [Packet; N] has no element to build and holds the enum above as it stands. Where a placeholder is unavoidable, writing it as a private constructor and reading into that keeps it out of the enum’s public API, which #[derive(Default)] would not.

§What is read back

The two forms are not interchangeable, and the asymmetry runs one way. A variant carrying nothing reads from either, so a producer that always writes the object form still round-trips. A variant carrying a value has only the object form: the name on its own leaves the value missing, which is ExpectedBrace in JSON and ExpectedObject in BEVE rather than an unknown variant, the name having been recognized and the value under it not being there.

use structio::{ErrorCode, from_str};

// A variant carrying nothing takes either form.
assert_eq!(from_str::<Shape>("\"Empty\"").unwrap(), Shape::Empty);
assert_eq!(from_str::<Shape>(r#"{"Empty":null}"#).unwrap(), Shape::Empty);

// A variant carrying a value takes the object form and only that.
assert_eq!(from_str::<Shape>(r#"{"Sides":6}"#).unwrap(), Shape::Sides(6));
assert_eq!(
    from_str::<Shape>("\"Sides\"").unwrap_err().code,
    ErrorCode::ExpectedBrace,
);

What is refused is a name no variant claims, and that is refused under every policy, including SkipUnknown. Stepping over an unknown object key still leaves the object readable; stepping over an unknown variant would leave the value itself undecided.

An object that is not exactly one member, {} or two tags at once, and a value that is neither an object nor a string, are ExpectedVariant. Under the object form a variant carrying nothing wants null specifically, so {"Empty":0} is ExpectedNull.

Reading reuses what the destination already holds when it is already the variant being read, so a loop that reads the same variant repeatedly keeps its payload’s buffers. Reading a different variant replaces the value, which is what changing variants means.

§What it expands to

One Variants impl carrying the name list and its compile-time perfect hash, and then, for each format, ReadEnum and Read/Write. Each write ends with a match over every declared variant whose arms are empty, dead code that asks the compiler to confirm the declaration names every variant the enum has, so extending the enum later and forgetting to say so here is a compile error rather than a value that silently writes nothing.

When a payload’s type cannot support both formats, declare the enum with json_tagged_enum! or beve_tagged_enum! instead.

§Further reading

docs/enums.md is the long form: every error an enum can produce and what distinguishes it from the others, how the policies meet a tag, generics and borrowed payloads, the string array a run of unit variants becomes in BEVE, and how validation, pointers and transcoding walk through one.

A declaration that leads with write_only generates the write half alone, so nothing in the type needs a Read impl or a Default; see object!.