Spytial for Rust

See the shape of your data, not the braces. Spytial is a drop-in replacement for std::dbg! that opens an interactive diagram of a Rust value in your browser.

When you want to know the shape of the tree your program just built, you reach for dbg! and start counting braces:

[src/main.rs:42:17] tree = RBTree {
    root: Some(RBNode { key: 38, color: Black, left: Some(RBNode {
    key: 19, color: Black, left: Some(RBNode { key: 12, color: Black,
    left: Some(RBNode { key: 8, color: Red, left: None, ...

That's a red-black tree, but you have to rebuild it in your head from the indentation. Spytial draws it for you — change one word:

- std::dbg!(tree)
+ spytial::dbg!(tree)

Your terminal output is unchanged; a browser viewer also opens with a diagram of the value. Repeated calls append to that same viewer, creating an ordered execution trail instead of a row of tabs. spytial::dbg! takes the same arguments as std::dbg! and returns the value the same way, so you can drop it in anywhere std::dbg! already appears.

Run it now

Add spytial to a project (cargo add spytial serde --features serde/derive), then:

use spytial::{dbg, SpytialDecorators};
use serde::Serialize;

#[derive(Debug, Serialize, SpytialDecorators)]
#[attribute(field = "key")]
struct Node {
    key: u32,
    left: Option<Box<Node>>,
    right: Option<Box<Node>>,
}

fn main() {
    let tree = Node {
        key: 5,
        left: Some(Box::new(Node { key: 3, left: None, right: None })),
        right: Some(Box::new(Node { key: 7, left: None, right: None })),
    };
    let _ = dbg!(tree);
}

cargo run prints the usual dbg! line to your terminal and opens the tree as the first capture in your browser. Getting started walks through the same example line by line. The SpytialDecorators derive and #[attribute] are optional: any Debug + Serialize value gets a structural diagram, and these additions make that diagram more informative.

Shape the layout with decorators

The #[attribute] above is a decorator: a rule attached to a type that says what should hold about the layout, not how to draw it. Add a few more and the default graph becomes a proper tree — left children go down-and-left, nodes are colored by a field, and the empty leaves are hidden:

#![allow(unused)]
fn main() {
#[orientation(selector = "{x, y : Node | x->y in left}",  directions = ["left",  "below"])]
#[orientation(selector = "{x, y : Node | x->y in right}", directions = ["right", "below"])]
}

Decorators are registered at link time and collected across nested types, so you decorate Node once and every Node in the value picks up the rules. See Decorators for the full reference and a red-black tree built up one rule at a time.

How it works

Three steps, all built into the crate, so diagrams work offline with no network calls:

  1. dbg!(&value) walks any Debug + Serialize value through serde into atoms and relations, then appends an envelope containing its expression, source, time, and thread.
  2. If a type derives SpytialDecorators, its link-time registration contributes layout and styling rules automatically, including rules from nested types.
  3. That data and any decorators fill a self-contained HTML session, rendered in the browser by a bundled copy of spytial-core.

The live viewer polls an in-process server bound to an OS-selected port on 127.0.0.1; it never exposes captured values to the LAN and makes no external network requests. Failures never panic: a serialization error, a missing temp dir, a failed loopback bind, or no browser to open all log a one-line warning and return. dbg!(x) always returns x.

Where next

The longer design argument is on Brown PLT's blog: Diagramming Program Values by Spatial Refinement. Generated rustdoc is on docs.rs.

Getting started

Install

Add spytial and serde to your Cargo.toml:

[dependencies]
spytial = "0.1"
serde = { version = "1", features = ["derive"] }

There's nothing else to install: no system packages, and nothing fetched at runtime. The HTML template and the rendering JavaScript are compiled into the crate as include_str! payloads, so diagrams work offline. The minimum supported Rust version is 1.80.

Your first diagram

Drop this into src/main.rs:

use spytial::{dbg, SpytialDecorators};
use serde::Serialize;

#[derive(Debug, Serialize, SpytialDecorators)]
#[attribute(field = "key")]
struct Node {
    key: u32,
    left: Option<Box<Node>>,
    right: Option<Box<Node>>,
}

fn main() {
    let tree = Node {
        key: 5,
        left: Some(Box::new(Node { key: 3, left: None, right: None })),
        right: Some(Box::new(Node { key: 7, left: None, right: None })),
    };

    let _ = dbg!(tree);
}

Run it:

cargo run

Two things happen:

  1. Your terminal prints [src/main.rs:LINE:COL] tree = Node { … } — exactly what std::dbg! would have printed.
  2. A browser viewer opens with the rendered tree as capture 1. Later dbg! calls append captures to the same viewer without opening more tabs.

The minimum contract is Debug (already required by std::dbg!) plus Serialize. That is enough for user-defined values and direct collections such as Vec and HashMap to get an automatic structural diagram. SpytialDecorators is optional enrichment: in this example its #[attribute(field = "key")] rule promotes each node's key into its label. Without that rule the diagram still renders, but the nodes are anonymous. The next page, Decorators, covers the rest.

The entry points

CallBehavior
dbg!(x)Prints [file:line:col] x = {:#?} to stderr, appends an ordered capture to the process viewer, and returns x through. dbg!(&x) borrows; dbg!(a, b) returns (a, b) and appends two captures.
dbg_in!(&session; x)The same behavior, routed to an explicit ViewerSession.
diagram(&x)Writes and opens a standalone diagram, with no stderr or source expression, and doesn't move x.

Decorators on a type apply automatically wherever a value of that type appears inside another type — derive-generated link-time registrations follow Vec<T>, Option<T>, Box<T>, and their nested combinations. You never register nested types yourself, and an undecorated outer value does not block decorators on values inside it.

The viewer session

The first dbg! call creates one default session for the process. Each capture gets a monotonic sequence number while holding the session lock, so calls from multiple threads cannot be lost or corrupt the stream. The viewer initially selects the latest capture; selecting an older capture stops following the tail until you select the newest one again.

Spytial writes the whole session after every capture to one self-contained HTML file in your OS temp directory, named like spytial-session-{pid}-{counter}-{nanos}.html. The live browser polls a tiny in-process HTTP server on 127.0.0.1 using an OS-selected, collision-free port. The server and live updates end when the process exits, but the HTML snapshot remains usable afterward and contains the bundled rendering assets. The transport is intentionally synchronous and single-user; it cannot be configured to bind beyond loopback and is not a deployment server. If the loopback server cannot start, Spytial still opens the durable snapshot once and continues refreshing that file as later captures arrive; reload the page to see those later captures.

To pin the output to a known path — for serving it from a static file server, or copying it off a remote machine — set SPYTIAL_OUTPUT_PATH:

SPYTIAL_OUTPUT_PATH=/tmp/my-session.html cargo run

The path is read when the session is created, taken verbatim, and atomically refreshed with the complete capture list. Its parent directory must already exist. If you create multiple explicit sessions while setting one output path, they will intentionally target the same file, so give only one of them that configuration.

To separate capture streams inside one process, create a named session and use dbg_in!:

#![allow(unused)]
fn main() {
use spytial::{dbg_in, ViewerSession};

let parser = ViewerSession::named("parser");
let state = dbg_in!(&parser; state);
}

Skipping the browser launch

In CI, over SSH, or inside a container — anywhere there's no display — disable the browser launch with SPYTIAL_NO_OPEN (1, true, or yes):

SPYTIAL_NO_OPEN=1 cargo run

stderr is unaffected, so cargo test capture behaves exactly as it does for std::dbg!. No viewer server is started. Spytial still appends every capture to the session HTML and prints that stable path so you can open it manually after or during the run. See Running headless & in Docker for the full setup.

Decorators

Spytial decorators describe what should hold about a layout — not how to render it. They're declarative constraints, attached to a type once with an attribute, applied everywhere a value of that type appears.

A decorator like #[align(selector = "reports_to", direction = "horizontal")] does not say "draw an arrow from A to B." It says "atoms related by reports_to should line up horizontally." The layout engine picks the actual coordinates, as long as the constraint holds. Three things follow:

  • They compose. Add an orientation rule, then a color, then a hide rule — the diagram refines step by step. No rule has to know about any other.
  • They're per-type, not per-instance. Decorate RBNode once; every RBNode in the value picks up the same rules, however deeply nested.
  • They're registered and collected transitively. Decorating Person is enough for those rules to apply wherever Person appears inside a Vec<T>, Option<T>, Box<T>, or their nested combinations.

A red-black tree, one rule at a time

The default layout gets you most of the way for trees and lists; a few decorators do the rest. This is the full examples/rbt.rs demo, built up stage by stage — each snippet is an attribute you add to the struct.

Stage 1 — no decorators. Debug + Serialize is enough for a correct but flat structural graph; you can't read it as a tree yet.

#![allow(unused)]
fn main() {
#[derive(Debug, Serialize)]
struct RBNode {
    key: u32,
    color: Color,
    left: Option<Box<RBNode>>,
    right: Option<Box<RBNode>>,
}
}

Stage 2 — show the key. Add the derive and the first decorator. Each RBNode atom now carries its key as a label, so you can see what's where.

#![allow(unused)]
fn main() {
#[derive(Debug, Serialize, SpytialDecorators)]
#[attribute(field = "key")]
}

Stage 3 — make it a tree. Left children go down-and-left, right children down-and-right; the layout now encodes BST order.

#![allow(unused)]
fn main() {
#[orientation(selector = "{x, y : RBNode | x->y in left}",  directions = ["left",  "below"])]
#[orientation(selector = "{x, y : RBNode | x->y in right}", directions = ["right", "below"])]
}

The selector reads: "for any pair (x, y) of RBNodes where x -> y is in the left relation, place y to the left of and below x."

Stage 4 — make it a red-black tree. Color nodes by their color field. The pattern matches any matching node, not specific instances.

#![allow(unused)]
fn main() {
#[atom_style(selector = "{x : RBNode | @:(x.color) = \"Red\"}",   border_style(color = "red"))]
#[atom_style(selector = "{x : RBNode | @:(x.color) = \"Black\"}", border_style(color = "black"))]
}

\"Red\" is a string literal in the query, escaped because the selector is itself a Rust string. Quoting matters: a bare name that resolves to nothing is the empty relation, so the comparison would just be false and the rule would quietly never fire — the diagram still renders, only unstyled. The diagram flags this with a ⚠ n selector warnings bar naming the decorator and its selector.

A raw string carries the same query without the backslashes, if you prefer to read the selector as the query engine sees it:

#![allow(unused)]
fn main() {
#[atom_style(selector = r#"{x : RBNode | @:(x.color) = "Red"}"#, border_style(color = "red"))]
}

Stage 5 — hide the scaffolding. The Color enum atoms, the u32 keys, and the None sentinels are already implied by node color, labels, and absent edges. Drop them from the canvas.

#![allow(unused)]
fn main() {
#[hide_atom(selector = "Color + u32 + None")]
}

Color + u32 + None is a set expression: match any atom whose type is Color, u32, or the literal None. Each rule refined an existing structure rather than imposing an external aesthetic.

Attribute reference

Every decorator is a Rust attribute on a type that derives SpytialDecorators. They group into three families.

Display

AttributeWhat it does
#[attribute(field = "...", selector = "...", filter = "...")]Promote a field's value into the node's label.
#[flag(name = "...")]Set a global display flag. name is required and is one of hideDisconnected, hideDisconnectedBuiltIns.

Layout constraints

AttributeWhat it does
#[orientation(selector = "...", directions = [...])]Place matched pairs in a direction. Required; each value is one of "above", "below", "left", "right", or a "directly*" variant. above/below and left/right are mutually exclusive, and a directly* value admits only its own plain counterpart alongside it.
#[align(selector = "...", direction = "horizontal" | "vertical")]Force matched atoms to share an axis.
#[cyclic(selector = "...", direction = "clockwise" | "counterclockwise")]Arrange matched atoms around a ring. direction defaults to clockwise.
#[group(selector = "...", name = "...", show_label = ...)]Cluster related atoms into a region. A binary selector builds one group per distinct first-column atom, keyed by it; a unary selector builds a single unkeyed group. Set show_label = false to hide the group caption and its background pill while keeping the hull.

orientation, align, cyclic, and group each take an optional negated = true — see Negated constraints below.

Styling, filtering, and overrides

Styling follows spytial-core's block system, introduced in 3.x. Blocks are written as nested groups that mirror the YAML 1:1:

  • line_style(color = "...", pattern = "solid" | "dashed" | "dotted", weight = 2.0, highlight = "...") — a drawn edge line;
  • text_style(size = "small" | "normal" | "large", color = "...") — any label;
  • border_style(color = "...", width = 2.0) / fill_style(color = "...") — an atom's outline and interior;
  • icon_style(path = "...", placement = "full" | "badge", opacity = 0.4) — an atom's icon.

Every block field is optional — set only what you mean.

Which keys each attribute accepts, and which values are legal, are generated from spytial-core's own language manifest (see spec-codegen/). A key or value this crate rejects is one spytial-core would reject or silently ignore, so typos, unknown block leaves, out-of-vocabulary values, and out-of-range numbers are all compile errors rather than a diagram that renders without them.

AttributeWhat it does
#[atom_style(selector = "...", border_style(...), fill_style(...), icon_style(...), text_style(...), show_label = ...)]Style matched atoms' border, interior fill, icon, and label independently.
#[size(selector = "...", height = ..., width = ...)]Override node dimensions (in diagram units).
#[icon(selector = "...", path = "...", show_labels = ...)]Deprecated by spytial-core 4.2; rewrites to atom_style. Its single show_labels boolean drove label visibility and icon geometry at once — prefer icon_style(...) plus show_label, which are independent.
#[edge_style(field = "...", line_style(...), text_style(...), show_label = ..., hidden = ...)]Style relation arrows: the drawn line, the edge's label, and visibility.
#[hide_field(field = "...", selector = "...", filter = "...")]Suppress a relation from the rendering.
#[hide_atom(selector = "...")]Suppress matched atoms entirely.
#[inferred_edge(name = "...", selector = "...", draw = "...", line_style(...), text_style(...))]Define a synthetic edge derivable from the data, optionally styled. draw attaches its ends to group hulls — see below.
#[tag(to_tag = "...", name = "...", value = "...", text_style(...))]Attach a computed attribute to matched atoms.
#[attribute(field = "...", text_style(...))](See Display above; text_style styles the attribute's line.)

An inferred_edge additionally takes draw = "<end> -> <end>", which decides what each end of the edge attaches to. Each end is either _ — the tuple's own atom, which is what an edge without draw does — or the name of a group constraint, in which case the end lands on that group's hull:

#![allow(unused)]
fn main() {
#[group(selector = "region", name = "regions")]
// One edge per `connected` pair, drawn hull to hull.
#[inferred_edge(name = "connected", selector = "connected", draw = "regions -> regions")]
// Each person to the hull of the region-group they manage.
#[inferred_edge(name = "manages", selector = "manages", draw = "_ -> regions")]
}

draw never decides which pairs get edges or which way they point — the selector does (transpose it, e.g. ~connected, to flip one). A keyed group constraint builds one group per key and the end's atom picks which; a unary one builds a single group the end attaches to directly. With draw, the selector may also be unary: the single atom feeds both ends.

Malformed draw strings are compile errors, including the redundant "_ -> _" (that's the default — drop the key). The group name, though, is resolved by spytial-core against the assembled spec: decorators compose across types, so no single attribute site can see which group constraints will end up in the spec. A name that no group defines is a layout-time warning (since spytial-core 5.1.0) and that edge is skipped; the rest of the diagram still renders.

A selector-based group additionally takes add_edge — the connector between the group's key and the group. Bare form add_edge = "togroup" (or "fromgroup"/"none"), or the styled block add_edge(points = "togroup", line_style(...), text_style(...)) — plus a top-level text_style(color = "...") for the group's own label.

Migrating from the 2.x flat forms: #[atom_color(selector, value)] and #[edge_style(field, value, style, weight, ...)] still compile and are rewritten onto the block forms — atom_color's value becomes the border colour (that is what 2.x drew; reach for fill_style only if you want a filled look), and edge_style's value/style/weight become line_style's color/pattern/weight. Mixing flat keys and blocks in one edge_style is a compile error.

Deprecation warnings: every form spytial-core has deprecated now warns at compile time, naming the replacement and how the fields map across. That covers whole attributes (#[atom_color], #[icon]) and single shapes of attributes that are otherwise current — #[edge_style(value = ...)] warns while #[edge_style(field = ..., line_style(...))] does not. Nothing stops compiling; the deprecated forms still work exactly as before. To keep one deliberately, put #[allow(deprecated)] on the type:

#[derive(Serialize, SpytialDecorators)]
#[allow(deprecated)]
#[atom_color(selector = "Node", value = "red")]
struct Node { /* ... */ }

The warning text comes from spytial-core's own language manifest, so it moves when upstream's does.

Breaking in spytial-core 3.0: two style rules that set the same property of the same edge/atom to different values now raise a StyleCollisionError at render time (2.x silently kept the first). Set each property in exactly one matching rule.

These map onto the same builder and YAML layer that spytial-core consumes, so anything expressible here is also expressible as a hand-written spec passed to diagram_with_spec.

Where a rule came from

Every rule the derive emits is stamped with a source block: the attribute exactly as you wrote it and its file:line. When the viewer reports a conflict — two rules that cannot both hold — or warns about a rule that matched nothing, it cites that text in place of its own description of the rule, so the report reads #[orientation(selector = "…", directions = ["left", "below"])] at src/tree.rs:14 and you can go straight to the line. Nothing to enable; a hand-written spec passed to diagram_with_spec needs no source, because there the YAML is what you wrote. The builder's .source(text, location) stamps the rule you pushed last, if you assemble decorators by hand and want the same treatment.

Attribute reference

The reference below is generated from spytial-core's own language manifest, the same file the derive's compile-time checks come from, so the keys, vocabularies, bounds, defaults, and selector arities are the engine's description of the language at the vendored version. It is the same text docs.rs shows on the derive.

Generated from spytial-core 6.4.1's language manifest, so every key, vocabulary, bound, default, and selector arity below is the engine's own description of the language (dated 2026-09-18). Keys are the Rust spellings; negated = true is spytial-core's hold: never. Where a selector lists more than one accepted shape, the first is the one the field is designed for. spytial-core's own guides: YAML reference, constraints, directives, selectors.

Constraints

#[orientation(…)]

Place the two ends of each selected tuple relative to one another. (a layout constraint; takes negated = true)

#[orientation(selector = "parent", directions = ["above"])]
  • selector: selector; required. Returns (source, target) pairs. The constraint applies to each pair. (a bad value makes spytial-core reject the whole spec)
    • binary (2 columns): Each tuple is one (first, last) pair, and the constraint applies to it.
    • n-ary (3 or more columns): Accepted: the pair is the tuple's first and last atom. The columns between are ignored.
  • directions: one or more of above, below, left, right, directlyAbove, directlyBelow, directlyLeft, directlyRight; required. Where the TARGET sits relative to the SOURCE. directions: [above] on selector parent places each tuple's target above its source. The directly* variants additionally enforce axis alignment. The direction is target-relative-to-source. Getting this backwards is the most common spec bug — transpose the selector (~parent) rather than flipping the direction if the relation reads the other way. (a bad value makes spytial-core reject the whole spec)
  • negated: true asserts the relationship must not hold (spytial-core's hold: never).

Contradictory direction sets are rejected at parse time: above with below, left with right, and a directly* variant combined with anything other than its own non-direct counterpart. Values outside the listed set are NOT rejected — they parse and then match nothing.

#[align(…)]

Put the selected atoms on a shared horizontal or vertical line. (a layout constraint; takes negated = true)

#[align(selector = "siblings", direction = "horizontal")]
  • selector: selector; required. Returns the pairs of atoms to align with one another. (a bad value makes spytial-core reject the whole spec)
    • binary (2 columns): Each tuple is one (first, last) pair, and the constraint applies to it.
    • n-ary (3 or more columns): Accepted: the pair is the tuple's first and last atom. The columns between are ignored.
  • direction: one of horizontal, vertical; required. horizontal gives the atoms a shared Y coordinate; vertical a shared X coordinate. An unrecognized value is a parse error. (a bad value makes spytial-core reject the whole spec)
  • negated: true asserts the relationship must not hold (spytial-core's hold: never).

#[cyclic(…)]

Arrange the selected atoms around the perimeter of a circle, in the order the selector defines. (a layout constraint; takes negated = true)

#[cyclic(selector = "nextState", direction = "clockwise")]
  • selector: selector; required. Returns (a, b) pairs meaning "b follows a around the circle". (a bad value makes spytial-core reject the whole spec)
    • binary (2 columns): Each tuple is one (first, last) pair, and the constraint applies to it.
    • n-ary (3 or more columns): Accepted: the pair is the tuple's first and last atom. The columns between are ignored.
  • direction: one of clockwise, counterclockwise; default clockwise. Which way the cycle runs.
  • negated: true asserts the relationship must not hold (spytial-core's hold: never).

Two cyclic constraints on the same selector with different directions is a parse error. An unrecognized direction string is not rejected at parse time.

#[group(…)]

Draw a hull around a set of atoms. For tuples (a, b), (a, c), … the group is keyed by a and contains {b, c, …}. (a layout constraint; takes negated = true)

#[group(selector = "Team.members", name = "Team")]
  • selector: selector; required. First column is the group key, second the members. A unary selector builds a single unkeyed group. A group with neither selector nor field is silently dropped — it matches neither grouping form. That is the one case where omitting this field is not an error but a no-op.
    • binary (2 columns): One group per distinct first atom: the first column is the key, the last the members. The group is named <name>[<key>], which is what a keyed inferredEdge draw end attaches to.
    • n-ary (3 or more columns): As binary — the key is the tuple's first atom and the member its last, ignoring the columns between.
    • unary (1 column): One single group holding every selected atom, with no key. It is named <name> with no [key] suffix, and a draw end naming it attaches there whatever the end's atom is.
  • name: string; required. Display name on the group, and the handle an inferredEdge draw endpoint refers to. Keyed captions use <name>[<key label>], adding the key ID only to distinguish duplicate captions. Required unless the constraint is negated (hold: never), where a name is generated. (a bad value makes spytial-core reject the whole spec)
  • add_edge: one of none, togroup, fromgroup; or the add_edge(…) block, which also styles it (see Style blocks); default none. Draw a connector between the group key and the group: togroup points key → group, fromgroup points group → key, none draws nothing. The legacy boolean true is still accepted and means togroup. (spytial-core drops a bad value silently)
  • show_label: true or false; default true. Whether the group caption and its background pill are drawn. The group hull remains visible.
  • text_style: text_style(…) block. The group's own label. Only color applies today — group labels auto-fit their box, so size is reserved.
  • negated: true asserts the relationship must not hold (spytial-core's hold: never).

#[size(…)]

Set the width and height of matching nodes, in pixels. (a layout constraint)

#[size(selector = "ImportantNode", width = 150, height = 80)]
  • width: number greater than 0; required. Node width in pixels. Required, and must be a number greater than 0. (a bad value makes spytial-core reject the whole spec)
  • height: number greater than 0; required. Node height in pixels. Required, and must be a number greater than 0. (a bad value makes spytial-core reject the whole spec)
  • selector: selector. Which nodes to resize. Omit to resize every node.
    • unary (1 column): The nodes to resize.

hold: never is not supported and is silently ignored.

#[hide_atom(…)]

Remove matching atoms from the diagram. (a layout constraint)

#[hide_atom(selector = "HelperNode")]
  • selector: selector; required. Which atoms to hide.
    • unary (1 column): The atoms to hide.

hold: never is not supported and is silently ignored. Hiding an atom that a layout constraint places, or that a group contains, makes the spec unsatisfiable: the layout reports a hidden-node conflict and draws a counterfactual with the conflicting atoms outlined. Hiding a keyed group's key is fine — the key is not inside the group.

Directives

#[attribute(…)]

Render a relation as a key-value line on its source node instead of as an edge.

#[attribute(field = "age", selector = "Person")]
  • field: relation (field) name; required. The relation to fold into the source node. Its edges are removed from the graph.
  • selector: selector. Apply only to these source atoms. Omit to apply to every source atom.
    • unary (1 column): The set of source atoms the directive is narrowed to.
  • filter: selector. Apply only to the (source, target, …) tuples this selector returns. Use it to scope a directive to part of a relation, e.g. only the tuples whose value is True.
    • n-ary (2 or more columns): A tuple matches on its first and last atom, so a longer tuple filters the same relation ends a pair would. A unary result matches nothing — the directive is then scoped to no tuples at all.
  • text_style: text_style(…) block. This attribute line's own styling.

Multiple targets for the same source become a list. Unlike tag, this removes the edge.

#[flag(…)]

Toggle a whole-diagram rendering behaviour.

#[flag(name = "hideDisconnectedBuiltIns")]
  • name: one of hideDisconnected, hideDisconnectedBuiltIns; required. hideDisconnected hides every atom with no edges; hideDisconnectedBuiltIns hides only the disconnected built-in atoms (Int, String, …). Any other value is silently ignored.

Written as a bare scalar: - flag: hideDisconnected, not a mapping.

#[atom_color(…)]

Set the color of matching atoms. (deprecated, rewrites to #[atom_style])

#[atom_color(selector = "Person", value = "#ff5733")]
  • value: color; required. Any CSS color. Applies to the node's outline, not its fill.
  • selector: selector; required. Which atoms to recolor. Omitting it drops the directive — it never means "every atom".
    • unary (1 column): The atoms to recolor.

#[atom_style(…)]

Style matching atoms. An atom is a composite of an interior fill, an outline, an icon, and its label, so each is its own block.

#[atom_style(selector = "Person", border_style(color = "#0369a1"))]
  • selector: selector. Which atoms to style. Omit to style every atom.
    • unary (1 column): The atoms this rule styles.
  • fill_style: fill_style(…) block. The node's interior fill.
  • border_style: border_style(…) block. The node's outline.
  • icon_style: icon_style(…) block. An icon drawn on the node.
  • text_style: text_style(…) block. The node's own label.
  • show_label: true or false; default true. Whether the atom's label is drawn. Independent of the icon: iconStyle.placement controls the icon's geometry, this controls the label.

Rules compose: a supertype selector already returns subtype atoms, so a Node rule and a RedNode rule both apply to a RedNode atom and their set properties gap-fill each other. Two rules setting the SAME property to DIFFERENT values is a hard error at layout time — styles never silently override.

#[icon(…)]

Assign an icon to matching atoms. (deprecated, rewrites to #[atom_style])

#[icon(selector = "Person", path = "person", show_labels = true)]
  • selector: selector; required. Which atoms get the icon. Omitting it drops the directive entirely — it never means "every atom".
    • unary (1 column): The atoms that get the icon.
  • path: icon name, icon-pack reference, URL, or path; required. Bundled name, icon-pack reference, URL, or path. Omitting it drops the directive.
  • show_labels: true or false; default false. Whether the atom's label is drawn alongside the icon.

#[edge_style(…)]

Style the edges of a relation: the drawn line, the label, and visibility.

#[edge_style(field = "parent", line_style(color = "blue", pattern = "dashed"))]
  • field: relation (field) name; required. The relation whose edges this styles.
  • selector: selector. Apply only to edges whose source atom is in this set. Omit to apply to every source atom.
    • unary (1 column): The set of source atoms the directive is narrowed to.
  • filter: selector. Apply only to the (source, target, …) tuples this selector returns. Use it to scope a directive to part of a relation, e.g. only the tuples whose value is True.
    • n-ary (2 or more columns): A tuple matches on its first and last atom, so a longer tuple filters the same relation ends a pair would. A unary result matches nothing — the directive is then scoped to no tuples at all.
  • line_style: line_style(…) block. The drawn line.
  • text_style: text_style(…) block. The edge label.
  • show_label: true or false; default true. Whether the edge label is drawn.
  • hidden: true or false; default false. Hide the edge entirely while keeping the relation in the data.
  • value: color; the deprecated edgeColor form, which rewrites onto the blocks above; mixing the two shapes is a compile error. Line color.
  • style: one of solid, dashed, dotted; the deprecated edgeColor form, which rewrites onto the blocks above; mixing the two shapes is a compile error. Dash pattern. Trimmed and lowercased before matching, unlike lineStyle.pattern. (spytial-core drops a bad value silently)
  • weight: number greater than 0; the deprecated edgeColor form, which rewrites onto the blocks above; mixing the two shapes is a compile error. Line thickness in pixels. (spytial-core drops a bad value silently)

Like atomStyle, overlapping rules compose and a genuine disagreement is an error rather than a silent override.

#[hide_field(…)]

Hide a relation's edges.

#[hide_field(field = "internal")]
  • field: relation (field) name; required. The relation to hide.
  • selector: selector. Apply only to edges whose source atom is in this set. Omit to apply to every source atom.
    • unary (1 column): The set of source atoms the directive is narrowed to.
  • filter: selector. Hide only the tuples this selector returns.
    • n-ary (2 or more columns): A tuple matches on its first and last atom, so a longer tuple filters the same relation ends a pair would. A unary result matches nothing — the directive is then scoped to no tuples at all.

#[inferred_edge(…)]

Draw an edge that is not in the data, computed from a selector.

#[inferred_edge(name = "reachable", selector = "^parent", line_style(color = "gray", pattern = "dotted"))]
  • name: string; required. The label drawn on the edge.
  • selector: selector; required. Returns the (source, target) pairs to connect. May be unary when draw is given — the single atom then feeds both ends.
    • binary (2 columns): One edge per tuple, from its first atom to its last.
    • n-ary (3 or more columns): One edge from the tuple's first atom to its last. Unlike the constraints, the columns between are not discarded: their labels are appended to the edge label as <name>[m1,m2].
    • unary (1 column); needs draw: One edge per atom, the atom feeding both ends — the shape draw: _ -> <group> needs, connecting each atom to its own group. Without draw a unary selector draws nothing at all: the engine reads this field as tuples, and single-atom tuples are dropped before any edge is made.
  • draw: string. Reinterpret what each end attaches to, as <end> -> <end>. Each end is _ (the atom itself, the default) or the name of a group constraint, in which case the end attaches to that group's hull. The left end applies to the first atom of each tuple, the right end to the last. Parsed strictly: a value that is not a string, or that does not contain exactly one ->, is a parse error. A group name that no group constraint defines is not — it raises a warning (code unresolved-reference, specType inferredEdge) and the edge is skipped at layout time, so a fragment that names a group defined in another fragment still parses. draw never reorders — transpose the selector (~connected) to flip an edge. (a bad value makes spytial-core reject the whole spec)
  • line_style: line_style(…) block. The drawn line.
  • text_style: text_style(…) block. The edge label.

Using any of the legacy inline color/style/weight/highlight keys raises a deprecation warning with specType inferredEdge. The block form wins when both are given. If an end's atom keys no group of that name in a given instance, that one edge is skipped with a console warning — data-dependent, not a spec error.

#[tag(…)]

Add a computed key-value line to matching nodes, without touching the graph structure.

#[tag(to_tag = "Person", name = "age", value = "age")]
  • to_tag: selector; required. Which atoms receive the tag.
    • unary (1 column): The atoms that receive the tag.
  • name: string; required. The literal label shown before the value; this field is not a selector.
  • value: selector; required. Evaluated once; each tuple is attached to the tagged atom in its first column. A binary result shows as name: value; a longer tuple shows one line per tuple, as name[k1,k2]: value. A unary result tags each selected atom with its own label.
    • n-ary (2 or more columns): One line per tuple whose first atom is the tagged atom: the last column is the value and any columns between become one comma-separated key tuple, as name[k1,k2]: value. A binary result is the plain name: value case.
    • unary (1 column): A membership tag: an atom in the result is tagged with its own label, as name: <label>. The value carries no more than "this atom is in the set".
  • text_style: text_style(…) block. This tag line's own styling.

Unlike attribute, tags do not remove edges.

Style blocks

Blocks are written as nested groups that mirror the YAML: line_style(color = "gray", pattern = "dotted"). Every leaf is optional.

line_style(…)

Styling for an edge's drawn line. Reused by edgeStyle, inferredEdge, and a group's addEdge connector.

  • color: color. Any CSS color.
  • pattern: one of solid, dashed, dotted. Dash pattern of the line. (spytial-core drops a bad value silently)
  • weight: number greater than 0. Line thickness in pixels. Must be > 0; a non-positive or non-numeric weight is dropped. (spytial-core drops a bad value silently)
  • highlight: color. A wider, translucent underlay drawn beneath the line. Orthogonal to color and pattern.

text_style(…)

Styling for a label. Reused by every form that renders text — an atom's own label, attribute and tag lines, edge labels, and a group label.

  • size: one of small, normal, large; default normal. Font-size tier relative to the node label: large renders bigger than the label, normal smaller, small smaller still. (spytial-core drops a bad value silently)
  • color: color. Any CSS color. Unset inherits the node label color, so dark mode still adapts.

border_style(…)

An atom's outline.

  • color: color. Any CSS color.
  • width: number greater than 0. Outline thickness in pixels. Must be > 0; anything else is dropped. (spytial-core drops a bad value silently)

fill_style(…)

An atom's interior fill. Opt-in: the default look is unfilled, with only the border and label marking the node.

  • color: color. Any CSS color.

icon_style(…)

An icon drawn on an atom. Every leaf is optional, including path, so a supertype rule can supply the icon and a subtype rule tune only its opacity.

  • path: icon name, icon-pack reference, URL, or path. A bundled icon name (person), an icon-pack reference (bi:person-fill), a URL, or a path. Resolved to a concrete URL or data URI at parse time. An iconStyle with no path draws nothing.
  • placement: one of full, badge; default full. full — the icon occupies the box, which stays transparent unless a fillStyle.color is given. badge — a small marker in the top-right corner, secondary to the label. (spytial-core drops a bad value silently)
  • opacity: number between 0 and 1; default 1. Alpha in [0,1]. Out-of-range values are dropped rather than clamped. Fade a full icon to use it as a watermark behind the label. (spytial-core drops a bad value silently)

add_edge(…) (on #[group])

Block form also styles the connector, which is an edge and so takes the shared edge blocks: { points: <direction>, lineStyle: {…}, textStyle: {…} }.

  • points: one of none, togroup, fromgroup; default none. The connector direction — the same value the bare string form carries.
  • line_style: line_style(…) block. The connector's line.
  • text_style: text_style(…) block. The connector's label.

Deprecated forms

Each still compiles and still works, with a compile-time deprecation warning naming the replacement; #[allow(deprecated)] on the type keeps one deliberately.

  • #[icon(…)] → #[atom_style]. The single showLabels boolean drove label visibility and icon geometry at once. atomStyle splits those into two independent knobs, which is what makes a faded watermark, or a hidden label with no icon, expressible. Mapping: path → icon_style.path; show_labels: false → show_label: false + icon_style.placement: full; show_labels: true → show_label: true + icon_style.placement: badge.
  • #[atom_color(…)] → #[atom_style]. atomStyle expresses the same recolor and also reaches the fill, the icon, and the label. The rewrite is border-preserving, so an existing diagram is unchanged. Mapping: value → border_style.color.
  • #[edge_style] written with value/style/weight → #[edge_style]. edgeStyle groups the same knobs into the shared lineStyle/textStyle blocks that every other form uses. Mapping: value → line_style.color; style → line_style.pattern; weight → line_style.weight; highlight → line_style.highlight.

Negated constraints

orientation, align, cyclic, and group accept negated = true, which flips the constraint from "this must hold" to "this must not hold." It's most useful alongside positive constraints — the positive ones say what shape the layout should take, the negated ones rule out a degenerate arrangement the solver might otherwise pick:

#![allow(unused)]
fn main() {
#[align(selector = "{x, y : Node | x->y in default_edge}", direction = "vertical")]
#[align(selector = "{x, y : Node | x->y in exception_edge}", direction = "vertical", negated = true)]
}

"Default edges align vertically; exception edges must not." A diagram with only negated constraints is under-specified, and the solver falls back to defaults.

Running headless & in Docker

Spytial's default is to open one persistent browser viewer for dbg! captures; standalone diagram(&value) calls still open their own files. Both behaviors are wrong for CI, remote shells, containers, and test suites. Two environment variables cover the headless cases.

SPYTIAL_NO_OPEN — suppress the browser launch

Set to 1, true, or yes (case-insensitive) and spytial skips the platform browser-open command. For dbg!, it also skips the live loopback server while continuing to append captures to the session HTML on disk. The stable path is printed to stderr:

SPYTIAL_NO_OPEN=1 cargo run --example rbt
spytial: capture appended to /tmp/spytial-session-12345-0-987654321.html

dbg!'s pretty-printed output is unaffected, so cargo test capture behaves exactly as it does for std::dbg!.

SPYTIAL_OUTPUT_PATH — pin the output filename

The default dbg! session already uses one stable, unique temp path. To choose it — for serving with a static HTTP server, retaining it as an artifact, or round-tripping it off a remote machine — set SPYTIAL_OUTPUT_PATH:

SPYTIAL_OUTPUT_PATH=/var/www/diagram.html cargo run

The value is read when the session starts and used verbatim. Each capture atomically replaces it with the complete ordered session, the parent directory must already exist, and concurrent calls are serialized. The file persists after process exit. Operating-system cleanup policies eventually remove the default temp file; Spytial does not delete it because it is the post-mortem viewer once the in-process server has stopped.

diagram() retains its standalone behavior: with this variable set, each standalone call overwrites the path with one diagram rather than a session.

Persistent transport limits

The dbg! viewer transport is a synchronous, single-user debugging aid. It is not configurable to listen beyond 127.0.0.1, has no external-service or deployment mode, and stops with the captured program. This is separate from the repository's viz_server demo binary and deliberately does not turn that server into the async, authenticated deployment service discussed in issue #47.

Docker

The repository ships a Dockerfile and entrypoint that give you a one-command demo without a local Rust toolchain:

docker build -t spytial .
docker run --rm -p 8080:8080 spytial        # default: the rbt example
docker run --rm -p 8080:8080 spytial demo   # any example in examples/

The container builds and runs the example once, then serves the rendered HTML over a small HTTP server. Open http://localhost:8080/rust_viz_data.html on your host. The image sets both variables above — SPYTIAL_NO_OPEN=1 via the Dockerfile (no display in the container) and SPYTIAL_OUTPUT_PATH=/tmp/rust_viz_data.html via the entrypoint (the path the server serves). The server is single-threaded and for local viewing only.

Library API

The dbg! macro and diagram() are tuned for interactive use: dbg! pretty-prints the value to stderr and appends it to the process viewer, diagram() opens a standalone page, and both swallow errors with a one-line eprintln!. When spytial is buried inside something else — a CLI subcommand, a test harness, a service that renders diagrams for clients — you'll want the lower-level entry points instead.

ViewerSession and dbg_in! — separate capture streams

#![allow(unused)]
fn main() {
use spytial::{dbg_in, ViewerSession};

let parser = ViewerSession::named("parser");
let (before, after) = dbg_in!(&parser; before, after);
eprintln!("session snapshot: {}", parser.output_path().display());
}

dbg! uses one lazily-created process session. ViewerSession::new() and ViewerSession::named() create independent sessions; dbg_in! provides the same evaluation, stderr, ownership, and return-value behavior while routing captures to one of them. ViewerSession::diagram() adds a capture without the Debug print and labels its expression diagram.

Each session starts its browser and loopback transport at most once, on the first non-headless capture. Cloning ViewerSession shares the capture stream, which makes it safe to pass to worker threads. id(), name(), and output_path() expose the session metadata without exposing the transport.

diagram(&value) — render and open

#![allow(unused)]
fn main() {
use spytial::diagram;
diagram(&tree);
}

Renders the value, writes to a temp file, opens a browser tab. Every step is best-effort: any failure is printed to stderr and swallowed, and the function returns (). No source location, and it borrows rather than moves. Any Serialize value works. A SpytialDecorators derive is detected automatically when present, but is not required.

Automatic discovery comes from the derive's link-time registration. If you implement HasSpytialDecorators by hand, diagram() cannot conditionally detect that trait implementation on stable Rust. Call T::decorators(), encode it with spytial_annotations::to_yaml, and pass it to diagram_with_spec instead.

Rules assembled through SpytialDecoratorsBuilder carry no source unless you stamp one: .source("#[orientation(...)]", Some("src/tree.rs:14")) marks the rule pushed just before it, and that text is what the viewer cites in a conflict report. The derive does this for every attribute automatically.

diagram_with_spec(&value, spec) — hand-written constraints

#![allow(unused)]
fn main() {
use spytial::diagram_with_spec;

let spec = r#"
constraints:
  - align:
      selector: reports_to
      direction: horizontal
directives:
  - flag: hideDisconnected
"#;

diagram_with_spec(&tree, spec);
}

Same diagram-and-browser flow, but with a YAML spec you assemble yourself, bypassing any derive-generated decorators on T. Useful for adding rules to a standard-library or third-party type, overriding the derive output for one call, or generating the spec from configuration. The YAML schema is the one the SpytialDecorators derive emits — see Decorators.

export_json_instance(&value) — capture the data, render nothing

#![allow(unused)]
fn main() {
use spytial::export_json_instance;
let instance = export_json_instance(&tree); // JsonDataInstance { atoms, relations }
}

Returns the relational representation of the value without writing HTML or touching the browser — the right entry point for persisting diagram data, sending it to a remote renderer, or feeding a tool with its own UI. It's infallible at the call boundary: if serialization fails it logs to stderr and returns an empty instance.

See Relationalization and reification for the atom and relation format, its mapping from Serde values, and the reverse operation.

try_export_json_instance(&value) -> Result<…> — fallible export

#![allow(unused)]
fn main() {
use spytial::export::try_export_json_instance;

match try_export_json_instance(&tree) {
    Ok(instance) => persist(instance),
    Err(err) => eprintln!("spytial export failed: {err}"),
}
}

The fallible variant: a Serialize error comes back to you instead of a silent empty instance. This is the right choice for library code that wants to surface failure to its caller, and for tests that assert serialization succeeded.

from_datum::<T>(&instance) and replit::<T>(&instance) — reconstruct

from_datum uses T: DeserializeOwned to rebuild a Rust value from an exported instance. replit also requires Debug and returns the rebuilt value's {:?} string. Use from_datum_root or replit_root when the root atom ID is known explicitly. Reconstruction requires a compatible Deserialize implementation; see Relationalization and reification for its precise scope and limits.

Choosing between them

Use caseEntry point
Ad-hoc debugging with stderr trailspytial::dbg!
Named or separate debug capture streamdbg_in!(&session; value)
Capture into an explicit session without stderrViewerSession::diagram(&value)
One-call render with auto layoutdiagram(&value)
Render with a custom YAML specdiagram_with_spec(&value, spec)
Capture relational JSON onlyexport_json_instance(&value)
Same, but surface errorstry_export_json_instance(&value)
Rebuild a typed valuefrom_datum::<T>(&instance)
Rebuild and print with Debugreplit::<T>(&instance)

Relationalization and reification

Spytial converts a Serde value into a relational instance for the diagram renderer. The forward operation is relationalization. The reverse operation, reification, reconstructs a Rust value of a specified type from that instance.

This page describes the representation produced by try_export_json_instance(&value) and read by from_datum::<T>(&instance). The input is the Serde data model, which is the structure exposed by a type's Serialize implementation. A Rust type may choose a Serde representation that differs from its Rust syntax.

The relational instance

An instance has two lists:

JsonDataInstance {
    atoms:     [IAtom { id, type, label }, ...],
    relations: [IRelation { id, name, types, tuples }, ...],
}

An atom represents one serialized value or one container occurrence. Its id is unique within the instance. Its type is a Serde type name, a named struct or enum type, or one of Spytial's container names. Its label is text for display and, for scalar values, for reconstruction. An atom is created for each primitive value, string, byte array, unit value, None, named value, and sequence, tuple, or map container. Sequence positions are also integer atoms.

A relation tuple connects atoms. Its first position is the source atom. The remaining positions are atoms that describe a part of that source. Spytial uses three principal forms:

field_name(parent, value)       // a struct or struct-variant field
idx(container, position, value) // a sequence or positional field
map_entry(map, key, value)      // a map entry

Formally, let E(x) be the atom ID returned when the serializer visits x. For a scalar, E(x) identifies its value atom. For a composite value, the serializer creates a parent atom, recursively obtains E for each part, and adds the corresponding tuples above. The return value is the parent atom ID. Option is the exception: an ordinary Some(x) returns E(x) without a new parent atom, as specified in the table below.

The value, position, and key positions refer to atom IDs, even when a diagram displays their labels. A field name is a relation name because it is a fixed role supplied by Serde. A map key is an atom because it is data supplied at run time. Empty containers have atoms but no element tuples.

An IRelation groups tuples by source atom type and relation name. For example, Person.name and Company.name are separate records, both named name. An ITuple contains parallel atoms and types lists. The record's types is a summary of its tuples: a position with differing types becomes atom, and a mixed-arity record retains the longest arity. The tuple's own types remains exact. A dot or backslash in either part of a relation ID is escaped with a backslash.

The following example shows the difference between a container atom, an element atom, and a relation. The two equal integers use one atom:

#![allow(unused)]
fn main() {
#[derive(serde::Serialize)]
struct Sample { values: Vec<i32> }

let value = Sample { values: vec![7, 7] };
}
atoms:
  s  = (type Sample,   label Sample)
  v  = (type sequence, label seq[2])
  n  = (type i32,      label 7)
  p0 = (type u64,      label 0)
  p1 = (type u64,      label 1)

relation tuples:
  Sample.values(s, v)
  sequence.idx(v, p0, n)
  sequence.idx(v, p1, n)

The symbolic IDs above explain the structure; actual export IDs have the form atom0, atom1, and so on. Equal scalar values of the same exposed type normally share an atom. Floats are keyed by bit pattern, so 0.0 and -0.0 remain distinct; NaN occurrences are not shared. Unit values, unit structs, unit variants, and None are shared by their type and label. Other composite values receive separate atoms for separate serialized occurrences. The instance does not record Rust pointer identity or aliasing.

Every Serde category

The table covers all 29 categories in the Serde data model. “Reverse” means how from_datum::<T> reads an instance produced by this serializer. It needs the target type T; the graph alone does not specify a Rust type.

Serde categoryForward: atoms and relation tuplesReverse
boolOne bool atom labeled true or false.Parse the label as bool.
i8, i16, i32, i64, i128One atom of the corresponding signed type, with a decimal label.Parse the label at the requested width.
u8, u16, u32, u64, u128One atom of the corresponding unsigned type, with a decimal label.Parse the label at the requested width.
f32, f64One atom of the corresponding float type, with a textual float label. Non-NaN values are shared by bit pattern.Parse the label at the requested width.
charOne char atom labeled with the character itself.Read the character from the label.
stringOne string atom labeled with the raw UTF-8 text.Supply that text to Serde's string visitor.
byte arrayOne bytes atom labeled like [0, 255].Parse the list of decimal bytes from the label.
optionNone is one None atom. Some(x) normally uses the atom for x directly. If x is itself a None or Some atom, a Some atom and value(Some, x) preserve the nesting.A None atom means absent; a Some atom follows value; any other atom is treated as an unwrapped Some(x).
unitOne unit atom labeled ().Visit unit.
unit_structOne unit_struct atom labeled with the struct name.Visit unit for the requested struct type.
unit_variantOne atom whose type is the enum name and whose label is the variant name.Select the variant from the label; it has no payload.
newtype_structOne newtype_struct atom labeled with its name, plus value(wrapper, inner).Follow value and deserialize the inner value.
newtype_variantOne enum-type atom labeled with the variant name, plus variant_value(variant, inner).Select the variant and follow variant_value.
seqOne sequence atom and idx(sequence, position, element) for each element.Sort by the integer position and visit elements in order.
tupleOne tuple atom and one idx tuple per element.Read positional elements in order.
tuple_structOne tuple_struct atom labeled with its name and one idx tuple per field.Read positional fields in order for the requested type.
tuple_variantOne enum-type atom labeled with the variant name and one idx tuple per field.Select the variant, then read positional fields in order.
mapOne map atom and map_entry(map, key, value) for each entry. Keys and values are both atoms and may themselves be containers.Visit the key and value of each entry.
structOne atom whose type and label are the struct name; each serialized field f adds f(struct, value).Supply relation names as field names to the requested struct's Deserialize implementation.
struct_variantOne enum-type atom labeled with the variant name; each serialized field f adds f(variant, value).Select the variant, then supply its field relations.

The exporter serializes position atoms as u64. A sequence or map whose length is unknown when serialization begins is labeled seq[0] or map[0]; that label is only display text. Reification obtains elements from relations, not from the label. The category is chosen by the type's Serialize implementation: an ordinary Vec<u8> can use seq, while a byte-buffer implementation calls Serde's serialize_bytes and uses byte array.

The reverse operation

For a value v of type T, the intended round trip is:

#![allow(unused)]
fn main() {
use spytial::{from_datum, replit};
use spytial::export::try_export_json_instance;

let instance = try_export_json_instance(&v)?;
let reconstructed: T = from_datum(&instance)?;
let debug_text = replit::<T>(&instance)?;
}

from_datum builds an index from atom IDs and outgoing relation tuples. It chooses the first atom with no incoming relation as the root, then drives T::deserialize from that atom. from_datum_root accepts an explicit root ID for a caller-supplied or reordered instance. Root selection is based on relations because a nested Some wrapper is emitted after its inner atom; therefore atoms[0] is not always the root. The exported shape has no rootId field.

replit::<T> reconstructs a real T and applies that type's Debug implementation. It does not print atom labels as a substitute for Debug. The evaluation corpus checks two properties where each is meaningful:

R-eq:      v == from_datum::<T>(&export(v))
R-inspect: format!("{:?}", v) == replit::<T>(&export(v))

These are properties of compatible Serialize and Deserialize implementations. The serializer does not make arbitrary custom implementations inverse to each other.

Conventions and limits

  • Serde can omit information. #[serde(skip)] removes a field from the instance. On reconstruction, Deserialize supplies its default, which can change the value and its Debug output. skip_serializing_if can round trip when the omitted value is recoverable through a matching default. A custom lossy Serialize implementation has the same general limit.
  • Some Serde representations need self-description. flatten, untagged, and internally or adjacently tagged enums can call deserialize_any before the target shape is known. Reification then infers a shape from the atom type, label, and outgoing relations. In this path, a user type named like a built-in atom type (such as map), a unit variant whose name equals its enum name, and a one-field struct variant whose only field is variant_value are ambiguous. The last shape is indistinguishable from a newtype variant in the exported instance. A zero-field struct variant or zero-element tuple variant has no outgoing relation and is indistinguishable from a unit variant. These empty variants fail when reached through deserialize_any, for example inside an untagged enum.
  • Labels serve as data for scalar reconstruction. Byte arrays are stored as decimal-list labels and parsed back. Floats are keyed by bits during export, but reification parses their labels. It does not preserve a NaN's payload bits. NaN cannot satisfy R-eq because it is unequal to itself; the corpus checks its Debug representation instead.
  • Order and identity have limits. idx preserves sequence order. Map entries follow serialization order, which need not be stable for HashMap. A reconstructed HashMap may print entries in a different order, so the corpus checks R-eq for it, not exact Debug text. Repeated composite values and shared pointers are serialized as separate occurrences. Pointer cycles are outside this tree representation. A graph whose edges are stored as ordinary indices can still round trip.
  • The reverse reader expects export-shaped input. It uses field relation names, idx, map_entry, value, and variant_value according to the requested type. It does not validate an arbitrary edited relational graph as a complete, unambiguous schema.

The library API describes the export and reification entry points. The repository's eval-corpus suite exercises every Serde category and the representation cases above.

Contributing

Contributions are welcome via pull requests. Bug reports, feature requests, and questions are welcome on the issue tracker. The canonical guide is CONTRIBUTING.md; the notes below cover the common workflows.

Local setup

cargo build
cargo test --lib --tests
cargo test --doc
cargo run --example rbt          # add SPYTIAL_NO_OPEN=1 to skip the browser

cargo test --manifest-path eval-corpus/Cargo.toml   # separate workspace

Before opening a PR

  • cargo fmt clean, and cargo clippy --all-targets -- -D warnings clean.
  • Add an entry to the Unreleased section of CHANGELOG.md.
  • Changes to decorators need test coverage in tests/decorators.rs.
  • Keep PRs focused; unrelated cleanups belong in separate PRs.

By contributing you agree your work is dual-licensed under MIT and Apache 2.0, matching the project.

Maintainers: the release process (trusted publishing, crate ordering) is in RELEASING.md.