jsonfix
jsonfix repairs, extracts, and parses the JSON that language models, logs,
and hand-edited files almost produce: trailing commas, 'single quotes',
markdown fences, prose around the document, None instead of null, or a
stream cut off mid-token. One call turns it into valid JSON — or into the best
prefix of it — and tells you byte-exactly what it could not save.
Zero dependencies, #![no_std] + alloc, #![forbid(unsafe_code)], lossless
numbers. Modeled on the excellent JavaScript
jsonrepair library.
Install
[]
= "0.1"
| Feature | Adds | Default |
|---|---|---|
| (none) | repair, extract, parse, stream | ✔ |
std |
repair_to_writer (io::Write sinks) |
— |
serde |
from_value: Value → your own types |
— |
serde_json |
loads / deserialize (implies serde) |
— |
Works with default-features = false — the crate needs only alloc.
The five verbs
| Function | Use it for |
|---|---|
repair |
turn broken JSON into a valid String |
extract |
pull the JSON span out of prose or a fence |
parse / parse_partial |
get a Value tree, complete or still streaming |
StreamRepairer |
repair a document that arrives chunk by chunk |
validate |
strict check with byte-precise errors |
use ;
let reply = "Sure! Here you go:\n```json\n{name: 'Ada', age: 36,}\n```";
// A chat reply has prose around the JSON: extract first, then repair.
assert_eq!;
assert_eq!;
// Without prose the same functions work on the bare document.
assert_eq!;
assert_eq!;
Repairing
repair returns canonical JSON text; repair_with takes Options.
All of the damage below is repaired by default (each is a toggleable pass):
| Damage | Input | Output |
|---|---|---|
| unquoted keys/values | {name: 'Ada'} |
{"name": "Ada"} |
| trailing / missing commas | [1, 2,] |
[1, 2] |
| comments | {"a": 1 /* note */} |
{"a": 1} |
True/False/None/undefined |
{a: True, b: None} |
{"a": true, "b": null} |
NaN/Infinity |
{"a": NaN} |
{"a": "NaN"} |
| numeric oddities | [.5, 2., 2e, -, 01] |
[0.5, 2.0, 2e0, -0, "01"] |
| typographic quotes | {"a": “v”} |
{"a": "v"} |
| missing escapes | {"a": "x"y"z"} |
{"a": "x\"y\"z"} |
| string concatenation | {"a": "x" + "y"} |
{"a": "xy"} |
| wrapper calls | NumberLong(2), ISODate("d") |
2, "d" |
| ellipses | [1, 2, ...] |
[1, 2] |
| truncation | {"a": [1, 2 |
{"a": [1, 2]} |
| NDJSON / several values | 1\n2\n3 |
[1, 2, 3] |
| markdown fences + prose | ```json\n{...}\n``` |
the document |
| JSONP | cb({"a": 1}) |
{"a": 1} |
| one escaping layer too many | {\"a\": 1} |
{"a": 1} |
use repair;
assert_eq!;
assert_eq!;
assert_eq!;
Finding JSON in prose
extract returns the raw span (unrepaired) of the first value; extract_all
returns every top-level span; extract_partial returns from the first value to
the end of input (useful while a stream is still arriving). repair_extract is
the convenience combo: extract, repair, and fall back to repairing the whole
input if the span is not JSON.
use ;
assert_eq!;
assert_eq!;
assert_eq!;
assert_eq!;
// Combo: prose + fence + damage in one call.
let reply = "Sure!\n```json\n{\"answer\": 42,}\n```\nDone.";
assert_eq!;
Parsing to a tree
parse gives a Value with accessor helpers, RFC 6901 pointers, and a
lossless Number that keeps the original digit text — 64-bit IDs and trailing
zeros survive a round trip byte for byte.
use parse;
let value = parse.unwrap;
// Numbers keep their exact text; parse them only if they fit.
let id = value.get.and_then.unwrap;
assert_eq!;
assert_eq!; // exact, never via f64
assert_eq!;
// Trees: index / get / pointer (RFC 6901) / len.
let doc = parse.unwrap;
// `[]` never panics: a missing key or index yields `null`, and values
// compare directly against strings, bools, and integers.
assert_eq!;
assert!;
assert_eq!;
assert_eq!;
assert_eq!;
// Render back to JSON text.
assert_eq!;
Partial parsing and truncated input
While a model is still writing, the document ends mid-value. Allow says which
cut-off constructs may be kept; parse_partial (or Options::partial) enables
the repair passes and the policy.
| Flag | Keeps a cut-off… |
|---|---|
Allow::STR |
string ("Hel) |
Allow::NUM |
number (1., 2e, -) |
Allow::ARR / Allow::OBJ |
array / object |
Allow::KEY |
object key ("x → "x": null) |
Allow::BOOL / Allow::NULL |
tru, fal / nul |
Allow::ATOM / Allow::COLLECTION / Allow::ALL |
shorthands |
A member whose key is cut off is kept (as null) only with Allow::KEY;
otherwise the unfinished member is dropped:
use ;
let cut = r#"{"answer": "Hel"#;
let opts = partial;
assert_eq!;
let torn = r#"{"key": "v, "x"#;
// Unfinished member "x is dropped ...
assert_eq!;
// ... unless Allow::KEY keeps it, as null.
assert_eq!;
Streaming token by token
StreamRepairer accepts the document chunk by chunk (LLM tokens) and, after
every chunk, returns exactly what repairing the whole input so far would
return. push re-renders the full repaired document; push_delta returns just
the change (keep this many bytes of your buffer, then append text) — which
is what a chat UI wants. reset() starts a new document.
use StreamRepairer;
let mut stream = new;
assert_eq!;
assert_eq!;
assert_eq!;
assert_eq!;
// Deltas: patch an on-screen buffer in place.
let mut stream = new;
let mut shown = Stringnew;
for chunk in
assert_eq!;
Choosing what gets repaired
Every repair pass is a bit in Repairs (FENCES, COMMENTS, UNQUOTED,
KEYWORDS, NUMBERS, CONCATENATION, CALLS, ENTITIES, QUOTES,
WHITESPACE, TRUNCATION, NDJSON; ALL / NONE shorthands), and
Options::strict() is a plain validator. With repairs off, repair reports
byte-precise errors instead of guessing.
use ;
// Fences and comments only: a bare word stays an error.
let opts = all.with_repairs;
assert_eq!;
assert!;
assert!;
assert!;
Errors
Failures carry the class, a stable message, and a 0-based byte offset:
use ;
let err = validate.unwrap_err;
assert_eq!;
assert_eq!;
assert_eq!; // stable, no position
assert_eq!;
// Wrapper errors display their inner error.
let mut sink = Vecnew;
let err = repair_to_writer.unwrap_err;
assert_eq!;
let err = .unwrap_err;
assert_eq!;
serde integration
loads returns a serde_json::Value in one call, deserialize reads straight
into your own types, and from_value does the same without serde_json.
Numbers outside the finite f64 range survive as strings instead of erroring
the way serde_json::from_str does.
let reply: Reply = deserialize.unwrap;
assert_eq!;
let value = loads.unwrap;
assert_eq!; // out-of-range text survives as a string
// `serde` only: no serde_json needed.
let reply: Reply = from_value.unwrap;
assert_eq!;
// Value <-> serde_json::Value conversions (feature `serde_json`).
use Value;
assert_eq!;
assert_eq!;
Byte and writer APIs
For FFI/C-ABI sinks and Vec<u8> buffers: repair_bytes / repair_bytes_with
/ repair_bytes_into are alloc-only (no std) and byte-identical to repair.
For files and sockets: repair_to_writer (feature std) writes in one
write_all and never touches the sink on failure. Value::write_to appends
JSON text to any String.
// alloc only — no `std` needed.
assert_eq!;
let err = repair_bytes.unwrap_err;
assert_eq!; // exact offset of the first bad byte
// Feature `std`: files, sockets, any io::Write.
let mut sink = Vecnew;
repair_to_writer.unwrap;
assert_eq!;
use parse;
let mut buf = Stringfrom;
parse.unwrap.write_to;
assert_eq!;
Guarantees and edge cases
- Lossless numbers:
Numbernever routes text throughf64.as_u64/as_i64parse exactly (Noneif the text does not fit);as_stris always the original text;as_f64saturates (1e400→inf), so useas_strwhen exactness matters. Valid input round-trips unchanged (1.10stays1.10). - Depth cap: nesting deeper than
MAX_NESTING_DEPTHerrors withErrorKind::DepthLimitExceededinstead of overflowing the stack — even under full repair. - Prose is never invented, but bare words become strings:
hello worldrepairs to the JSON string"hello world"(theUNQUOTEDpass doing its job); empty input errors withNoValueFound. Useextractfirst when only a JSON region should be considered. - Every pass is opt-out, and
Options::strict()accepts nothing.
The rest of the surface
Tree accessors, streaming state, and bitmask algebra — the pieces the sections above use but do not enumerate:
use ;
// Every Value kind is matchable; objects keep insertion order and duplicates.
let kinds: = parse
.unwrap.as_array.unwrap.iter.map.collect;
assert_eq!;
assert!;
assert!;
assert_eq!;
// Number accessors are exact-or-None; f64 is the saturating convenience.
assert_eq!;
assert_eq!;
// Build and edit trees: `From` conversions, `collect`, `get_mut`, `take`.
let mut reply = parse.unwrap;
reply.get_mut.and_then.unwrap.push;
*reply.pointer_mut.unwrap = from;
assert_eq!;
let tags = reply.get_mut.unwrap.take; // moved out, `null` left behind
assert_eq!;
// Stream state: raw input, repaired output, byte count.
let mut stream = new;
stream.push.unwrap;
assert_eq!;
assert_eq!;
assert_eq!;
assert!;
// Bitmask algebra: |, union, without, contains, bits, is_empty.
assert_eq!;
assert!;
assert_eq!;
assert!;
assert!;
Complete API reference
Every public item, in one place. Options fields (allow, repairs) are
public; ErrorKind, WriteError, and DeserializeError are
#[non_exhaustive], so match them with a wildcard arm.
| Item | Returns | Notes |
|---|---|---|
parse(input) / parse_with(input, Options) |
Result<Value, Error> |
tree from possibly-broken input |
parse_partial(input, Options) |
Result<Value, Error> |
alias of parse_with, named for the partial case |
validate(input) |
Result<Value, Error> |
strict JSON check |
repair(input) / repair_with(input, Options) |
Result<String, Error> |
canonical JSON text |
repair_into(input, &mut String, Options) |
Result<(), Error> |
append to a buffer; untouched on error |
repair_extract(input) |
Result<String, Error> |
extract + repair, whole-input fallback |
extract(input) |
Option<&str> |
first JSON span, unrepaired |
extract_all(input) |
Vec<&str> |
every top-level span |
extract_partial(input) |
&str |
first span through end of input |
repair_bytes / repair_bytes_with |
Result<Vec<u8>, Error> |
byte output, alloc-only |
repair_bytes_into(input, &mut Vec<u8>, Options) |
Result<(), Error> |
byte buffer, untouched on error |
repair_to_writer(input, &mut impl Write, Options) |
Result<(), WriteError> |
(feature std) |
deserialize / deserialize_with |
Result<T, DeserializeError> |
(feature serde_json) |
loads / loads_with |
Result<serde_json::Value, DeserializeError> |
(feature serde_json) |
from_value(Value) |
Result<T, serde::de::value::Error> |
(feature serde) |
MAX_NESTING_DEPTH |
usize constant |
nesting cap (deeper input errors) |
| Type | Shape | Methods / fields |
|---|---|---|
Value |
Null, Bool, Number, String, Array(Vec<Value>), Object(Vec<(String, Value)>) |
as_str, as_bool, as_number, as_f64, as_i64, as_u64, as_array, as_object, get, index, pointer, as_array_mut, as_object_mut, get_mut, pointer_mut, take, len, is_null, is_bool, is_number, is_string, is_array, is_object, is_empty, write_to, to_json_string, to_serde_json / from_serde_json (serde_json); v["key"] / v[0] (never panic, null when absent); == against str/String/bool/integers/floats; From bool, strings, integers, (), Vecs; FromIterator |
Number |
the original digit text | as_str, as_f64, as_i64, as_u64 |
Options |
allow: Allow, repairs: Repairs |
all, strict, partial, with_repairs, with_allow, repairs, allows |
Allow |
NOTHING/NONE, STR, NUM, ARR, OBJ, KEY, BOOL, NULL, ATOM, COLLECTION, ALL |
contains, is_empty, bits, union, without, BitOr |
Repairs |
NONE, FENCES, COMMENTS, UNQUOTED, KEYWORDS, NUMBERS, CONCATENATION, CALLS, ENTITIES, QUOTES, WHITESPACE, TRUNCATION, NDJSON, ALL |
contains, bits, union, without, BitOr |
Error |
kind + byte offset | new, kind, position, message; Display, core::error::Error |
ErrorKind |
UnexpectedEnd, UnexpectedCharacter(char), ExpectedObjectKey, ExpectedColon, NoValueFound, InvalidEscape, InvalidUnicodeEscape, ExpectedComma, TrailingComma, TrailingValue, UnquotedValue, DepthLimitExceeded, InvalidUtf8 |
#[non_exhaustive] |
StreamRepairer |
accumulated input + rendered output | new, with_options, push, push_delta, value, input, output, len, is_empty, reset |
Delta<'a> |
keep: usize, text: &'a str |
— |
WriteError (std) |
Repair(Error) | Write(io::Error) |
#[non_exhaustive] |
DeserializeError (serde_json) |
Repair(Error) | Json(String) |
#[non_exhaustive] |
How it compares
Feature surface vs the other JSON-repair crates on crates.io (sources and READMEs checked 2026-09; "—" means the crate does not ship the feature):
jsonfix |
jsonrepair |
jsonrepair-rs |
llm_json |
repair_json |
|
|---|---|---|---|---|---|
| Dependencies | 0 | 2 (memchr, thiserror) |
0 (opt. serde*) |
3 (serde_json, clap, ...) |
1 (thiserror) |
no_std + alloc |
✔ | — | — | — | — |
Lossless number text (u64 IDs, 1.10) |
✔ | — | — | — | — |
| Repair passes you can toggle | 12 passes | 10 options | 1 (strict) |
4 bools | — |
| Partial-parse policy (per value kind) | 7 flags | — | — | 1 global toggle | streaming only |
Incremental Delta output (push_delta) |
✔ | — | — | — | — |
| Byte-precise error positions | ✔ | — | — | — | — |
| Streaming API | ✔ | ✔ | ✔ | ✔ | ✔ |
The row that matters for LLM pipelines: when a stream truncates mid-number,
jsonfix keeps the digits it saw and reports byte-exact offsets — no other
crate in the table promises either.
Performance
On the 13.2 KB fenced-LLM-reply fixture from the bench suite (cargo bench -p jsonfix-benchmarks --bench repair):
| Operation | Time | Throughput |
|---|---|---|
repair |
~276–283 μs | ~44–46 MiB/s |
repair_extract |
~282–290 μs | ~43–45 MiB/s |
For context on the same machine, validate costs about the same as repair,
and both are ≈2× a native serde_json::from_str on the repaired output
(~190 μs). Numbers are single-machine criterion medians and move with the
machine; the ratios are the claim.
No standard library required
#![no_std] with alloc only, and #![forbid(unsafe_code)] — which matters
when the input comes from an untrusted model or a hostile log line. The
optional serde/serde_json features are declared default-features = false
(alloc-only), so they do not pull std in.
Development
$ cargo test --all-features # full suite + doctests
$ cargo test --no-default-features --lib # alloc-only surface
$ cargo clippy --all-targets --all-features -- -D warnings
$ cargo bench -p jsonfix-benchmarks --bench repair # criterion
$ cargo +nightly fuzz run repair -- -runs=50000 # harness in fuzz/
Behaviour follows the widely used JavaScript jsonrepair for the cases it
defines, expressed here as a tolerant lexer plus a lenient parser; the
streaming and partial-parse policies go beyond it.
License
Dual-licensed under MIT or Apache-2.0, at your option.