A port of the canonical TypeScript implementation. Behaviour is defined by
../typescript/src/StructUtility.tsand pinned by the shared corpus; this port matches it. The in-depth companion toREADME.md(quick-start + signature reference) and the language-neutral../DOCS.md.
Four parts, each with a different job:
- Tutorial — install and learn the API hands-on.
- How-to guides — recipes for specific tasks.
- Reference — signatures live in
README.md; this section adds the Java-specific semantics and types. - Explanation — the model, the port's role, and Java-specific behaviour.
Then: Build, test, extend.
Status nuance. The top-level
README.mdlists Java under Partial, but the port now defines the full canonical API (all 48 functions, theInjectionstate machine,SKIP/DELETE, the mode constants, all 11 transform commands, the validate checkers and select operators) andpython3 ../tools/check_parity.pyreports itok. Treat "Partial" as a stale label; the parity tool and the source are ground truth.
Build from the java/ directory (not yet a published artifact):
cd java
mvn -DskipTests compile # or: make buildGroup / artifact com.voxgig:struct-java; single class
voxgig.struct.Struct (import voxgig.struct.Struct;). The library proper
has zero runtime dependencies — it hand-rolls its own JSON printer
(jsonify). Gson is test scope only, to load the shared corpus.
Every method is static on Struct. The data model is JSON-shaped
Object: Map<String,Object> for maps, List<Object> for lists, plus
String / Number / Boolean / null scalars. The jm / jt builders
return mutable LinkedHashMap / ArrayList:
Object config = Struct.merge(Struct.jt(
Struct.jm("db", Struct.jm("host", "localhost", "port", 5432), "debug", false), // defaults
Struct.jm("db", Struct.jm("host", "db.internal"), "debug", true))); // overrides
Struct.getpath(config, "db.host"); // "db.internal"
Struct.getpath(config, "db.port"); // 5432 (survived the deep merge)
merge/setpath/walk(andselect, which tags child nodes with$KEY) write back into their input nodes, so feed them mutable containers —jm/jt,LinkedHashMap/ArrayList, orStruct.clone(...)first.Map.of(...)is immutable and will throw if mutated; it is fine only as read-only input to cloning operations (transform,validate, which clonedata/specinternally).
Each call has the same meaning in every port; only the syntax changes. See
../DOCS.md for the language-neutral
walkthrough; the Java-flavoured version:
// Reshape by example — the spec mirrors the output you want.
Struct.transform(
Map.of("user", Map.of("first", "Ada", "last", "Lovelace"), "age", 36),
Map.of("name", "`user.first`", "surname", "`user.last`", "years", "`age`"));
// { name: "Ada", surname: "Lovelace", years: 36 }
// Validate by example — leaves are type checkers; throws on mismatch.
Struct.validate(Map.of("name", "Ada", "age", 36),
Map.of("name", "`$STRING`", "age", "`$INTEGER`"));
// Walk the tree — replace values on ascent (after callback).
Struct.walk(tree, null, (key, val, parent, path) -> val == null ? "DEFAULT" : val);
// Select children by query — each match tagged with its $KEY (mutates
// child nodes, so build them mutable).
Struct.select(Struct.jm("a", Struct.jm("age", 30), "b", Struct.jm("age", 25)),
Map.of("age", 30));
// [ { age: 30, $KEY: "a" } ]import voxgig.struct.Struct.StructUtility;
StructUtility su = new StructUtility();
su.getpath(Map.of("a", Map.of("b", 1)), "a.b"); // 1Struct.StructUtility mirrors the canonical StructUtility class — every
function is an instance method, sentinels/constants are instance fields —
for consumers that want to swap the implementation.
List<Object> errs = new ArrayList<>();
Struct.validate(payload, spec, Map.of("errs", errs));
if (!errs.isEmpty()) { /* report them */ }Supply an errs list in the options map; validate (and transform)
accumulate into it instead of throwing.
List<Object> spec = new ArrayList<>();
spec.add("`$APPLY`");
spec.add((Function<Object,Object>) v -> 1 + ((Number) v).intValue()); // the function
spec.add(1); // its argument value
Struct.transform(new LinkedHashMap<>(), spec); // 2The spec is ["$APPLY", fn, value]: a java.util.function.Function
inlined as element 1 (not a name — injectorArgs type-checks it as
T_function), with element 2 the value/sub-spec it receives. Note path
inside a WalkApply callback is reused — copy it (new ArrayList<>(path))
to retain it past the call.
A transform command like $EACH appears in value position — as the
first element of a list ["$EACH", path, subspec] — mapping the sub-spec
over every entry at path:
Struct.transform(
Struct.jm("v", 1, "a", Struct.jt(Struct.jm("q", 13), Struct.jm("q", 23))),
Struct.jm("x", Struct.jm("y", Struct.jt("`$EACH`", "a",
Struct.jm("q", "`$COPY`", "r", "`.q`", "p", "`...v`")))));
// { x={ y=[{ q=13, r=13, p=1 }, { q=23, r=23, p=1 }] } }Putting a command in key position (or, for $APPLY, directly under a
map) is an error — commands must be list values:
Struct.transform(new LinkedHashMap<>(), Struct.jm("x", "`$APPLY`"));
// throws: $APPLY: invalid placement in parent map, expected: list.Struct.jsonify(value); // pretty (2-space), insertion-ordered keys
Struct.jsonify(value, Map.of("indent", 0)); // compact — flags are a Map (indent/offset)
Struct.stringify(value, 80); // truncated human form, for logsjsonify pretty-prints by default (indent 2), nesting lists and maps:
Struct.jsonify(Struct.jm("a", 1, "b", Struct.jt(2, 3)));
// {
// "a": 1,
// "b": [
// 2,
// 3
// ]
// }For more task recipes (rename fields, $EACH, $MERGE, $FORMAT, $ONE,
$EXACT, …) see the language-neutral
How-to guides — the spec syntax is identical;
only the host literals differ.
The Java signatures, with examples, are in
README.md → Function reference; the
canonical public surface (48 names) is the export { … } block in
../typescript/src/StructUtility.ts,
which ../tools/check_parity.py checks this port against.
Java-specific points the signatures don't show:
Objectat the boundaries. Inputs/outputs are untyped JSON-shapedObject;isnode/ismap/islistare plainboolean(no type-guard narrowing), so a downcast still needsinstanceofor a cast.UNDEF, notnull, marks absent.Struct.UNDEFis a singletonObjectfor TSundefined; Group A readers return it (or youralt) for a missing slot, while a storednullis a distinct JSON value (seenullversus absent).getpropvsgetelem.getpropworks on maps and lists;getelemis list-specific, supports-1-from-the-end indexing, and invokes a callablealt(Function) when the element is absent.- Overloads stand in for optional params (no default args): e.g.
walk(val, apply)/walk(val, before, after[, maxdepth]),getprop(val, key[, alt]),transform(data, spec[, options]), and the same forvalidate/inject/merge/slice/pathify/jsonify/stringify/flatten. Options ride in aMap(extra,modify,errs,meta,handler). - Type flags combine bitwise.
typify("hi")isT_scalar | T_string; test with0 != (Struct.T_string & t).typify(Struct.UNDEF)isT_noval;typify(null)isT_scalar | T_null;typename(t)names the highest set bit.
The core functions keep the canonical lowercase names exactly, even the
compound ones (getprop, setprop, getpath, setpath, getelem,
getdef, delprop, haskey, strkey, join, isnode, escre, escurl,
keysof, pathify, jsonify, stringify); only the regex layer
(reCompile / reTest / reFind / reFindAll / reReplace / reEscape)
and the three injection helpers (checkPlacement / injectorArgs /
injectChild) use camelCase. Parity is checked case/underscore-insensitively.
(getElem / getProp / joinUrl / escapeRegex / escapeUrl do not
exist as methods — read by their real lowercase names above.)
This is a port: the canonical TS is the source of truth and the shared
corpus in ../build/test/ is the contract. Answer
behaviour questions by reading the canonical TS, not by polling ports; a
behaviour change starts there and flows to every port (see
../AGENTS.md).
Java has only null, so the port introduces Struct.UNDEF (a singleton
Object) to carry TypeScript's undefined and keep the
Group A/B rule intact:
- Group A — readers (
getprop,getelem,haskey,isempty,isnode): a storednullreads as no value (you getaltorfalse); a truly missing slot returnsUNDEF. - Group B — value processors (
setprop,clone,walk,merge,inject,transform,validate,select, …):nullis preserved literally.
The corpus marks a real null "__NULL__" (and uses "__UNDEF__" /
"__EXISTS__" in match assertions); the runner round-trips these. This is
the single most common source of port bugs — get it right.
Maps are LinkedHashMap<String,Object> (insertion-ordered, as canonical
jsonify requires); lists are ArrayList<Object>. Both are mutable and
shared by reference, so the canonical "lists are mutable in place" property
holds without the ListRef wrapper that Go and PHP need — walk,
merge, inject, and setpath rely on it.
The uniform six-function layer (reCompile / reTest / reFind /
reFindAll / reReplace / reEscape) wraps java.util.regex.Pattern, a
backtracking engine and a strict superset of the RE2 subset the
corpus stays inside — it allows backreferences and lookaround, but those
don't port, so don't use them. Two sharp edges align with the other
backtracking/ECMA ports (Python, PHP, Perl, Ruby, JS, .NET): catastrophic
backtracking on shapes like ^(a+)+$, and zero-width
reReplace("a*", "abc", "X") returning "XXbXcX" (RE2 ports like Go return
"XbXcX"). See README.md → Regex and
../REGEX_PATHOLOGICAL.md.
cd java
mvn -DskipTests compile # build the library (make build)
mvn test # run the shared corpus suite (make test)
make lint # compile + checkstyle:check + spotbugs:checkmvn test drives the shared corpus from
../build/test/ via the runner in
src/test/ (Runner.java, StructCorpusTest.java), loading
the .jsonic cases with Gson and writing per-file pass counts to
target/corpus-scoreboard.json (committed baseline:
test-baseline.json). The library is
src/Struct.java; sourceDirectory is src/ (flat —
no src/main/java).
Toolchain: source/target level 17 (runs on JDK 21); test deps JUnit 6.1 + Gson (test scope); lint Checkstyle + SpotBugs.
To change behaviour: this is a port — never diverge from the canonical
alone. Edit the canonical TS + corpus first, port the change into
src/Struct.java, mvn test until the scoreboard is green, then re-run
python3 ../tools/check_parity.py. Full checklist in
../AGENTS.md.