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:
dbg!(&value)walks anyDebug + Serializevalue through serde into atoms and relations, then appends an envelope containing its expression, source, time, and thread.- If a type derives
SpytialDecorators, its link-time registration contributes layout and styling rules automatically, including rules from nested types. - 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
- Getting started — install, your first diagram, and where the output goes.
- Decorators — every layout rule, plus a worked red-black tree.
- Running headless & in Docker — CI, containers, no-display environments.
- Library API — using spytial as a dependency instead of a debug macro.
- Relationalization and reification — how Serde values become atoms and relations, and how they are reconstructed.
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:
- Your terminal prints
[src/main.rs:LINE:COL] tree = Node { … }— exactly whatstd::dbg!would have printed. - 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
| Call | Behavior |
|---|---|
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
RBNodeonce; everyRBNodein the value picks up the same rules, however deeply nested. - They're registered and collected transitively. Decorating
Personis enough for those rules to apply whereverPersonappears inside aVec<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
| Attribute | What 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
| Attribute | What 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.
| Attribute | What 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
StyleCollisionErrorat 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 ofabove,below,left,right,directlyAbove,directlyBelow,directlyLeft,directlyRight; required. Where the TARGET sits relative to the SOURCE.directions: [above]on selectorparentplaces each tuple's target above its source. Thedirectly*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:trueasserts the relationship must not hold (spytial-core'shold: never).
Contradictory direction sets are rejected at parse time:
abovewithbelow,leftwithright, and adirectly*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 ofhorizontal,vertical; required.horizontalgives the atoms a shared Y coordinate;verticala shared X coordinate. An unrecognized value is a parse error. (a bad value makes spytial-core reject the whole spec)negated:trueasserts the relationship must not hold (spytial-core'shold: 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 ofclockwise,counterclockwise; defaultclockwise. Which way the cycle runs.negated:trueasserts the relationship must not hold (spytial-core'shold: 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. Agroupwith neitherselectornorfieldis 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 keyedinferredEdgedrawend 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 adrawend naming it attaches there whatever the end's atom is.
- binary (2 columns): One group per distinct first atom: the first column is the key, the last the members. The group is named
name: string; required. Display name on the group, and the handle aninferredEdgedrawendpoint 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 ofnone,togroup,fromgroup; or theadd_edge(…)block, which also styles it (see Style blocks); defaultnone. Draw a connector between the group key and the group:togrouppoints key → group,fromgrouppoints group → key,nonedraws nothing. The legacy booleantrueis still accepted and meanstogroup. (spytial-core drops a bad value silently)show_label:trueorfalse; defaulttrue. 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. Onlycolorapplies today — group labels auto-fit their box, sosizeis reserved.negated:trueasserts the relationship must not hold (spytial-core'shold: 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: neveris 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: neveris 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 ofhideDisconnected,hideDisconnectedBuiltIns; required.hideDisconnectedhides every atom with no edges;hideDisconnectedBuiltInshides 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:trueorfalse; defaulttrue. Whether the atom's label is drawn. Independent of the icon:iconStyle.placementcontrols the icon's geometry, this controls the label.
Rules compose: a supertype selector already returns subtype atoms, so a
Noderule and aRedNoderule 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:trueorfalse; defaultfalse. 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:trueorfalse; defaulttrue. Whether the edge label is drawn.hidden:trueorfalse; defaultfalse. Hide the edge entirely while keeping the relation in the data.value: color; the deprecatededgeColorform, which rewrites onto the blocks above; mixing the two shapes is a compile error. Line color.style: one ofsolid,dashed,dotted; the deprecatededgeColorform, which rewrites onto the blocks above; mixing the two shapes is a compile error. Dash pattern. Trimmed and lowercased before matching, unlikelineStyle.pattern. (spytial-core drops a bad value silently)weight: number greater than 0; the deprecatededgeColorform, 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 whendrawis 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 shapedraw: _ -> <group>needs, connecting each atom to its own group. Withoutdrawa 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 agroupconstraint, 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 nogroupconstraint defines is not — it raises a warning (codeunresolved-reference, specTypeinferredEdge) and the edge is skipped at layout time, so a fragment that names a group defined in another fragment still parses.drawnever 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/highlightkeys raises a deprecation warning with specTypeinferredEdge. 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 asname: value; a longer tuple shows one line per tuple, asname[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 plainname: valuecase. - 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".
- 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
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 ofsolid,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 ofsmall,normal,large; defaultnormal. Font-size tier relative to the node label:largerenders bigger than the label,normalsmaller,smallsmaller 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 offull,badge; defaultfull.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; default1. Alpha in [0,1]. Out-of-range values are dropped rather than clamped. Fade afullicon 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 ofnone,togroup,fromgroup; defaultnone. 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 singleshowLabelsboolean 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 withvalue/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 case | Entry point |
|---|---|
| Ad-hoc debugging with stderr trail | spytial::dbg! |
| Named or separate debug capture stream | dbg_in!(&session; value) |
| Capture into an explicit session without stderr | ViewerSession::diagram(&value) |
| One-call render with auto layout | diagram(&value) |
| Render with a custom YAML spec | diagram_with_spec(&value, spec) |
| Capture relational JSON only | export_json_instance(&value) |
| Same, but surface errors | try_export_json_instance(&value) |
| Rebuild a typed value | from_datum::<T>(&instance) |
Rebuild and print with Debug | replit::<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 category | Forward: atoms and relation tuples | Reverse |
|---|---|---|
bool | One bool atom labeled true or false. | Parse the label as bool. |
i8, i16, i32, i64, i128 | One atom of the corresponding signed type, with a decimal label. | Parse the label at the requested width. |
u8, u16, u32, u64, u128 | One atom of the corresponding unsigned type, with a decimal label. | Parse the label at the requested width. |
f32, f64 | One 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. |
char | One char atom labeled with the character itself. | Read the character from the label. |
string | One string atom labeled with the raw UTF-8 text. | Supply that text to Serde's string visitor. |
byte array | One bytes atom labeled like [0, 255]. | Parse the list of decimal bytes from the label. |
option | None 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). |
unit | One unit atom labeled (). | Visit unit. |
unit_struct | One unit_struct atom labeled with the struct name. | Visit unit for the requested struct type. |
unit_variant | One 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_struct | One newtype_struct atom labeled with its name, plus value(wrapper, inner). | Follow value and deserialize the inner value. |
newtype_variant | One enum-type atom labeled with the variant name, plus variant_value(variant, inner). | Select the variant and follow variant_value. |
seq | One sequence atom and idx(sequence, position, element) for each element. | Sort by the integer position and visit elements in order. |
tuple | One tuple atom and one idx tuple per element. | Read positional elements in order. |
tuple_struct | One tuple_struct atom labeled with its name and one idx tuple per field. | Read positional fields in order for the requested type. |
tuple_variant | One enum-type atom labeled with the variant name and one idx tuple per field. | Select the variant, then read positional fields in order. |
map | One 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. |
struct | One 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_variant | One 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,Deserializesupplies its default, which can change the value and itsDebugoutput.skip_serializing_ifcan round trip when the omitted value is recoverable through a matching default. A custom lossySerializeimplementation has the same general limit. - Some Serde representations need self-description.
flatten,untagged, and internally or adjacently tagged enums can calldeserialize_anybefore 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 asmap), a unit variant whose name equals its enum name, and a one-field struct variant whose only field isvariant_valueare 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 throughdeserialize_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.
NaNcannot satisfyR-eqbecause it is unequal to itself; the corpus checks itsDebugrepresentation instead. - Order and identity have limits.
idxpreserves sequence order. Map entries follow serialization order, which need not be stable forHashMap. A reconstructedHashMapmay print entries in a different order, so the corpus checksR-eqfor it, not exactDebugtext. 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, andvariant_valueaccording 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 fmtclean, andcargo clippy --all-targets -- -D warningsclean.- Add an entry to the
Unreleasedsection 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.