Struct for Rust
Rust port of the canonical TypeScript implementation. Status: complete — the full shared corpus passes (
make test→ 1309 checks;cargo clippyclean): minor utilities,walk,merge,getpath,setpath,inject,transform(all 10 commands),validate(all 15 checkers),select(all operators), and theprimary.checkSDK test.
For motivation, the language-neutral concepts, and the cross-language parity
matrix, see the top-level README and REPORT.md.
Build & test
Inside the monorepo:
# the implemented subsets
Tested with stable Rust 1.80+ (edition 2021). Crate: voxgig-struct;
library path voxgig_struct. Zero runtime third-party dependencies —
the insertion-ordered map type lives in-tree at
src/ordered_map.rs; lazy statics use
std::sync::LazyLock; the regex engine lives at
src/re.rs. serde_json appears under
[dev-dependencies] only — used by the test corpus loader.
use ;
let store = map_of;
let host = get_path;
// host == Value::Str("localhost")
In-memory data model
List / Map are heap-allocated and reference-counted, so a mutation through
one Value is visible to every holder — this is the canonical "lists are
mutable and reference-stable" invariant. Not thread-safe (single-threaded data
model, like the JS canonical).
Function values
Callables embedded in the data (Value::Func) all use one signature —
Fn(&Inj, &Value /*val*/, &str /*ref*/, &Value /*store*/) -> Value — created
with Value::func(closure). The TypeScript canonical is dynamically typed and
uses a few different shapes for the same slots; the Rust port unifies them onto
this signature, so the calling conventions differ slightly from TS:
| Where | Rust call | Read for | TS canonical |
|---|---|---|---|
Transform commands / validate checkers / select operators / the handler |
f(inj, val, ref, store) |
all four | (inj, val, ref, store) — same |
$WHEN / $BT / $DS / $SPEC thunks |
f(inj, val, ref, store) (args ignored) |
— | () — args ignored either way |
$APPLY (['$APPLY', applyFn, child]) |
f(inj, val, "", store) |
val = the resolved child; store; inj = the child injection |
apply(resolved, store, cinj) — same data, different order |
$FORMAT user formatter (['$FORMAT', fn, child]) |
applied to each node of the resolved child; f(inj, val, "", store) |
val = the current node |
walk(resolved, formatter) — TS's formatter also gets (key, parent, path); the Rust form receives only val |
get_elem(list, key, alt) when alt is callable and the element is absent |
f(inj, val, "", store) (a fresh throwaway injection, Noval val/store, empty ref) |
— | alt() — args ignored either way |
In short: a Value::func closure that reads its val argument (and store /
inj if needed) behaves correctly everywhere. For $FORMAT, prefer the seven
built-in named formatters — identity, upper, lower, string,
number, integer, concat — passed as a string; a user function works but
can't see the walk key / parent / path. (Note: callbacks passed as parameters
— walk's before/after, filter's check, InjectDef::modify /
::handler — are ordinary Rust closures and keep their full signatures.)
Name mapping (TS canonical → Rust)
The Rust API uses idiomatic snake_case. The repo already documents per-language
casing (getpath in JS/Py/Lua/Rb/PHP, GetPath in Go/C#, getPath in Java); the
Rust convention is get_path.
| TS | Rust | TS | Rust |
|---|---|---|---|
typename |
type_name |
keysof |
keys_of |
getdef |
get_def |
haskey |
has_key |
isnode / ismap / islist |
is_node / is_map / is_list |
strkey |
str_key |
iskey / isempty / isfunc |
is_key / is_empty / is_func |
escre / escurl |
esc_re / esc_url |
getelem / getprop |
get_elem / get_prop |
getpath / setpath |
get_path / set_path |
setprop / delprop |
set_prop / del_prop |
checkPlacement |
check_placement |
getdef |
get_def |
injectorArgs / injectChild |
injector_args / inject_child |
size / slice / pad / typify |
(same) | clone / walk / merge / inject |
(same) |
items / flatten / filter / join |
(same) | transform / validate / select |
(same) |
jsonify / stringify / pathify |
(same) | jm / jt |
(same) |
Type constants are SCREAMING_SNAKE: T_ANY … T_NODE, M_KEYPRE / M_KEYPOST
/ M_VAL. Sentinels: SKIP, DELETE.
Optional parameters
Rust has no optional/overloaded parameters, so:
get_prop(node, key, alt)/get_elem(list, key, alt)/get_def(val, alt)takealt: Value(passValue::Novalfor the bare case).get_elem_or_elsetakes a lazy alt closure.slice(val, start: Option<i64>, end: Option<i64>, mutate: bool).pad(s, padding: Option<i64>, padchar: Option<String>).walk(val, before: Option<&mut WalkClosure>, after: Option<&mut WalkClosure>, maxdepth: Option<i64>).merge(list, maxdepth: Option<i64>).get_path/set_path/transform/validate/injecttakeinjdef: Option<&InjectDef>(a smallDefaultstruct of the publicly-setPartial<Injection>fields).items(node)returns aValue::Listof[key, value]pairs;items_vecreturnsVec<(String, Value)>. Likewisekeys_of/keysof_vec,filter/filter_vals.
See REPORT.md for the rust-port adaptations
write-up, and ../NOTES.md for cross-port quirks.
Minor utility examples
Concrete examples for the most-used minor utilities. Each call is the Rust expression of the canonical input; the comment shows the Rust-native result.
is_node reports whether a value is a node (map or list):
is_node; // true
is_map / is_list distinguish the two node kinds; is_key accepts a
non-empty string or a number; is_empty reports the empty/absent values:
is_map; // true
is_list; // true
is_key; // true
is_empty; // true
size counts entries of a list/map (or the length of a string):
size; // 3
slice keeps the first N; a negative start drops the last |start| items,
and end is exclusive:
slice; // Value::List([2, 3, 4])
slice; // Value::Str("abc") (drops the last 3)
pad pads on the right (negative padding pads on the left):
pad; // "a "
typify returns an i64 bit-field (a "kind" flag combined with a specific type
flag); type_name maps a flag back to a human name:
typify; // 201326720 (T_SCALAR | T_NUMBER | T_INTEGER)
type_name; // "map" (8192 == T_MAP)
get_prop reads a key from a map or list:
get_prop; // Value::Num(1.0)
set_prop / del_prop return the parent with the key set/removed:
set_prop;
// Value::Map({ a: 1, b: 2 })
del_prop;
// Value::Map({ b: 2 })
get_elem is list-specific and supports negative indexing from the end:
get_elem; // Value::Num(30.0)
has_key tests presence of a key (a stored Null counts as absent):
has_key; // true
items returns the [key, value] pairs of a map (or list) as a Value::List:
items;
// Value::List([["a", 1], ["b", 2]])
str_key coerces a key to its canonical string form (numbers truncate):
str_key; // "2"
keys_of returns sorted string keys of a map:
keys_of;
// Value::List(["a", "b"]) (sorted)
filter passes each (key, value) pair to the check and returns the matching
values (not the pairs), as a Value::List:
filter;
// Value::List([4, 5])
set_path writes a value at a dot path, returning the (mutated) store;
pathify renders a path list back to a dot string:
set_path;
// Value::Map({ a: 1, b: 22 })
pathify;
// "a.b.c"
merge folds a list of nodes — last input wins, maps deep-merge, lists merge
by index; clone makes a deep copy; flatten removes one level of nesting:
merge;
// Value::Map({ a: 1, b: 3, d: 4, e: 8, k: [11, 20], x: { y: 7, z: 6 } })
clone;
// Value::Map({ a: { b: [1, 2] } }) (a deep copy)
flatten;
// Value::List([1, 2, [3]]) (one level by default)
esc_re / esc_url escape for regex / URL contexts; join concatenates with
a separator:
esc_re; // "a\\.b\\+c"
esc_url; // "hello%20world%3F"
join; // "a/b/c"
inject replaces backtick refs in strings with store values; validate
checks data against a by-example shape (Err on mismatch); select finds
children matching a query, each tagged with its $KEY:
inject;
// Value::Map({ x: 1, y: 2 })
validate.unwrap;
// Value::Map({ name: "Ada", age: 36 }) (Err on mismatch)
select;
// Value::List([{ name: "Alice", age: 30, $KEY: "a" }])
Regex
Uniform six-function regex API (see /design/REGEX_API.md). The Rust port
ships its own RE2-subset engine in src/re.rs — no regex crate
dependency, no third-party crates at all (Cargo.toml lists none for
runtime).
API
| Function | Returns |
|---|---|
re_compile(pattern) |
Result<Regex, RegexError> |
re_test(pattern, input) |
bool |
re_find(pattern, input) |
Option<Vec<String>> — [whole, group1, …] |
re_find_all(pattern, input) |
Vec<Vec<String>> |
re_replace(pattern, input, r) |
String |
re_escape(s) |
String |
Dialect
The in-tree engine implements the RE2 subset documented in
/design/REGEX.md: literals + escapes, ., ^/$, * + ? {n} {n,} {n,m}
(greedy + lazy), classes incl. \d \w \s and friends, \b/\B,
(...) / (?:...), alternation.
Not supported (by design — RE2 doesn't either):
backreferences, lookaround, possessive quantifiers, atomic groups.
Backref patterns like ^(a+)\1$ compile (the parser doesn't reject
\1) but never match the back-reference semantically, so re_test
returns false rather than erroring. Don't rely on this — write
portable patterns.
Sharp edges (Rust-specific)
- Bounded quantifiers are unrolled.
a{0,10000}compiles into 10 000 Split+atom-clone pairs. The matcher was previously recursive during epsilon-closure and stack-overflowed on such patterns; it is now iterative (Threads::adduses an explicit work stack).re_test("^a{0,10000}b$", …)now runs in ~10 ms here. - No catastrophic backtracking. Thompson-NFA construction means P1/P2 from the discovery panel run in microseconds.
- Zero-width
re_replace.re_replace("a*", "abc", "X")returns"XXbXcX"— the convention shared with all PCRE/ECMA/Java/.NET engines and the other in-tree Thompson ports (C / Lua / Zig). Go (RE2) returns"XbXcX"instead; see/design/REGEX_PATHOLOGICAL.md. - Single-threaded.
ValueusesRc<RefCell<…>>so it is!Send + !Sync. The regex statics usestd::sync::LazyLockand are thread-safe in isolation, but the public API isn't.
See /design/REGEX_PATHOLOGICAL.md for the cross-port pathological-input panel.