diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..1275141 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,39 @@ +name: Rust + +on: [push, pull_request] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./rust + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + workspaces: rust + + - name: Check Formatting + run: cargo fmt --check + + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Test + run: cargo test + + - name: Build CLI + run: cargo build --features cli diff --git a/.gitignore b/.gitignore index b42a09b..df9bc86 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ __pycache__ # Go artifacts *.test *.out + +# Bench fixtures +test/.bench-circular.flatted.json diff --git a/README.md b/README.md index 429c152..9bd1ef0 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ Available also for **[Python](./python/flatted.py)**. Available also for **[Go](./golang/README.md)**. +Available also for **[Rust](./rust/README.md)**. + - - - ## ℹ️ JSON only values diff --git a/package.json b/package.json index 14ed1f0..57d06bb 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "test": "c8 node test/index.js", "test:php": "php php/test.php", "test:py": "python python/test.py", + "test:rs": "cargo test --manifest-path rust/Cargo.toml", + "bench:js-rs": "node test/bench-js-vs-rs.mjs", "ts": "tsc -p .", "coverage": "mkdir -p ./coverage; c8 report --reporter=text-lcov > ./coverage/lcov.info" }, @@ -34,6 +36,7 @@ "php/flatted.php", "python/flatted.py", "golang/pkg/flatted/flatted.go", + "rust/src/lib.rs", "types/" ], "keywords": [ diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 0000000..f33ec4f --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1,3 @@ +/target +/Cargo.lock +/flatted diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..7cdbe5c --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "flatted" +version = "0.1.0" +edition = "2021" +rust-version = "1.70" +authors = ["Andrea Giammarchi"] +license = "ISC" +description = "A super light and fast circular JSON parser." +repository = "https://github.com/WebReflection/flatted" +homepage = "https://github.com/WebReflection/flatted" +readme = "README.md" +keywords = ["circular", "json", "parser", "serialize", "flatted"] +categories = ["encoding", "parser-implementations"] +exclude = ["target/"] + +[dependencies] +indexmap = "2" +serde_json = { version = "1", default-features = false, features = ["std", "preserve_order"] } + +[lib] +name = "flatted" +path = "src/lib.rs" + +[[bin]] +name = "flatted" +path = "src/main.rs" +required-features = ["cli"] + +[features] +default = [] +cli = [] diff --git a/rust/Makefile b/rust/Makefile new file mode 100644 index 0000000..c8c2cd5 --- /dev/null +++ b/rust/Makefile @@ -0,0 +1,20 @@ +BINARY_NAME=flatted + +.PHONY: build test lint check clean + +build: + cargo build --release --features cli + cp target/release/$(BINARY_NAME) ./$(BINARY_NAME) + +test: + cargo test + +lint: + cargo fmt --check + cargo clippy --all-targets -- -D warnings + +check: test lint + +clean: + cargo clean + rm -f $(BINARY_NAME) diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 0000000..c3750bf --- /dev/null +++ b/rust/README.md @@ -0,0 +1,83 @@ +# flatted (Rust) + +A super light and fast circular JSON parser. + +## Usage + +```toml +[dependencies] +flatted = { path = "../rust" } # or publish path / git dependency +``` + +```rust +use std::cell::RefCell; +use std::rc::Rc; + +use flatted::{parse_simple, stringify_simple, Object, Value}; + +fn main() { + // const a = [{}]; a[0].a = a; a.push(a); + let a = Value::Array(Rc::new(RefCell::new(vec![Value::empty_object()]))); + let first = a.as_array().unwrap().borrow()[0].clone(); + first + .as_object() + .unwrap() + .borrow_mut() + .insert("a".into(), a.clone()); + a.as_array().unwrap().borrow_mut().push(a.clone()); + + let s = stringify_simple(&a).unwrap(); + assert_eq!(s, r#"[["1","0"],{"a":"0"}]"#); + + let back = parse_simple(&s).unwrap(); + let again = back.as_array().unwrap().borrow()[1].clone(); + let self_ref = again.as_object().unwrap().borrow().get("a").cloned().unwrap(); + assert!(back.ptr_eq(&self_ref)); +} +``` + +Arrays and objects use `Rc>` so circular and shared references keep +their identity after parsing (Rust has no built-in reference-typed JSON value). + +## API + +| Function | Role | +|----------|------| +| `stringify(value, replacer, space)` | Flatten to flatted JSON text | +| `parse(text, reviver)` | Restore a recursive value graph | +| `to_json(value)` | Flatten to a plain `serde_json::Value` array | +| `from_json(value)` | Restore recursion from that array | +| `stringify_simple` / `parse_simple` | Same without replacer/reviver/space | + +## CLI + +```bash +cargo build --release --features cli +echo '{"a":"b"}' | ./target/release/flatted +echo '[{"a":"1"},"b"]' | ./target/release/flatted -d +``` + +## Test + +```bash +cargo test +``` + +## Bench (vs JS) + +Same 1s wall-clock methodology as `test/bench.js`: + +```bash +# from repo root — compares ESM/JS vs Rust release +npm run bench:js-rs + +# or individually +node test/bench-flatted.mjs +cargo run --example bench --release --manifest-path rust/Cargo.toml +``` + +## Note on crates.io + +The name `flatted` is already taken on crates.io by an unofficial port. This +directory is the official WebReflection port in this repository; publish naming +can be decided separately (for example a scoped / renamed crate). diff --git a/rust/examples/bench.rs b/rust/examples/bench.rs new file mode 100644 index 0000000..dcc6bbf --- /dev/null +++ b/rust/examples/bench.rs @@ -0,0 +1,252 @@ +//! Flatted bench mirroring `test/bench.js` timing (1s wall-clock ops/sec). +//! +//! ```bash +//! cargo run --example bench --release +//! cargo run --example bench --release -- --json +//! ``` + +use std::cell::RefCell; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +use flatted::{parse_simple, stringify_simple, Value}; +use serde_json::Value as JsonValue; + +const WINDOW: Duration = Duration::from_secs(1); + +fn repo_test_dir() -> PathBuf { + // examples/ -> rust/ -> repo root -> test/ + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("test") +} + +fn bench_stringify(value: &Value) -> f64 { + let start = Instant::now(); + let mut i = 0u64; + let mut last = String::new(); + while start.elapsed() < WINDOW { + last = stringify_simple(value).expect("stringify"); + i += 1; + } + // keep last result live so the optimizer cannot drop the work + std::hint::black_box(&last); + i as f64 / WINDOW.as_secs_f64() +} + +fn bench_parse(text: &str) -> f64 { + let start = Instant::now(); + let mut i = 0u64; + let mut last = Value::Null; + while start.elapsed() < WINDOW { + last = parse_simple(text).expect("parse"); + i += 1; + } + std::hint::black_box(&last); + i as f64 / WINDOW.as_secs_f64() +} + +fn load_data_json() -> Value { + let path = repo_test_dir().join("data.json"); + let raw = fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + let json: JsonValue = serde_json::from_str(&raw).expect("data.json"); + Value::from_json(&json) +} + +fn slice_array(value: &Value, len: usize) -> Value { + let items: Vec = value + .as_array() + .expect("array") + .borrow() + .iter() + .take(len) + .cloned() + .collect(); + Value::Array(Rc::new(RefCell::new(items))) +} + +fn concat_shared(parts: &[&Value]) -> Value { + let mut out = Vec::new(); + for part in parts { + out.extend(part.as_array().expect("array").borrow().iter().cloned()); + } + Value::Array(Rc::new(RefCell::new(out))) +} + +fn make_circular_object() -> Value { + let o = Value::empty_object(); + o.as_object() + .unwrap() + .borrow_mut() + .insert("b".into(), o.clone()); + o +} + +fn circular_list(n: usize) -> Value { + let items: Vec = (0..n).map(|_| make_circular_object()).collect(); + Value::Array(Rc::new(RefCell::new(items))) +} + +struct ResultRow { + case: &'static str, + op: &'static str, + label: String, + ops: f64, +} + +fn emit(row: &ResultRow, json: bool) { + if json { + println!( + r#"{{"engine":"rust","case":"{}","op":"{}","label":"{}","ops":{:.2}}}"#, + row.case, + row.op, + row.label.replace('"', "\\\""), + row.ops + ); + } else { + println!( + "rust / flatted {} {} parsed {:.2} times per second", + row.op, row.label, row.ops + ); + } +} + +fn pair(case: &'static str, label: String, value: &Value) -> (ResultRow, ResultRow) { + let stringify_ops = bench_stringify(value); + let text = stringify_simple(value).unwrap(); + let parse_ops = bench_parse(&text); + ( + ResultRow { + case, + op: "stringify", + label: label.clone(), + ops: stringify_ops, + }, + ResultRow { + case, + op: "parse", + label, + ops: parse_ops, + }, + ) +} + +fn main() { + let json = env::args().any(|a| a == "--json"); + let data = load_data_json(); + let keys = data.as_array().unwrap().borrow()[0] + .as_object() + .unwrap() + .borrow() + .len(); + + if !json { + println!("-----------------------------------"); + println!("Object with {keys} keys each"); + println!("-----------------------------------"); + } + + let dummy100 = data.clone(); + let dummy50 = slice_array(&data, 50); + let dummy10 = slice_array(&data, 10); + + for (case, label, value) in [ + ("objects_100", "100 objects".to_string(), &dummy100), + ("objects_50", "50 objects".to_string(), &dummy50), + ("objects_10", "10 objects".to_string(), &dummy10), + ] { + let (s, p) = pair(case, label, value); + emit(&s, json); + emit(&p, json); + } + + if !json { + println!("-----------------------------------"); + println!("50% same objects"); + println!("-----------------------------------"); + } + let shared50 = concat_shared(&[&dummy50, &dummy50]); + let (s, p) = pair("shared_50", "100 objects".into(), &shared50); + emit(&s, json); + emit(&p, json); + + if !json { + println!("-----------------------------------"); + println!("90% same objects"); + println!("-----------------------------------"); + } + let shared90 = concat_shared(&[ + &dummy10, &dummy10, &dummy10, &dummy10, &dummy10, &dummy10, &dummy10, &dummy10, &dummy10, + &dummy10, + ]); + let (s, p) = pair("shared_90", "100 objects".into(), &shared90); + emit(&s, json); + emit(&p, json); + + if !json { + println!("-----------------------------------"); + println!("with circular"); + println!("-----------------------------------"); + } + let circ = circular_list(100); + let (s, p) = pair("circular_100", "100 objects".into(), &circ); + emit(&s, json); + emit(&p, json); + + if !json { + println!("-----------------------------------"); + println!("with circular 90% same"); + println!("-----------------------------------"); + } + let circ10 = circular_list(10); + let circ90 = concat_shared(&[ + &circ10, &circ10, &circ10, &circ10, &circ10, &circ10, &circ10, &circ10, &circ10, &circ10, + ]); + let (s, p) = pair("circular_shared_90", "100 objects".into(), &circ90); + emit(&s, json); + emit(&p, json); + + // Big real-world: prefer preconverted flatted payload (from compare harness). + let flatted_path = repo_test_dir().join(".bench-circular.flatted.json"); + if flatted_path.is_file() { + if !json { + println!("-----------------------------------"); + println!("Big real-world circular data"); + println!("-----------------------------------"); + } + let text = fs::read_to_string(&flatted_path).expect("circular flatted"); + let value = parse_simple(&text).expect("parse circular flatted"); + let label = format!("{} chars", text.len()); + let stringify_ops = bench_stringify(&value); + let parse_ops = bench_parse(&text); + emit( + &ResultRow { + case: "big_circular", + op: "stringify", + label: label.clone(), + ops: stringify_ops, + }, + json, + ); + emit( + &ResultRow { + case: "big_circular", + op: "parse", + label, + ops: parse_ops, + }, + json, + ); + } else if !json { + println!("-----------------------------------"); + println!( + "Big real-world circular data (skipped: missing {})", + flatted_path.display() + ); + println!("Run: node test/bench-js-vs-rs.mjs # prepares the fixture"); + println!("-----------------------------------"); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 0000000..57f016e --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,355 @@ +//! A super light and fast circular JSON parser. +//! +//! Port of [flatted](https://github.com/WebReflection/flatted) for Rust. +//! +//! The wire format is a JSON array where index `0` is the root. Objects, arrays, +//! and strings are stored once and replaced elsewhere by their index as a +//! decimal string. Only round-trip through `parse(stringify(value))` — do not +//! mix with plain `serde_json` encode/decode of the same payload. +//! +//! Arrays and objects use `Rc>` so circular and shared references +//! keep their identity after parsing. + +mod value; + +use std::collections::HashMap; + +use serde_json::{Map as JsonMap, Value as JsonValue}; + +pub use value::{Array, Object, ObjectRef, Value}; + +/// Error returned by flatted operations. +#[derive(Debug)] +pub enum Error { + /// Invalid JSON / flatted text. + Json(serde_json::Error), + /// Flatted payload is not a top-level array. + ExpectedArray, + /// String index did not point at a valid slot. + InvalidIndex(String), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Json(e) => write!(f, "{e}"), + Error::ExpectedArray => write!(f, "flatted input must be a JSON array"), + Error::InvalidIndex(s) => write!(f, "invalid flatted index: {s}"), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Error::Json(e) => Some(e), + _ => None, + } + } +} + +impl From for Error { + fn from(value: serde_json::Error) -> Self { + Error::Json(value) + } +} + +/// Optional key whitelist passed to [`stringify`], mirroring `JSON.stringify`. +pub type Replacer<'a> = Option<&'a [&'a str]>; + +/// Converts a value into a specialized flatted JSON string. +/// +/// `replacer`, when set, is a key whitelist (like `JSON.stringify`'s array form). +/// `space`, when set, pretty-prints each node with that indent string (JS style). +pub fn stringify( + value: &Value, + replacer: Replacer<'_>, + space: Option<&str>, +) -> Result { + let mut known_objects: HashMap<*const (), String> = HashMap::new(); + let mut known_strings: HashMap = HashMap::new(); + let mut input: Vec = Vec::new(); + + push_known( + value.clone(), + &mut known_objects, + &mut known_strings, + &mut input, + ); + + let mut output: Vec = Vec::with_capacity(input.len()); + let mut i = 0; + while i < input.len() { + let current = input[i].clone(); + let transformed = transform( + ¤t, + replacer, + &mut known_objects, + &mut known_strings, + &mut input, + ); + output.push(transformed); + i += 1; + } + + if let Some(indent) = space { + Ok(join_pretty(&output, indent)?) + } else { + Ok(serde_json::to_string(&JsonValue::Array(output))?) + } +} + +fn push_known( + v: Value, + known_objects: &mut HashMap<*const (), String>, + known_strings: &mut HashMap, + input: &mut Vec, +) -> String { + let idx = input.len().to_string(); + if let Some(ptr) = v.identity_ptr() { + known_objects.insert(ptr, idx.clone()); + } else if let Value::String(ref s) = v { + known_strings.insert(s.clone(), idx.clone()); + } + input.push(v); + idx +} + +fn relate( + v: &Value, + known_objects: &mut HashMap<*const (), String>, + known_strings: &mut HashMap, + input: &mut Vec, +) -> JsonValue { + match v { + Value::Null => JsonValue::Null, + Value::Bool(b) => JsonValue::Bool(*b), + Value::Number(n) => JsonValue::Number(n.clone()), + Value::String(s) => { + if let Some(idx) = known_strings.get(s) { + return JsonValue::String(idx.clone()); + } + JsonValue::String(push_known( + Value::String(s.clone()), + known_objects, + known_strings, + input, + )) + } + Value::Array(_) | Value::Object(_) => { + let ptr = v.identity_ptr().expect("container has identity"); + if let Some(idx) = known_objects.get(&ptr) { + return JsonValue::String(idx.clone()); + } + JsonValue::String(push_known(v.clone(), known_objects, known_strings, input)) + } + } +} + +fn transform( + v: &Value, + replacer: Replacer<'_>, + known_objects: &mut HashMap<*const (), String>, + known_strings: &mut HashMap, + input: &mut Vec, +) -> JsonValue { + match v { + Value::Array(arr) => { + let items: Vec = arr + .borrow() + .iter() + .map(|item| relate(item, known_objects, known_strings, input)) + .collect(); + JsonValue::Array(items) + } + Value::Object(obj) => { + let mut map = JsonMap::new(); + for (key, item) in obj.borrow().iter() { + if let Some(whitelist) = replacer { + if !whitelist.iter().any(|k| *k == key) { + continue; + } + } + map.insert( + key.clone(), + relate(item, known_objects, known_strings, input), + ); + } + JsonValue::Object(map) + } + Value::Null => JsonValue::Null, + Value::Bool(b) => JsonValue::Bool(*b), + Value::Number(n) => JsonValue::Number(n.clone()), + Value::String(s) => JsonValue::String(s.clone()), + } +} + +/// Match JS: `'[' + output.map(v => JSON.stringify(v, null, space)).join(',') + ']'`. +fn join_pretty(items: &[JsonValue], indent: &str) -> Result { + let mut parts = Vec::with_capacity(items.len()); + for item in items { + let pretty = pretty_with_indent(item, indent)?; + parts.push(pretty); + } + let mut out = String::from('['); + for (idx, part) in parts.iter().enumerate() { + if idx > 0 { + out.push(','); + } + out.push_str(part); + } + out.push(']'); + Ok(out) +} + +fn pretty_with_indent(value: &JsonValue, indent: &str) -> Result { + // serde_json always pretty-prints with two spaces; remap when needed. + let two_space = serde_json::to_string_pretty(value)?; + if indent == " " { + Ok(two_space) + } else { + Ok(remap_indent(&two_space, " ", indent)) + } +} + +fn remap_indent(text: &str, from: &str, to: &str) -> String { + text.lines() + .map(|line| { + let mut depth = 0; + let mut rest = line; + while rest.starts_with(from) { + depth += 1; + rest = &rest[from.len()..]; + } + format!("{}{}", to.repeat(depth), rest) + }) + .collect::>() + .join("\n") +} + +/// Converts a specialized flatted string into a value graph. +/// +/// `reviver`, when set, is called as `reviver(key, value)` for each property, +/// including the root with key `""`, matching `JSON.parse`. +pub fn parse(text: &str, reviver: Option) -> Result +where + F: FnMut(String, Value) -> Value, +{ + let json: JsonValue = serde_json::from_str(text)?; + let JsonValue::Array(flat) = json else { + return Err(Error::ExpectedArray); + }; + + // Phase 1: allocate shells so circular links can share identity. + let input: Vec = flat + .iter() + .map(|v| match v { + JsonValue::Array(_) => Value::empty_array(), + JsonValue::Object(_) => Value::empty_object(), + JsonValue::Null => Value::Null, + JsonValue::Bool(b) => Value::Bool(*b), + JsonValue::Number(n) => Value::Number(n.clone()), + JsonValue::String(s) => Value::String(s.clone()), + }) + .collect(); + + // Phase 2: fill arrays and objects; nested strings are indexes into `input`. + for (i, v) in flat.iter().enumerate() { + match v { + JsonValue::Array(items) => { + let Value::Array(target) = &input[i] else { + unreachable!("shell type mismatch"); + }; + let mut slot = target.borrow_mut(); + slot.reserve(items.len()); + for item in items { + slot.push(resolve_ref(item, &input)?); + } + } + JsonValue::Object(map) => { + let Value::Object(target) = &input[i] else { + unreachable!("shell type mismatch"); + }; + let mut slot = target.borrow_mut(); + for (key, item) in map { + slot.insert(key.clone(), resolve_ref(item, &input)?); + } + } + _ => {} + } + } + + let root = input.into_iter().next().unwrap_or(Value::Null); + + if let Some(mut revive) = reviver { + Ok(apply_reviver(&mut revive, String::new(), root)) + } else { + Ok(root) + } +} + +fn resolve_ref(item: &JsonValue, input: &[Value]) -> Result { + match item { + JsonValue::String(s) => { + let idx: usize = s.parse().map_err(|_| Error::InvalidIndex(s.clone()))?; + input + .get(idx) + .cloned() + .ok_or_else(|| Error::InvalidIndex(s.clone())) + } + JsonValue::Null => Ok(Value::Null), + JsonValue::Bool(b) => Ok(Value::Bool(*b)), + JsonValue::Number(n) => Ok(Value::Number(n.clone())), + JsonValue::Array(_) | JsonValue::Object(_) => { + Err(Error::InvalidIndex("nested container without index".into())) + } + } +} + +fn apply_reviver(revive: &mut F, key: String, value: Value) -> Value +where + F: FnMut(String, Value) -> Value, +{ + match &value { + Value::Array(arr) => { + let len = arr.borrow().len(); + for i in 0..len { + let child = arr.borrow()[i].clone(); + let revived = apply_reviver(revive, i.to_string(), child); + arr.borrow_mut()[i] = revived; + } + } + Value::Object(obj) => { + let keys: Vec = obj.borrow().keys().cloned().collect(); + for k in keys { + let child = obj.borrow().get(&k).cloned().unwrap(); + let revived = apply_reviver(revive, k.clone(), child); + obj.borrow_mut().insert(k, revived); + } + } + _ => {} + } + revive(key, value) +} + +/// Converts a value into a JSON-serializable flatted array without losing recursion. +pub fn to_json(value: &Value) -> Result { + let text = stringify(value, None, None)?; + Ok(serde_json::from_str(&text)?) +} + +/// Converts a previously flattened JSON array back into a recursive value. +pub fn from_json(value: &JsonValue) -> Result { + let text = serde_json::to_string(value)?; + parse_simple(&text) +} + +/// Convenience: stringify with no replacer or space. +pub fn stringify_simple(value: &Value) -> Result { + stringify(value, None, None) +} + +/// Convenience: parse with no reviver. +pub fn parse_simple(text: &str) -> Result { + parse(text, None:: Value>) +} diff --git a/rust/src/main.rs b/rust/src/main.rs new file mode 100644 index 0000000..f122210 --- /dev/null +++ b/rust/src/main.rs @@ -0,0 +1,104 @@ +//! CLI for flattening / unflattening circular JSON (optional `cli` feature). + +use std::env; +use std::fs; +use std::io::{self, Read, Write}; +use std::process; + +use flatted::{parse_simple, stringify_simple, Value}; +use serde_json::Value as JsonValue; + +fn usage(exe: &str) { + eprintln!("Usage: {exe} [OPTION]... [FILE]"); + eprintln!("Flatten or unflatten circular JSON structures."); + eprintln!(); + eprintln!("Options:"); + eprintln!(" -d, --decompress, --unflatten unflatten flatted JSON to plain JSON"); + eprintln!(); + eprintln!("If no FILE is provided, or if FILE is -, read from standard input."); +} + +fn read_input(path: Option<&str>) -> Result> { + match path { + None | Some("-") => { + let mut buf = String::new(); + io::stdin().read_to_string(&mut buf)?; + Ok(buf) + } + Some(p) => Ok(fs::read_to_string(p)?), + } +} + +fn flatten(input: &str) -> Result> { + let data: JsonValue = serde_json::from_str(input)?; + let value = Value::from_json(&data); + Ok(stringify_simple(&value)?) +} + +fn unflatten(input: &str) -> Result> { + let parsed = parse_simple(input.trim())?; + // Lossy for cycles: emit structure with cycles broken to null for display. + let json = parsed.to_serde_json(); + Ok(serde_json::to_string_pretty(&json)?) +} + +fn main() { + let exe = env::args().next().unwrap_or_else(|| "flatted".into()); + let args: Vec = env::args().skip(1).collect(); + + let mut decompress = false; + let mut file: Option<&str> = None; + + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "-h" | "--help" => { + usage(&exe); + process::exit(0); + } + "-d" | "--decompress" | "--unflatten" => decompress = true, + "--" => { + i += 1; + if i < args.len() { + file = Some(args[i].as_str()); + } + break; + } + s if s.starts_with('-') => { + eprintln!("{exe}: unknown option {s}"); + usage(&exe); + process::exit(1); + } + s => { + file = Some(s); + break; + } + } + i += 1; + } + + let input = match read_input(file) { + Ok(s) => s, + Err(e) => { + eprintln!("{exe}: {e}"); + process::exit(1); + } + }; + + let result = if decompress { + unflatten(&input) + } else { + flatten(&input) + }; + + match result { + Ok(out) => { + let mut stdout = io::stdout().lock(); + let _ = writeln!(stdout, "{out}"); + } + Err(e) => { + eprintln!("{exe}: {e}"); + process::exit(1); + } + } +} diff --git a/rust/src/value.rs b/rust/src/value.rs new file mode 100644 index 0000000..d63f472 --- /dev/null +++ b/rust/src/value.rs @@ -0,0 +1,231 @@ +use std::cell::RefCell; +use std::collections::HashSet; +use std::fmt; +use std::rc::Rc; + +use indexmap::IndexMap; +use serde_json::{Map as JsonMap, Number, Value as JsonValue}; + +/// Ordered object map (insertion order, matching JS). +pub type Object = IndexMap; + +/// Shared mutable array handle (enables circular / shared references). +pub type Array = Rc>>; + +/// Shared mutable object handle (enables circular / shared references). +pub type ObjectRef = Rc>; + +/// JSON-compatible value with shared ownership for arrays and objects. +/// +/// Unlike `serde_json::Value`, arrays and objects sit behind `Rc>`, +/// so a graph can point back into itself or share a node across positions. +#[derive(Clone)] +pub enum Value { + Null, + Bool(bool), + Number(Number), + String(String), + Array(Array), + Object(ObjectRef), +} + +impl fmt::Debug for Value { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Value::Null => write!(f, "Null"), + Value::Bool(b) => f.debug_tuple("Bool").field(b).finish(), + Value::Number(n) => f.debug_tuple("Number").field(n).finish(), + Value::String(s) => f.debug_tuple("String").field(s).finish(), + Value::Array(a) => write!(f, "Array(len={})", a.borrow().len()), + Value::Object(o) => write!(f, "Object(len={})", o.borrow().len()), + } + } +} + +impl Value { + /// Returns `true` when both values refer to the same array or object allocation. + pub fn ptr_eq(&self, other: &Value) -> bool { + match (self, other) { + (Value::Array(a), Value::Array(b)) => Rc::ptr_eq(a, b), + (Value::Object(a), Value::Object(b)) => Rc::ptr_eq(a, b), + _ => false, + } + } + + /// Returns a reference to the shared array, if this value is an array. + pub fn as_array(&self) -> Option<&Array> { + match self { + Value::Array(a) => Some(a), + _ => None, + } + } + + /// Returns a reference to the shared object, if this value is an object. + pub fn as_object(&self) -> Option<&ObjectRef> { + match self { + Value::Object(o) => Some(o), + _ => None, + } + } + + /// Pointer identity for known-map deduplication during stringify. + pub(crate) fn identity_ptr(&self) -> Option<*const ()> { + match self { + Value::Array(a) => Some(Rc::as_ptr(a) as *const ()), + Value::Object(o) => Some(Rc::as_ptr(o) as *const ()), + _ => None, + } + } + + /// Creates an empty shared array. + pub fn empty_array() -> Self { + Value::Array(Rc::new(RefCell::new(Vec::new()))) + } + + /// Creates an empty shared object. + pub fn empty_object() -> Self { + Value::Object(Rc::new(RefCell::new(Object::new()))) + } + + /// Converts a non-circular `serde_json::Value` tree into a flatted `Value`. + pub fn from_json(value: &JsonValue) -> Self { + match value { + JsonValue::Null => Value::Null, + JsonValue::Bool(b) => Value::Bool(*b), + JsonValue::Number(n) => Value::Number(n.clone()), + JsonValue::String(s) => Value::String(s.clone()), + JsonValue::Array(items) => { + let arr = items.iter().map(Value::from_json).collect(); + Value::Array(Rc::new(RefCell::new(arr))) + } + JsonValue::Object(map) => { + let mut obj = Object::new(); + for (k, v) in map { + obj.insert(k.clone(), Value::from_json(v)); + } + Value::Object(Rc::new(RefCell::new(obj))) + } + } + } + + /// Converts into a `serde_json::Value` tree. + /// + /// Shared references are cloned as distinct subtrees. Circular references + /// are replaced with `Null` after the first visit (lossy). Prefer + /// [`crate::to_json`] when recursion must be preserved. + pub fn to_serde_json(&self) -> JsonValue { + let mut visiting = HashSet::new(); + self.to_serde_json_inner(&mut visiting) + } + + fn to_serde_json_inner(&self, visiting: &mut HashSet<*const ()>) -> JsonValue { + match self { + Value::Null => JsonValue::Null, + Value::Bool(b) => JsonValue::Bool(*b), + Value::Number(n) => JsonValue::Number(n.clone()), + Value::String(s) => JsonValue::String(s.clone()), + Value::Array(a) => { + let ptr = Rc::as_ptr(a) as *const (); + if !visiting.insert(ptr) { + return JsonValue::Null; + } + let items: Vec = a + .borrow() + .iter() + .map(|v| v.to_serde_json_inner(visiting)) + .collect(); + visiting.remove(&ptr); + JsonValue::Array(items) + } + Value::Object(o) => { + let ptr = Rc::as_ptr(o) as *const (); + if !visiting.insert(ptr) { + return JsonValue::Null; + } + let mut map = JsonMap::new(); + for (k, v) in o.borrow().iter() { + map.insert(k.clone(), v.to_serde_json_inner(visiting)); + } + visiting.remove(&ptr); + JsonValue::Object(map) + } + } + } +} + +impl PartialEq for Value { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Value::Null, Value::Null) => true, + (Value::Bool(a), Value::Bool(b)) => a == b, + (Value::Number(a), Value::Number(b)) => a == b, + (Value::String(a), Value::String(b)) => a == b, + // Containers compare by identity; use stringify for structural checks. + (Value::Array(a), Value::Array(b)) => Rc::ptr_eq(a, b), + (Value::Object(a), Value::Object(b)) => Rc::ptr_eq(a, b), + _ => false, + } + } +} + +impl From<()> for Value { + fn from(_: ()) -> Self { + Value::Null + } +} + +impl From for Value { + fn from(b: bool) -> Self { + Value::Bool(b) + } +} + +impl From for Value { + fn from(n: i64) -> Self { + Value::Number(n.into()) + } +} + +impl From for Value { + fn from(n: u64) -> Self { + Value::Number(n.into()) + } +} + +impl From for Value { + fn from(n: f64) -> Self { + Number::from_f64(n) + .map(Value::Number) + .unwrap_or(Value::Null) + } +} + +impl From for Value { + fn from(s: String) -> Self { + Value::String(s) + } +} + +impl From<&str> for Value { + fn from(s: &str) -> Self { + Value::String(s.to_owned()) + } +} + +impl From> for Value { + fn from(items: Vec) -> Self { + Value::Array(Rc::new(RefCell::new(items))) + } +} + +impl From for Value { + fn from(map: Object) -> Self { + Value::Object(Rc::new(RefCell::new(map))) + } +} + +impl From for Value { + fn from(value: JsonValue) -> Self { + Value::from_json(&value) + } +} diff --git a/rust/tests/parity.rs b/rust/tests/parity.rs new file mode 100644 index 0000000..5ddc318 --- /dev/null +++ b/rust/tests/parity.rs @@ -0,0 +1,465 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use flatted::{ + from_json, parse, parse_simple, stringify, stringify_simple, to_json, Object, Value, +}; +use serde_json::json; + +fn obj(entries: &[(&str, Value)]) -> Value { + let mut map = Object::new(); + for (k, v) in entries { + map.insert((*k).to_owned(), v.clone()); + } + Value::Object(Rc::new(RefCell::new(map))) +} + +fn arr(items: Vec) -> Value { + Value::Array(Rc::new(RefCell::new(items))) +} + +#[test] +fn multiple_nulls() { + let a = arr(vec![Value::Null, Value::Null]); + assert_eq!(stringify_simple(&a).unwrap(), "[[null,null]]"); +} + +#[test] +fn empty_collections() { + assert_eq!(stringify_simple(&arr(vec![])).unwrap(), "[[]]"); + assert_eq!(stringify_simple(&obj(&[])).unwrap(), "[{}]"); +} + +#[test] +fn recursive_array() { + let a = arr(vec![]); + a.as_array().unwrap().borrow_mut().push(a.clone()); + assert_eq!(stringify_simple(&a).unwrap(), r#"[["0"]]"#); + + let back = parse_simple(r#"[["0"]]"#).unwrap(); + let first = back.as_array().unwrap().borrow()[0].clone(); + assert!(back.ptr_eq(&first)); +} + +#[test] +fn recursive_object() { + let o = obj(&[]); + o.as_object() + .unwrap() + .borrow_mut() + .insert("o".into(), o.clone()); + assert_eq!(stringify_simple(&o).unwrap(), r#"[{"o":"0"}]"#); + + let back = parse_simple(r#"[{"o":"0"}]"#).unwrap(); + let inner = back + .as_object() + .unwrap() + .borrow() + .get("o") + .cloned() + .unwrap(); + assert!(back.ptr_eq(&inner)); +} + +#[test] +fn values_in_array_and_object() { + let a = arr(vec![]); + { + let mut slot = a.as_array().unwrap().borrow_mut(); + slot.push(a.clone()); + slot.push(Value::from(1_i64)); + slot.push(Value::from("two")); + slot.push(Value::from(true)); + } + assert_eq!(stringify_simple(&a).unwrap(), r#"[["0",1,"1",true],"two"]"#); + + let o = obj(&[]); + { + let mut slot = o.as_object().unwrap().borrow_mut(); + slot.insert("o".into(), o.clone()); + slot.insert("one".into(), Value::from(1_i64)); + slot.insert("two".into(), Value::from("two")); + slot.insert("three".into(), Value::from(true)); + } + assert_eq!( + stringify_simple(&o).unwrap(), + r#"[{"o":"0","one":1,"two":"1","three":true},"two"]"# + ); +} + +#[test] +fn object_in_array_and_array_in_object() { + let a = arr(vec![]); + let o = obj(&[]); + + { + let mut slot = a.as_array().unwrap().borrow_mut(); + slot.push(a.clone()); + slot.push(Value::from(1_i64)); + slot.push(Value::from("two")); + slot.push(Value::from(true)); + slot.push(o.clone()); + } + { + let mut slot = o.as_object().unwrap().borrow_mut(); + slot.insert("o".into(), o.clone()); + slot.insert("one".into(), Value::from(1_i64)); + slot.insert("two".into(), Value::from("two")); + slot.insert("three".into(), Value::from(true)); + slot.insert("a".into(), a.clone()); + } + + assert_eq!( + stringify_simple(&a).unwrap(), + r#"[["0",1,"1",true,"2"],"two",{"o":"2","one":1,"two":"1","three":true,"a":"0"}]"# + ); + assert_eq!( + stringify_simple(&o).unwrap(), + r#"[{"o":"0","one":1,"two":"1","three":true,"a":"2"},"two",["2",1,"1",true,"0"]]"# + ); +} + +#[test] +fn objects_in_array_js_identity() { + // JS keeps equal-but-distinct objects separate (unlike Python equality dedup). + let a = arr(vec![]); + let o = obj(&[]); + + { + let mut slot = a.as_array().unwrap().borrow_mut(); + slot.extend([ + a.clone(), + Value::from(1_i64), + Value::from("two"), + Value::from(true), + o.clone(), + ]); + } + { + let mut slot = o.as_object().unwrap().borrow_mut(); + slot.insert("o".into(), o.clone()); + slot.insert("one".into(), Value::from(1_i64)); + slot.insert("two".into(), Value::from("two")); + slot.insert("three".into(), Value::from(true)); + slot.insert("a".into(), a.clone()); + } + + let test_a = obj(&[("test", Value::from("OK"))]); + let array_a = arr(vec![ + Value::from(1_i64), + Value::from(2_i64), + Value::from(3_i64), + ]); + let test_o = obj(&[("test", Value::from("OK"))]); + let array_o = arr(vec![ + Value::from(1_i64), + Value::from(2_i64), + Value::from(3_i64), + ]); + + a.as_array().unwrap().borrow_mut().push(test_a); + a.as_array().unwrap().borrow_mut().push(array_a); + o.as_object() + .unwrap() + .borrow_mut() + .insert("test".into(), test_o); + o.as_object() + .unwrap() + .borrow_mut() + .insert("array".into(), array_o); + + assert_eq!( + stringify_simple(&a).unwrap(), + r#"[["0",1,"1",true,"2","3","4"],"two",{"o":"2","one":1,"two":"1","three":true,"a":"0","test":"5","array":"6"},{"test":"7"},[1,2,3],{"test":"7"},[1,2,3],"OK"]"# + ); + assert_eq!( + stringify_simple(&o).unwrap(), + r#"[{"o":"0","one":1,"two":"1","three":true,"a":"2","test":"3","array":"4"},"two",["2",1,"1",true,"0","5","6"],{"test":"7"},[1,2,3],{"test":"7"},[1,2,3],"OK"]"# + ); +} + +#[test] +fn roundtrip_primitives() { + for value in [ + Value::from(1_i64), + Value::from(false), + Value::Null, + Value::from("test"), + ] { + let text = stringify_simple(&value).unwrap(); + let back = parse_simple(&text).unwrap(); + assert_eq!(back, value); + } +} + +#[test] +fn readme_circular_example() { + // const a = [{}]; a[0].a = a; a.push(a); + // stringify(a) => [["1","0"],{"a":"0"}] + let a = arr(vec![obj(&[])]); + let first = a.as_array().unwrap().borrow()[0].clone(); + first + .as_object() + .unwrap() + .borrow_mut() + .insert("a".into(), a.clone()); + a.as_array().unwrap().borrow_mut().push(a.clone()); + assert_eq!(stringify_simple(&a).unwrap(), r#"[["1","0"],{"a":"0"}]"#); +} + +#[test] +fn special_strings() { + let special = r"\x7e"; + let o = obj(&[("a", Value::from(special))]); + let back = parse_simple(&stringify_simple(&o).unwrap()).unwrap(); + assert_eq!( + back.as_object().unwrap().borrow().get("a"), + Some(&Value::from(special)) + ); +} + +#[test] +fn replacer_whitelist() { + let o = obj(&[("a", Value::from(1_i64)), ("b", Value::from(2_i64))]); + let s = stringify(&o, Some(&["b"]), None).unwrap(); + assert_eq!(s, r#"[{"b":2}]"#); +} + +#[test] +fn indentation_js_style() { + let o = obj(&[("a", Value::from(1_i64))]); + // JS: '[' + JSON.stringify({a:1}, null, 2) + ']' => '[{\n "a": 1\n}]' + assert_eq!( + stringify(&o, None, Some(" ")).unwrap(), + "[{\n \"a\": 1\n}]" + ); +} + +#[test] +fn to_from_json() { + let a = arr(vec![obj(&[])]); + let first = a.as_array().unwrap().borrow()[0].clone(); + first + .as_object() + .unwrap() + .borrow_mut() + .insert("a".into(), a.clone()); + + let flat = to_json(&a).unwrap(); + let back = from_json(&flat).unwrap(); + let m = back.as_array().unwrap().borrow()[0].clone(); + let self_ref = m.as_object().unwrap().borrow().get("a").cloned().unwrap(); + assert!(back.ptr_eq(&self_ref)); +} + +#[test] +fn complex_shared_structure() { + let unique = obj(&[("a", Value::from("sup"))]); + let nested = obj(&[ + ("prop", obj(&[("value", Value::from(123_i64))])), + ( + "a", + arr(vec![ + obj(&[]), + obj(&[( + "b", + arr(vec![obj(&[ + ("a", Value::from(1_i64)), + ("d", Value::from(2_i64)), + ("c", unique.clone()), + ( + "z", + obj(&[ + ("g", Value::from(2_i64)), + ("a", unique.clone()), + ( + "b", + obj(&[ + ("r", Value::from(4_i64)), + ("u", unique.clone()), + ("c", Value::from(5_i64)), + ]), + ), + ("f", Value::from(6_i64)), + ]), + ), + ("h", Value::from(1_i64)), + ])]), + )]), + ]), + ), + ( + "b", + obj(&[ + ("e", Value::from("f")), + ("t", unique.clone()), + ("p", Value::from(4_i64)), + ]), + ), + ]); + + let text = stringify_simple(&nested).unwrap(); + let p = parse_simple(&text).unwrap(); + + let res_b_t = p + .as_object() + .unwrap() + .borrow() + .get("b") + .unwrap() + .as_object() + .unwrap() + .borrow() + .get("t") + .cloned() + .unwrap(); + + let res_a_c = { + let a = p.as_object().unwrap().borrow().get("a").cloned().unwrap(); + let second = a.as_array().unwrap().borrow()[1].clone(); + let b = second + .as_object() + .unwrap() + .borrow() + .get("b") + .cloned() + .unwrap(); + let first = b.as_array().unwrap().borrow()[0].clone(); + let c = first + .as_object() + .unwrap() + .borrow() + .get("c") + .cloned() + .unwrap(); + c + }; + + assert!(res_b_t.ptr_eq(&res_a_c)); +} + +#[test] +fn empty_keys_circular() { + let inner = obj(&[("d", Value::from(1_i64))]); + let empty_key_map = obj(&[("c", inner)]); + let b_map = obj(&[("", empty_key_map.clone())]); + let a = obj(&[("b", b_map), ("_circular", empty_key_map)]); + + let back = parse_simple(&stringify_simple(&a).unwrap()).unwrap(); + let circular = back + .as_object() + .unwrap() + .borrow() + .get("_circular") + .cloned() + .unwrap(); + let via_empty = back + .as_object() + .unwrap() + .borrow() + .get("b") + .unwrap() + .as_object() + .unwrap() + .borrow() + .get("") + .cloned() + .unwrap(); + assert!(circular.ptr_eq(&via_empty)); +} + +#[test] +fn deep_chain() { + let amount = 1500; + let mut chain = arr(vec![Value::from("leaf")]); + for _ in 0..amount { + chain = arr(vec![chain]); + } + let text = stringify_simple(&chain).unwrap(); + let mut parsed = parse_simple(&text).unwrap(); + for _ in 0..amount { + let next = parsed.as_array().unwrap().borrow()[0].clone(); + parsed = next; + } + // Innermost value is the original `["leaf"]` array. + assert_eq!(parsed.as_array().unwrap().borrow()[0], Value::from("leaf")); +} + +#[test] +fn specs_examples() { + assert_eq!(stringify_simple(&Value::from("a")).unwrap(), r#"["a"]"#); + assert_eq!( + stringify_simple(&arr(vec![Value::from("a")])).unwrap(), + r#"[["1"],"a"]"# + ); + assert_eq!( + stringify_simple(&arr(vec![ + Value::from("a"), + Value::from(1_i64), + Value::from("b") + ])) + .unwrap(), + r#"[["1",1,"2"],"a","b"]"# + ); + assert_eq!( + stringify_simple(&obj(&[("a", Value::from("a"))])).unwrap(), + r#"[{"a":"1"},"a"]"# + ); + assert_eq!( + stringify_simple(&obj(&[ + ("a", Value::from("a")), + ("n", Value::from(1_i64)), + ("b", Value::from("b")), + ])) + .unwrap(), + r#"[{"a":"1","n":1,"b":"2"},"a","b"]"# + ); +} + +#[test] +fn large_fixtures() { + for name in ["65515.json", "65518.json"] { + let path = format!("../test/{name}"); + let Ok(data) = std::fs::read_to_string(&path) else { + continue; + }; + let raw: serde_json::Value = serde_json::from_str(&data).unwrap(); + let tool_data = raw.get("toolData").expect("toolData"); + let text = serde_json::to_string(tool_data).unwrap(); + let parsed = parse_simple(&text).unwrap(); + assert!(!matches!(parsed, Value::Null)); + } +} + +#[test] +fn reviver_transforms_root() { + let text = stringify_simple(&Value::from(1_i64)).unwrap(); + let back = parse( + &text, + Some(|_k, v| match v { + Value::Number(n) if n.as_i64() == Some(1) => Value::from(42_i64), + other => other, + }), + ) + .unwrap(); + assert_eq!(back, Value::from(42_i64)); +} + +#[test] +fn from_json_value_conversion() { + let v = Value::from(json!({"a": [1, true, null]})); + let text = stringify_simple(&v).unwrap(); + let back = parse_simple(&text).unwrap(); + assert_eq!( + back.as_object() + .unwrap() + .borrow() + .get("a") + .unwrap() + .as_array() + .unwrap() + .borrow() + .len(), + 3 + ); +} diff --git a/test/bench-flatted.mjs b/test/bench-flatted.mjs new file mode 100644 index 0000000..cd4e06a --- /dev/null +++ b/test/bench-flatted.mjs @@ -0,0 +1,132 @@ +// Flatted-only bench mirroring test/bench.js timing (1s wall-clock ops/sec). +// Used by test/bench-js-vs-rs.mjs for JS vs Rust comparison. +// +// node test/bench-flatted.mjs +// node test/bench-flatted.mjs --json + +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { parse, stringify } from '../esm/index.js'; + +const require = createRequire(import.meta.url); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const jsonMode = process.argv.includes('--json'); + +const WINDOW_MS = 1000; + +function bench(method, dummy) { + const t = Date.now(); + let i = 0; + let r; + while (Date.now() - t < WINDOW_MS) { + r = method(dummy); + i++; + } + return { ops: i / (WINDOW_MS / 1000), last: r }; +} + +function emit(caseId, op, label, ops) { + if (jsonMode) { + console.log(JSON.stringify({ engine: 'js', case: caseId, op, label, ops: +ops.toFixed(2) })); + } else { + console.log(`js / flatted ${op} ${label} parsed ${ops.toFixed(2)} times per second`); + } +} + +function pair(caseId, label, value) { + const s = bench(stringify, value); + emit(caseId, 'stringify', label, s.ops); + const p = bench(parse, s.last); + emit(caseId, 'parse', label, p.ops); + return s.last; +} + +const dummyAll = JSON.parse(readFileSync(join(__dirname, 'data.json'), 'utf8')); +const keys = Object.keys(dummyAll[0]).length; + +if (!jsonMode) { + console.log('-----------------------------------'); + console.log('Object with ' + keys + ' keys each'); + console.log('-----------------------------------'); +} + +let dummy100 = dummyAll; +let dummy50 = dummyAll.slice(0, 50); +let dummy10 = dummyAll.slice(0, 10); + +pair('objects_100', '100 objects', dummy100); +pair('objects_50', '50 objects', dummy50); +pair('objects_10', '10 objects', dummy10); + +if (!jsonMode) { + console.log('-----------------------------------'); + console.log('50% same objects'); + console.log('-----------------------------------'); +} +dummy100 = dummy50.concat(dummy50); +pair('shared_50', '100 objects', dummy100); + +if (!jsonMode) { + console.log('-----------------------------------'); + console.log('90% same objects'); + console.log('-----------------------------------'); +} +dummy100 = [].concat( + dummy10, dummy10, dummy10, dummy10, dummy10, + dummy10, dummy10, dummy10, dummy10, dummy10 +); +pair('shared_90', '100 objects', dummy100); + +if (!jsonMode) { + console.log('-----------------------------------'); + console.log('with circular'); + console.log('-----------------------------------'); +} +function makeCircularObject() { + const a = {}; + a.b = a; + return a; +} +dummy100 = []; +for (let i = 0; i < 100; i++) dummy100.push(makeCircularObject()); +pair('circular_100', '100 objects', dummy100); + +if (!jsonMode) { + console.log('-----------------------------------'); + console.log('with circular 90% same'); + console.log('-----------------------------------'); +} +dummy10 = []; +for (let i = 0; i < 10; i++) dummy10.push(makeCircularObject()); +dummy100 = [].concat( + dummy10, dummy10, dummy10, dummy10, dummy10, + dummy10, dummy10, dummy10, dummy10, dummy10 +); +pair('circular_shared_90', '100 objects', dummy100); + +const circularTxt = join(__dirname, 'circular.txt'); +const flattedFixture = join(__dirname, '.bench-circular.flatted.json'); + +if (existsSync(circularTxt)) { + if (!jsonMode) { + console.log('-----------------------------------'); + console.log('Big real-world circular data'); + console.log('-----------------------------------'); + } + // circular.txt is CircularJSON; convert once to flatted for fair JS/Rust parity. + let flattedText; + if (existsSync(flattedFixture)) { + flattedText = readFileSync(flattedFixture, 'utf8'); + } else { + const CircularJSON = require('circular-json'); + const cirular = CircularJSON.parse(readFileSync(circularTxt, 'utf8')); + flattedText = stringify(cirular); + writeFileSync(flattedFixture, flattedText); + } + const value = parse(flattedText); + const label = flattedText.length + ' chars'; + emit('big_circular', 'stringify', label, bench(stringify, value).ops); + emit('big_circular', 'parse', label, bench(parse, flattedText).ops); +} diff --git a/test/bench-js-vs-rs.mjs b/test/bench-js-vs-rs.mjs new file mode 100644 index 0000000..256ee1f --- /dev/null +++ b/test/bench-js-vs-rs.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node +/** + * Compare flatted ESM/JS vs Rust using the same 1s wall-clock approach as test/bench.js. + * + * node test/bench-js-vs-rs.mjs + * + * Prepares the big circular fixture, runs both benches with --json, prints a table. + */ + +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { stringify } from '../esm/index.js'; + +const require = createRequire(import.meta.url); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); +const rustDir = join(root, 'rust'); +const fixture = join(__dirname, '.bench-circular.flatted.json'); +const circularTxt = join(__dirname, 'circular.txt'); + +function ensureFixture() { + if (existsSync(fixture)) return; + if (!existsSync(circularTxt)) { + console.warn('skipping big circular fixture (test/circular.txt missing)'); + return; + } + process.stderr.write('Preparing flatted fixture from circular.txt...\n'); + const CircularJSON = require('circular-json'); + const data = CircularJSON.parse(readFileSync(circularTxt, 'utf8')); + writeFileSync(fixture, stringify(data)); +} + +function runJson(command, args, cwd) { + const res = spawnSync(command, args, { + cwd, + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + }); + if (res.status !== 0) { + process.stderr.write(res.stderr || res.stdout || `${command} failed\n`); + process.exit(res.status || 1); + } + return (res.stdout || '') + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.startsWith('{')) + .map((l) => JSON.parse(l)); +} + +ensureFixture(); + +process.stderr.write('Running JS (ESM) bench...\n'); +const jsRows = runJson(process.execPath, [join(__dirname, 'bench-flatted.mjs'), '--json'], root); + +process.stderr.write('Running Rust bench (release)...\n'); +const rustRows = runJson( + 'cargo', + ['run', '--example', 'bench', '--release', '--quiet', '--', '--json'], + rustDir +); + +const byKey = new Map(); +for (const row of [...jsRows, ...rustRows]) { + const key = `${row.case}\t${row.op}`; + if (!byKey.has(key)) byKey.set(key, { case: row.case, op: row.op, label: row.label }); + byKey.get(key)[row.engine] = row.ops; +} + +const order = [ + 'objects_100', + 'objects_50', + 'objects_10', + 'shared_50', + 'shared_90', + 'circular_100', + 'circular_shared_90', + 'big_circular', +]; + +console.log('flatted ESM/JS vs Rust (ops/sec, ~1s each; higher is better)'); +console.log('='.repeat(78)); +console.log( + 'case'.padEnd(22) + + 'op'.padEnd(12) + + 'js'.padStart(12) + + 'rust'.padStart(12) + + 'rust/js'.padStart(10) +); +console.log('-'.repeat(78)); + +for (const caseId of order) { + for (const op of ['stringify', 'parse']) { + const row = byKey.get(`${caseId}\t${op}`); + if (!row) continue; + const js = row.js ?? NaN; + const rust = row.rust ?? NaN; + const ratio = js > 0 ? rust / js : NaN; + console.log( + caseId.padEnd(22) + + op.padEnd(12) + + js.toFixed(1).padStart(12) + + rust.toFixed(1).padStart(12) + + (Number.isFinite(ratio) ? ratio.toFixed(2) + 'x' : 'n/a').padStart(10) + ); + } +} + +console.log('='.repeat(78)); +console.log('Methodology: same as test/bench.js — count iterations in a 1s window.'); +console.log('JS: esm/index.js via Node. Rust: cargo --release example bench.');