nanojson 0.5.0

A #![no_std], allocation-free, zero-dependency JSON serializer and pull-parser.
Documentation

nanojson

Crates.io Docs.rs CI License: MIT Rust 100% Safe no-std compatible

nanojson is a zero-dependency, no-std compatible JSON serializer and pull-parser with hand-written derives (no serde, no pro-macro2). It uses an immediate-mode API, validating your schema while parsing.

use nanojson::{Serialize, Deserialize};

// Annotate your types once:
#[derive(Serialize, Deserialize)]
struct Point { x: i64, y: i64 }

// std API
let json: String = nanojson::stringify(&Point { x: 3, y: 4 })?;
let point: Point = nanojson::parse(&json)?;

// no-std API
let mut buf = [0; 256]; // <-- json string stored here
let json:  &str  = nanojson::stringify_sized(&mut buf, &Point { x: 3, y: 4 })?;
let point: Point = nanojson::parse_sized(&mut [0; 64], json)?; // <- provide scratch buffer

Included blanket implementations, per feature:

  • no-std: primitive types including f32/f64, fixed sized arrays, Option<T>
  • optional support for:
    • alloc: String, Vec<T>, Box<T>, BTreeMap<String, V>
    • std: HashMap<String, V>
    • derive Derive macros #[derive(Serialize, Deserialize)]
    • arrayvec: ArrayVec<T, N> and ArrayString<N>

By default, alloc, std and derive features are enabled


Derive macros

Use derive feature (enabled by default) and annotate your types:

use nanojson::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct Vec2 {
    #[nanojson(default)] // Allow member to be default initialized if 
    x: i64,              // omitted during parsing. 
    #[nanojson(default)]
    y: i64,
}

#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct Entity {
    id: i64,
    #[nanojson(rename = "is_active")]
    active: bool,
    position: Vec2, // <- nested derived struct, works automatically
    health: i64,
}

#[derive(Serialize, Deserialize, Debug, PartialEq)]
enum Team {
    Red,            // <- unit enums format: "Red" (a simple string)
    Blue,
    #[nanojson(rename = "spectator")]
    Spectator,
}

#[derive(Serialize, Deserialize, Debug, PartialEq)]
enum Event {
    // tuple struct format: "VariantName": { member: value, ...}
    Spawn { entity_id: i64, x: i64, y: i64 },
    // single tuple tuples format: "VariantName": value
    Death(i32),
}

Two API tiers

std tier (default feature)

No buffer choices. The output String grows as needed; the scratch buffer for parsing is auto-allocated to src.len() bytes (a safe upper bound).

Serialization

// One-liner for a derived type
let json: String = nanojson::stringify(&entity)?;

// Closure form for hand-written JSON
let json: String = nanojson::stringify_as(|s| {
    s.object_begin()?;
      s.member("name")?; s.string("Alice")?;
      s.member("age")?;  s.integer(30)?;
    s.object_end()
})?;

Deserialization

// One-liner for a derived type
let entity: Entity = nanojson::parse(&json)?;

// Closure form for manual parsing
let json = r#"{"x": 3, "y": 4}"#;
let (x, y) = nanojson::parse_as(&json, |p| {
    p.object_begin()?;
    let mut x = 0i64; let mut y = 0i64;
    while let Some(k) = p.member()? {
        match k {
            "x" => x = p.integer()?,
            "y" => y = p.integer()?,
            _   => {}
        }
    }
    p.object_end()?;
    Ok((x, y))
})?;

no_std tier

All memory on the stack. You choose output buffer size when stringifying, and scratch buffer size for parsing (scratch buffer only needs to fit the longest single field value after escape-decoding, typically 32–128 bytes).

Serialization

// One-liner for a derived type
let mut buf = [0; 256];
let json = nanojson::stringify_sized(&mut buf, &entity)?;

// Closure form
let json = nanojson::stringify_sized_as(&mut buf, |s| {
    s.object_begin()?;
      s.member("name")?; s.string("Alice")?;
      s.member("age")?;  s.integer(30)?;
    s.object_end()
})?;

Deserialization

// One-liner for a derived type (STR_BUF = 64)
let entity: Entity = nanojson::parse_sized(&mut [0; 64], &json)?;

// Low-level parser for hand-written code
let json = r#"{"x": 3, "y": 4}"#.as_bytes();
let (x, y) = nanojson::parse_sized_as(&mut [0; 64], &json, |p| {
    p.object_begin()?;
    let mut x = 0i64; let mut y = 0i64;
    while let Some(k) = p.member()? {
        match k {
            "x" => x = p.integer()?,
            "y" => y = p.integer()?,
            _   => {}
        }
    }
    p.object_end()?;
    Ok((x, y))
})?;

Size estimation

let n = nanojson::measure(|s| entity.serialize(s));
// n is the exact byte count, use it to pick N in stringify_sized / parse_sized.

Pretty-printing

Pass an indent width to the _pretty or _compact variants of any serialization function:

// std tier
let json = nanojson::stringify_pretty(2, &entity)?;       // indent level 
let json = nanojson::stringify_pretty_as(2, |s| { ... })?;
let json = nanojson::stringify_compact(80, 2, &entity)?;  // line len + indent
let json = nanojson::stringify_compact_as(80, 2, |s| { ... })?;

// no_std tier
let mut buf = [0; 256];
let json = nanojson::stringify_sized_pretty(&mut buf, 2, &entity)?;
let json = nanojson::stringify_sized_pretty_as(&mut buf, 2, |s| { ... })?;

Pretty

{
  "name": "Alice",
  "status": {
    "active": true,
    "level": 3
  }
}

Compact: (max line length 50)

{
  "name": "Alice", 
  "status": { "active": true, "level": 3 }
}

Error handling

Parse errors are ParseError { kind: ParseErrorKind, offset: usize }.

The offset is a byte position in the source slice.

Kind Meaning
UnexpectedToken { expected, got } Parser expected one token type, found another
UnexpectedEof Input ended before the value was complete
InvalidEscape(byte) Unknown \X escape sequence
StringBufferOverflow Decoded string didn't fit in the scratch buffer
InvalidUtf8 String content is not valid UTF-8 after unescaping
UnknownField { type_name, expected_fields } Key not recognised by the deserializer
MissingField { field } A required field was absent (used by derived code)

Parse error can be printed with a nice looking error message like so:

match nanojson::parse::<MyStruct>(json) {
    Err(err) =>  err.print(json),
    _        => { ... }
}
27 |    "not_a_valid_field": {},
   |    ^
   |    unknown field in `Inventory`, expected one of: `items`, `metadata`

Serialization errors are SerializeError<W::Error>:

Variant Meaning
Write(e) Write error from the sink (e.g. WriteError::BufferFull from SliceWriter)
DepthExceeded Nesting exceeded the DEPTH const generic (default 32)
InvalidState member called outside an object or twice without an intervening value
InvalidUtf8(offset) Final string isn't utf-8 compatible. This indicates a serialization bug

Workspace layout

nanojson/
├── Cargo.toml                    workspace root
├── nanojson/                     core library (#![no_std], no dependencies)
│   ├── src/
│   │   ├── lib.rs
│   │   ├── write.rs              Write, SliceWriter, SizeCounter
│   │   ├── serialize.rs          Serializer, SerializeError, Serialize
│   │   ├── deserialize.rs        Parser, Deserialize
│   │   └── error.rs              Error types
│   ├── examples/
│   └── tests/
└── nanojson-derive/              proc-macro crate (no syn/quote/proc_macro2)

Core concepts

Concept What it is
Serializer<W> Serializer. You call methods (object_begin, member, integer, …) in order and it writes JSON to your W: Write sink.
Parser<'src, 'buf> Pull parser. You drive it step by step (object_begin, member, integer, …). It never builds a tree.
Write Trait for output sinks. SliceWriter writes into a &mut [u8]. SizeCounter counts bytes without writing (useful for pre-sizing). Vec<u8> implements Write when the std feature is on.
Serialize Trait implemented by types that know how to write themselves. Primitive impls are provided.
Deserialize Trait implemented by types that know how to parse themselves.

Limitations

  • Scratch buffer is reused per string. Parser::string() and Parser::member() both write into the same &mut [u8]. The returned &str is invalidated by the next string parse. Copy the value immediately if you need to keep it.
  • No streaming / async. The serializer writes synchronously to Write; the parser requires the entire input to be in memory at once.
  • No serde compatibility. nanojson is its own trait ecosystem. If you need serde interop, use serde.
  • Non-finite floats are an error. Serializing f32::NAN, f64::INFINITY, etc. returns SerializeError::InvalidValue. JSON has no representation for these values.
  • Nesting depth limit. The serializer's DEPTH const generic (default 32) limits how deeply you can nest objects and arrays. Use Serializer<W, 64> directly for deeper structures.
  • No multi member tuple enum variants
    • Use enum Kind { A { number: i32, name: String } } instead of enum Kind { A(i32, String) }

Running the examples

cargo run --example simple        # simple derive example
cargo run --example nostd         # almost same as simple but no_std
cargo run --example big           # big derive example
cargo run --example manual        # hand-written serialize + parse
cargo run --example derive        # derive-macro workflow
cargo run --example sensor_log    # embedded sensor log
cargo run --example recursive     # recursive tree + depth limits

Running the tests

cargo test                    # std feature (default)
cargo test --no-default-features  # no_std mode