json-escape 0.1.2

A highly ergonomic, well-tested, no_std library for streaming JSON string escaping and unescaping. It processes JSON strings with zero-copy slicing and no intermediate allocations, ensuring both high performance and RFC-compliant correctness, ideal for I/O and memory-constrained environments.
Documentation

json-escape

crates.io docs.rs CI

A highly ergonomic, well-tested, no_std library for streaming JSON string escaping and unescaping. It processes JSON strings with zero-copy slicing and no intermediate allocations, ensuring both high performance and RFC-compliant correctness, ideal for parsers, I/O operations, and memory-constrained environments. ✅

The core of the library is two iterator-based structs that enable its streaming nature:

  • Escape: Lazily yields escaped string slices from an input &str.
  • Unescape: Lazily yields unescaped byte slices from an input &[u8].

This streaming approach avoids allocating a single large String or Vec<u8> for the result, making it incredibly efficient for large data processing.


Key Features

  • 🎯 Ergonomic & Intuitive: Simple-to-use functions like escape_str and unescape return familiar iterators that integrate cleanly with Rust's ecosystem.
  • 🚀 Streaming & Iterator-based: Process data in chunks without buffering the entire result in memory, delivering high performance for large payloads.
  • ✨ Zero-Copy Slicing: For sequences of characters that don't need modification, the iterators yield slices borrowed directly from the input.
  • ✅ Correct & Compliant: Fully compliant with the JSON RFC for all escape sequences, including full support for UTF-16 surrogate pairs (\uD83D\uDE00 for 😀).
  • 🧩 no_std Compatible: Usable in embedded systems and other memory-constrained environments (with the alloc feature for owned conversions).
  • ⚙️ Full Functionality: Implements PartialEq for convenient testing and provides std::io::Read integration (with the std feature) for plugging directly into libraries like serde_json.

Quick Start

Escaping a String

use json_escape::escape_str;
use std::borrow::Cow;

let input = "Hello, \"world\"!\nThis is a backslash: \\";
let expected = r#"Hello, \"world\"!\nThis is a backslash: \\"#;

// escape_str returns an iterator. Collect it into a String.
let escaped_string: String = escape_str(input).collect();
assert_eq!(escaped_string, expected);

// For efficiency, convert to a Cow<str>. This avoids allocation
// if the input string requires no escaping.
let cow: Cow<str> = escape_str("no escapes needed").into();
assert!(matches!(cow, Cow::Borrowed(_)));

Unescaping a String

use json_escape::unescape;
use std::borrow::Cow;

let input = r#"Emoji: \uD83D\uDE00 and a tab\t!"#;
let expected = "Emoji: 😀 and a tab\t!";

// unescape returns an iterator over Result<&[u8], _>.
// The `decode_utf8` helper collects and validates the output.
let decoded_cow: Cow<str> = unescape(input).decode_utf8().unwrap();
assert_eq!(decoded_cow, expected);

Performance

The library's design focuses on minimizing allocations, leading to superior performance, especially when dealing with large payloads or zero-copy streaming.

Benchmarks confirm that json-escape is significantly faster than standard methods when avoiding unnecessary allocations.

Operation Scenario json-escape (Median Time) Comparison (e.g., serde_json or Main API)
Escaping (Collect to String) No Escapes $309.95\text{ ns}$ (Explicit API) $338.38\text{ ns}$ (Main API)
Unescaping (Iterate Only) No Escapes $54.999\text{ ns}$ (Explicit API) $88.443\text{ ns}$ (Main API)
Escaping (Iterate Only) Dense Escapes $236.56\text{ ns}$ (Explicit API) $278.02\text{ ns}$ (Main API)
Unescaping (Iterate Only) Dense Escapes $200.87\text{ ns}$ (Explicit API) $501.67\text{ ns}$ (Main API)
Unescaping (Decode UTF-8) Unicode $1.5011\text{ ms}$ (Explicit API) $1.8918\text{ ms}$ (Main API)
Escaping to String Sparse Escapes ~2.8% to ~8.8% Faster serde_json::to_string
Unescaping from Str No Escapes ~2.8% Faster serde_json::from_str

Key Takeaways

  1. Zero-Allocation Wins: For I/O-bound tasks using the Write to Sink or std::io::Read integrations, both the Main and Explicit APIs show near-identical, minimal overhead ($<159~\text{ps}$), which is the most efficient method for large data.
  2. Explicit Module Advantage (Unescaping): The new explicit module significantly outperforms the main API in unescaping when used for pure iteration or in dense/sparse escape scenarios, sometimes being over 2x faster (e.g., Sparse or Dense Escapes). This is likely due to the structural clarity of its chunks simplifying internal logic.
  3. Overall Speed: json-escape consistently outperforms the overhead of allocating and parsing with general-purpose tools like serde_json for simple string conversion tasks.

Fine-Grained Control with the explicit Module

The new json_escape::explicit module provides more detail and control by yielding chunk structs that explicitly separate the literal text from the escaped/unescaped character. This is useful for advanced debugging, logging, and custom stream processing.

Explicit Escaping

The Escape iterator in this module yields an EscapedChunk<'a> containing the literal() slice and the escaped() static string.

use json_escape::explicit::escape_str;

let mut escaper = escape_str("a\nb");

let chunk1 = escaper.next().unwrap();
assert_eq!("a", chunk1.literal());
assert_eq!(Some(r#"\n"#), chunk1.escaped());

let chunk2 = escaper.next().unwrap();
assert_eq!("b", chunk2.literal());
assert_eq!(None, chunk2.escaped());

Explicit Unescaping

The Unescape iterator yields an UnescapedChunk<'a> containing the literal() byte slice and the unescaped() character.

use json_escape::explicit::unescape;

let mut unescaper = unescape(br"hello\tworld");

let chunk1 = unescaper.next().unwrap().unwrap();
assert_eq!(b"hello", chunk1.literal());
assert_eq!(Some('\t'), chunk1.unescaped());

let chunk2 = unescaper.next().unwrap().unwrap();
assert_eq!(b"world", chunk2.literal());
assert_eq!(None, chunk2.unescaped());

Advanced Usage: Zero-Allocation REST API Parsing

A common scenario in web services is receiving a JSON payload where one of the fields is another JSON object, escaped as a string.

{
  "transaction_id": "txn_123",
  "payload": "{\"user_id\": 42, \"items\": [\"apple\", \"orange\"], \"metadata\": {\"source\": \"mobile\"}}"
}

json-escape avoids the standard inefficient practice of allocating a new String for the unescaped payload by plugging its streaming Unescape reader directly into serde_json.

The json-escape Solution: No Intermediate Allocation!

use json_escape::unescape_quoted; // Use the crate root function
use serde::Deserialize;
use serde_json::RawValue;

// The inner payload we want to extract and parse.
#[derive(Deserialize, Debug)]
struct InnerPayload {
    user_id: u64,
    items: Vec<String>,
}

// The outer structure. We use `&RawValue` for a zero-copy view.
#[derive(Deserialize)]
struct OuterPayload<'a> {
    transaction_id: String,
    #[serde(borrow)]
    payload: &'a RawValue,
}

fn main() {
    let response_body = r#"{
        "transaction_id": "txn_123",
        "payload": "{\"user_id\": 42, \"items\": [\"apple\", \"orange\"]}"
    }"#;

    // 1. Parse the outer frame without allocating for `payload`.
    let outer: OuterPayload = serde_json::from_str(response_body).unwrap();

    // 2. Create a streaming reader from the raw, escaped payload.
    // This implements `std::io::Read`.
    // NOTE: unescape_quoted is available in both the root and 'explicit' module.
    let reader = unescape_quoted(outer.payload.get()); 

    // 3. Parse the inner payload directly from the reader.
    // NO intermediate `String` is ever allocated for the unescaped payload!
    let inner: InnerPayload = serde_json::from_reader(reader).unwrap();

    assert_eq!(inner.user_id, 42);
    assert_eq!(inner.items, vec!["apple", "orange"]);
    println!("Successfully parsed inner payload: {:?}", inner);
}

Installation

Add this to your Cargo.toml:

[dependencies]
json-escape = "0.1.2"

Feature Flags

  • alloc (enabled by default): Provides Cow, String, and Vec conversions.
  • std (enabled by default): Provides std::io::Read and std::error::Error implementations.

For no_std environments without an allocator, use:

[dependencies]
json-escape = { version = "*", default-features = false }

License

This project is licensed under either of

at your option.