voxgig-struct 0.1.2

Uniform JSON-shaped data structure manipulations — Rust port of @voxgig/struct
Documentation

Struct for Rust

Rust port of the canonical TypeScript implementation. Status: complete — the full shared corpus passes (make test → 1309 checks; cargo clippy clean): minor utilities, walk, merge, getpath, setpath, inject, transform (all 10 commands), validate (all 15 checkers), select (all operators), and the primary.check SDK 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:

cd rust
cargo build
make test           # runs the shared corpus (../build/test/test.json) against
                    # 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 voxgig_struct::{Value, get_path};

let store = Value::map_of([(
    "db".to_string(),
    Value::map_of([("host".to_string(), Value::str("localhost"))]),
)]);
let host = get_path(&store, &Value::str("db.host"), None);
// host == Value::Str("localhost")

In-memory data model

pub enum Value {
    Noval,                                       // TS `undefined` — property absent
    Null,                                        // JSON null — distinct from Noval
    Bool(bool),
    Num(f64),                                    // one numeric kind; integer-ness derived
    Str(String),
    List(Rc<RefCell<Vec<Value>>>),               // reference-stable, mutable in place
    Map(Rc<RefCell<OrderedMap<Value>>>),   // insertion-ordered, reference-stable
    Func(Rc<dyn Fn(&Inj, &Value, &str, &Value) -> Value>),  // callable values in data
    Sentinel(&'static Sentinel),                 // SKIP / DELETE — pointer identity
}

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 signatureFn(&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 formattersidentity, 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 parameterswalk'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_ANYT_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) take alt: Value (pass Value::Noval for the bare case). get_elem_or_else takes 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 / inject take injdef: Option<&InjectDef> (a small Default struct of the publicly-set Partial<Injection> fields).
  • items(node) returns a Value::List of [key, value] pairs; items_vec returns Vec<(String, Value)>. Likewise keys_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(&Value::map_of([("a".into(), Value::Num(1.0))])); // 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(&Value::map_of([("a".into(), Value::Num(1.0))])); // true
is_list(&Value::list(vec![Value::Num(1.0), Value::Num(2.0)])); // true
is_key(&Value::str("name")); // true
is_empty(&Value::empty_list()); // true

size counts entries of a list/map (or the length of a string):

size(&Value::list(vec![Value::Num(1.0), Value::Num(2.0), Value::Num(3.0)])); // 3

slice keeps the first N; a negative start drops the last |start| items, and end is exclusive:

slice(
    Value::list(vec![
        Value::Num(1.0), Value::Num(2.0), Value::Num(3.0), Value::Num(4.0), Value::Num(5.0),
    ]),
    Some(1),
    Some(4),
    false,
); // Value::List([2, 3, 4])
slice(Value::str("abcdef"), Some(-3), None, false); // Value::Str("abc")  (drops the last 3)

pad pads on the right (negative padding pads on the left):

pad(Value::str("a"), Some(3), None); // "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(&Value::Num(1.0)); // 201326720  (T_SCALAR | T_NUMBER | T_INTEGER)
type_name(8192); // "map"  (8192 == T_MAP)

get_prop reads a key from a map or list:

get_prop(&Value::map_of([("x".into(), Value::Num(1.0))]), &Value::str("x"), Value::Noval); // Value::Num(1.0)

set_prop / del_prop return the parent with the key set/removed:

set_prop(Value::map_of([("a".into(), Value::Num(1.0))]), &Value::str("b"), Value::Num(2.0));
// Value::Map({ a: 1, b: 2 })
del_prop(
    Value::map_of([("a".into(), Value::Num(1.0)), ("b".into(), Value::Num(2.0))]),
    &Value::str("a"),
);
// Value::Map({ b: 2 })

get_elem is list-specific and supports negative indexing from the end:

get_elem(
    &Value::list(vec![Value::Num(10.0), Value::Num(20.0), Value::Num(30.0)]),
    &Value::Num(-1.0),
    Value::Noval,
); // Value::Num(30.0)

has_key tests presence of a key (a stored Null counts as absent):

has_key(&Value::map_of([("a".into(), Value::Num(1.0))]), &Value::str("a")); // true

items returns the [key, value] pairs of a map (or list) as a Value::List:

items(&Value::map_of([("a".into(), Value::Num(1.0)), ("b".into(), Value::Num(2.0))]));
// Value::List([["a", 1], ["b", 2]])

str_key coerces a key to its canonical string form (numbers truncate):

str_key(Value::Num(2.2)); // "2"

keys_of returns sorted string keys of a map:

keys_of(&Value::map_of([("b".into(), Value::Num(4.0)), ("a".into(), Value::Num(5.0))]));
// 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(vec![
        Value::Num(1.0), Value::Num(2.0), Value::Num(3.0), Value::Num(4.0), Value::Num(5.0),
    ]),
    |(_k, v)| matches!(v, Value::Num(n) if *n > 3.0),
);
// 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_of([("a".into(), Value::Num(1.0)), ("b".into(), Value::Num(2.0))]),
    &Value::str("b"),
    Value::Num(22.0),
    None,
);
// Value::Map({ a: 1, b: 22 })
pathify(&Value::list(vec![Value::str("a"), Value::str("b"), Value::str("c")]), None, None);
// "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::list(vec![
        Value::map_of([
            ("a".into(), Value::Num(1.0)),
            ("b".into(), Value::Num(2.0)),
            ("k".into(), Value::list(vec![Value::Num(10.0), Value::Num(20.0)])),
            ("x".into(), Value::map_of([
                ("y".into(), Value::Num(5.0)), ("z".into(), Value::Num(6.0)),
            ])),
        ]),
        Value::map_of([
            ("b".into(), Value::Num(3.0)),
            ("d".into(), Value::Num(4.0)),
            ("e".into(), Value::Num(8.0)),
            ("k".into(), Value::list(vec![Value::Num(11.0)])),
            ("x".into(), Value::map_of([("y".into(), Value::Num(7.0))])),
        ]),
    ]),
    None,
);
// Value::Map({ a: 1, b: 3, d: 4, e: 8, k: [11, 20], x: { y: 7, z: 6 } })
clone(&Value::map_of([(
    "a".into(),
    Value::map_of([("b".into(), Value::list(vec![Value::Num(1.0), Value::Num(2.0)]))]),
)]));
// Value::Map({ a: { b: [1, 2] } })  (a deep copy)
flatten(
    &Value::list(vec![
        Value::Num(1.0),
        Value::list(vec![
            Value::Num(2.0),
            Value::list(vec![Value::Num(3.0)]),
        ]),
    ]),
    None,
);
// 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(&Value::str("a.b+c")); // "a\\.b\\+c"
esc_url(&Value::str("hello world?")); // "hello%20world%3F"
join(
    &Value::list(vec![Value::str("a"), Value::str("b"), Value::str("c")]),
    Some("/"),
    false,
); // "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_of([("x".into(), Value::str("`a`")), ("y".into(), Value::Num(2.0))]),
    &Value::map_of([("a".into(), Value::Num(1.0))]),
    None,
);
// Value::Map({ x: 1, y: 2 })
validate(
    &Value::map_of([
        ("name".into(), Value::str("Ada")),
        ("age".into(), Value::Num(36.0)),
    ]),
    &Value::map_of([
        ("name".into(), Value::str("`$STRING`")),
        ("age".into(), Value::str("`$INTEGER`")),
    ]),
    None,
).unwrap();
// Value::Map({ name: "Ada", age: 36 })  (Err on mismatch)
select(
    &Value::map_of([
        ("a".into(), Value::map_of([
            ("name".into(), Value::str("Alice")), ("age".into(), Value::Num(30.0)),
        ])),
        ("b".into(), Value::map_of([
            ("name".into(), Value::str("Bob")), ("age".into(), Value::Num(25.0)),
        ])),
    ]),
    &Value::map_of([("age".into(), Value::Num(30.0))]),
);
// 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::add uses 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. Value uses Rc<RefCell<…>> so it is !Send + !Sync. The regex statics use std::sync::LazyLock and are thread-safe in isolation, but the public API isn't.

See /design/REGEX_PATHOLOGICAL.md for the cross-port pathological-input panel.