JSON parsing and string encoding in 100% pure Zsh.
Source the library and work with native Zsh scalars, arrays, and associative arrays. No jq, Python, external commands, third-party modules, or build step is required. The library uses Zsh builtins and parameter expansion only.
This is the first standalone extraction of the JSON core from
zcoder.zsh, with a new zjson_ API. Version 0.1.0 is an initial
development release; the API may evolve. Zsh 5.8 and newer are supported.
The release test matrix runs actual Zsh 5.8 and 5.9.2 on Linux, in both C and
C.UTF-8 locales, with the correctness suite's command path empty.
Copy this directory into your project, keeping zjson.zsh beside lib/, then
source the entry point from Zsh:
source /path/to/zjson/zjson.zsh
if zjson_parse_object '{"name":"zjson","enabled":true,"ports":[8080,8081]}'; then
print -r -- "${ZJSON_OBJECT[name]}" # zjson
print -r -- "${ZJSON_OBJECT_TYPES[enabled]}" # true
zjson_parse_array "${ZJSON_OBJECT[ports]}"
print -rl -- "${ZJSON_ARRAY[@]}" # one port per line
else
print -u2 -r -- "$ZJSON_ERROR"
fi
zjson_quote $'hello\nworld'
print -r -- "$REPLY" # "hello\nworld"
zjson_validate '{"valid":true}' # status 0Call functions directly to retain their result variables in the current shell. They do not print results or diagnostics. Check the return status before reading output; copy results before another call overwrites them.
Run the self-contained example with zsh -f examples/basic.zsh.
| Function | Result |
|---|---|
zjson_validate "$json" |
Validate exactly one complete JSON value. |
zjson_quote "$text" |
Encode a string, including surrounding quotes, into REPLY. |
zjson_utf8_repair "$text" |
Return repaired, unquoted UTF-8 in REPLY, preserving parser state and diagnostics. |
zjson_parse_object "$json" |
Populate ZJSON_OBJECT and ZJSON_OBJECT_TYPES. |
zjson_parse_array "$json" |
Populate ZJSON_ARRAY and ZJSON_ARRAY_TYPES. |
zjson_get "$json" "$pointer" |
Resolve a JSON Pointer into REPLY and ZJSON_TYPE. |
zjson_begin "$json" |
Initialize the tokenizer and read the first token. |
zjson_next |
Advance one token. |
zjson_discard_value |
Validate and consume the value at the current token. |
zjson_skip_value |
Alias function for zjson_discard_value. |
zjson_capture_raw_value |
Consume a value and put its original JSON text in REPLY. |
zjson_capture_value |
Consume a value and put compact, re-encoded JSON in REPLY. |
zjson_with_context callback args... |
Run a callback in the same shell and restore the outer tokenizer and diagnostics. |
Whole-document functions and zjson_quote take exactly one argument, except
zjson_get, which takes JSON and a Pointer. Status is 0 for success, 1 for
invalid JSON, or 2 for incorrect arguments. Lookup also uses 2 for invalid
Pointer syntax and 3 when a valid Pointer cannot resolve a value.
ZJSON_ERROR contains a diagnostic on failure. Object/array results and lookup
results are published only after the entire document succeeds; their respective
outputs are cleared on failure.
zjson_utf8_repair returns 0 even when it repairs malformed bytes, or 2 for
incorrect argument count; it preserves diagnostics in either case.
zjson_with_context returns its callback's status, or 2 if no callback is given.
Strings are decoded; numbers retain their exact text without shell arithmetic or
floating-point conversion. Booleans and null are the strings true, false, and
null. Nested containers retain their original JSON text so you can decode them
in a subsequent call.
Types are string, number, true, false, null, { (object), and [ (array).
This distinguishes "null" from null, and "123" from 123.
Arrays use native Zsh indexing (starting at 1). Objects support empty keys and
literal shell metacharacters; duplicate keys use the last value. Associative
arrays do not preserve object member order. Check key existence with
${+ZJSON_OBJECT[key]} to distinguish a missing key from an empty string.
json='{"servers":[{"host":"localhost","port":8080}],"token":null}'
if zjson_get "$json" /servers/0/host; then
print -r -- "$REPLY ($ZJSON_TYPE)" # localhost (string)
fi
zjson_get "$json" /token # status 0, REPLY=null, type=null
zjson_get "$json" /missing # status 3, empty result and typezjson_get accepts the RFC 6901 JSON Pointer string form:
''selects the root;/selects an object member with an empty key./servers/0/hostselects the first server's host. Pointer array indexes start at 0, while nativeZJSON_ARRAYindexes start at 1.- Inside a key,
~1represents/and~0represents~. For example,/a~1bselectsa/b, and/~01selects the literal key~1. - Keys are literal UTF-8 strings, with no globbing, shell evaluation, or Unicode normalization. Numeric object keys are literal too.
- A referenced duplicate member is ambiguous and fails. Duplicates elsewhere remain permitted; selecting a container retains its original members.
- Array indexes must be
0or decimal digits without leading zeros.-refers to the nonexistent position after the last element and returns status 3. - URI fragments such as
#/servers/0are not accepted. Percent sequences in keys are literal; callers pass the already decoded Pointer string.
Strings are decoded, numbers retain their spelling, and containers retain their source text. The result type uses the same vocabulary as the object/array APIs. Lookup validates skipped values and requires EOF before publishing results. Malformed JSON takes precedence over a missing or ambiguous value. Pointer syntax is checked before parsing the document.
Existing readable errors remain in ZJSON_ERROR. Callers can also inspect:
| Variable | Meaning |
|---|---|
ZJSON_ERROR_CODE |
Stable machine-readable error code. Empty on success. |
ZJSON_ERROR_OFFSET |
1-based byte offset in JSON, including length + 1 for EOF. |
ZJSON_ERROR_LINE |
1-based line number; LF starts a new line. |
ZJSON_ERROR_COLUMN |
1-based byte column, consistent across locales. |
Argument and Pointer-syntax errors have no JSON location; all three location fields are zero. Resolution failures point to the container or scalar where lookup failed. Line and column are computed only on failure. New document or quote operations reset diagnostics; advancing a failed tokenizer preserves them. See diagnostic codes for the complete contract.
zjson_utf8_repair "$text" is useful for pasted text and cached response bodies.
It preserves valid bytes, including NUL, and replaces malformed UTF-8 prefixes
with U+FFFD while retaining later valid characters. Its output is unquoted.
Only REPLY changes; tokenizer state and active diagnostics are preserved.
Use zjson_with_context when an operation needs to parse unrelated JSON while
an outer tokenizer is active:
zjson_begin '[1,2]'
zjson_next # outer token: 1
zjson_with_context zjson_get '{"name":"inner"}' /name
print -r -- "$REPLY" # inner
zjson_next # outer token: commaThe callback runs in the same shell. Source, byte array, cursor, current token,
diagnostics, and parser scratch state are restored on return or error unwinding.
REPLY, ZJSON_TYPE, and object/array decoder results remain callback outputs.
Application variable updates also remain visible. Nested scopes are supported;
state copying occurs only at these explicit boundaries. See the full
embedding contract.
ZJSON_TOKEN_TYPE contains a value type, punctuation ({ } [ ] : ,), or eof.
ZJSON_TOKEN_VALUE contains decoded string text or the literal token text.
ZJSON_TOKEN_START is the token's 1-based byte offset; ZJSON_POS is the
scanner cursor. Tokenization alone does not validate container grammar or require
EOF. Use zjson_validate for complete validation.
The consume/capture functions leave the next token current. Raw capture excludes surrounding whitespace and preserves internal whitespace, number spelling, and escape spelling. Compact capture removes structural whitespace and re-encodes strings; it is not a canonicalization algorithm.
- Input must be UTF-8. Parsing rejects malformed UTF-8, a leading BOM, unescaped controls, invalid escapes and numbers, trailing commas, and trailing content.
- Unicode escapes decode to UTF-8 in both UTF-8 and C locales. Paired UTF-16 surrogates decode together; unpaired surrogates become U+FFFD, preserving the original parser's recovery policy.
- Strict input validation uses native multibyte character classes when an active UTF-8 locale is available, with explicit rejection above U+10FFFF. Other locales use a byte grammar. Both paths enforce the same encoding rules.
- String encoding replaces malformed UTF-8 prefixes with U+FFFD and escapes all JSON control characters. It is a text encoder, not a lossless binary codec.
- Zsh scalars can retain NUL; the library preserves it through
\u0000. External process arguments cannot carry NUL. - Container nesting is limited to 128. Parsing holds the complete source and a byte array in memory. It is intended for shell-sized documents, not streaming large datasets; input size has no separate hard cap.
- There is one shared tokenizer state. Calls preserve shell options and locale,
but replace the documented
ZJSON_*results. ReserveZJSON_*,zjson_*, and_zjson_*names for the library.REPLYfollows Zsh's caller-local output convention. Usezjson_with_contextfor nested operations. The tokenizer is not incremental, and simultaneous independent parser objects are not provided.
zsh -f tests/run.zsh
LC_ALL=C zsh -f tests/run.zsh
zsh -f -n zjson.zsh
zsh -f -n lib/zjson.zsh
zsh -f -n lib/pointer.zsh
zsh -f tests/matrix.zsh /path/to/zsh-5.8 /path/to/zsh-5.9.2
zsh -f benchmarks/run.zsh 5The test runner empties PATH before loading the library and running every
assertion. Tests cover valid and malformed syntax, scalar types, nested values,
control characters, Unicode and malformed UTF-8, exact source capture, nesting
limits, shell metacharacters, and caller option/alias isolation.
Pointer examples, diagnostic locations, and deterministic mixed-escape strings
extend the same dependency-free suite. See benchmark methodology and results.
The CI workflow provisions versioned Zsh
interpreters and runs the matrix on pushes, pull requests, and releases.
Provisioning the test interpreters uses the runner's build tools; installing or
using zjson itself requires no compilation. See compatibility testing.
Licensed under Apache-2.0. See origin and extraction notes.