serpent-serializer 0.2.0

Serialize Lua values to round-trippable Lua source with cycle and shared-reference handling
Documentation
//! Semantic round-trip checks.
//!
//! Serialize a value, load it back, and assert the reloaded graph matches. These
//! cover shared references, self references, table-as-key, mixed array and hash
//! tables, and a master fixture that exercises many features at once.

mod common;

use common::{deep_eq, func};
use serpent_serializer::options::Options;
use serpent_serializer::value::{Global, Key, Table, Value};
use serpent_serializer::{block, dump, dump_with, load, LoadOptions};

fn vs(s: &str) -> Value {
    Value::Str(s.as_bytes().to_vec())
}

fn load_dump(t: &Value) -> Value {
    let src = dump(t).unwrap();
    load(&src, &LoadOptions::default()).unwrap()
}

/// A nocode dump keeps function bodies stable, which the loader can parse.
fn load_dump_nocode(t: &Value, extra: Options) -> Value {
    let opts = Options {
        nocode: true,
        ..extra
    };
    let src = dump_with(t, opts).unwrap();
    load(&src, &LoadOptions::default()).unwrap()
}

fn get(t: &Table, k: &Key) -> Option<Value> {
    t.get(k)
}

fn assert_num(t: &Table, k: &Key, want: f64) {
    let got = get(t, k);
    assert!(
        matches!(&got, Some(Value::Number(n)) if *n == want),
        "key {k:?}: want {want}, got {got:?}"
    );
}

fn assert_str(t: &Table, k: &Key, want: &[u8]) {
    let got = get(t, k);
    assert!(
        matches!(&got, Some(Value::Str(s)) if s == want),
        "key {k:?}: want {want:?}, got {got:?}"
    );
}

#[test]
fn numeric_keys_all_present() {
    // Regression: keys [4]..[10] all survive.
    let a = Table::new();
    for i in 4..=10 {
        a.set(Key::Number(i as f64), Value::Number(1.0));
    }
    let back = load_dump(&Value::Table(a.clone()));
    let Value::Table(bt) = back else { panic!() };
    for i in 4..=10 {
        assert_num(&bt, &Key::Number(i as f64), 1.0);
    }
}

#[test]
fn maxnum_limits_array() {
    let a = Table::new();
    let arr = Table::new();
    for n in [7, 6, 5, 4, 3, 2, 1] {
        arr.push(Value::Number(n as f64));
    }
    let b = Table::new();
    b.push(Value::Number(1.0));
    b.push(Value::Number(2.0));
    a.set(Key::Str(b"a".to_vec()), Value::Table(arr));
    a.set(Key::Str(b"b".to_vec()), Value::Table(b));

    let opts = Options {
        maxnum: Some(3),
        ..Options::dump()
    };
    let src = dump_with(&Value::Table(a), opts).unwrap();
    let back = load(&src, &LoadOptions::default()).unwrap();
    let Value::Table(bt) = back else { panic!() };
    let Value::Table(ba) = get(&bt, &Key::Str(b"a".to_vec())).unwrap() else {
        panic!()
    };
    let Value::Table(bb) = get(&bt, &Key::Str(b"b".to_vec())).unwrap() else {
        panic!()
    };
    assert_eq!(ba.border(), 3);
    assert_num(&ba, &Key::Number(3.0), 5.0);
    assert_eq!(bb.border(), 2);
}

#[test]
fn sparse_false_keeps_size() {
    // sparse=false keeps the array size across a round trip. A contiguous array
    // of four elements survives with the same border.
    let a = Table::new();
    for n in 1..=4 {
        a.push(Value::Number(n as f64));
    }
    let src_border = a.border();
    let opts = Options {
        sparse: false,
        ..Options::dump()
    };
    let src = dump_with(&Value::Table(a), opts).unwrap();
    let back = load(&src, &LoadOptions::default()).unwrap();
    let Value::Table(bt) = back else { panic!() };
    assert_eq!(bt.border(), src_border);
    assert_eq!(bt.border(), 4);
}

#[test]
fn shared_reference_stays_shared() {
    // {p = shared, q = shared}: after reload, both point at one table.
    let shared = Table::new();
    shared.push(Value::Number(1.0));
    let top = Table::new();
    top.set(Key::Str(b"p".to_vec()), Value::Table(shared.clone()));
    top.set(Key::Str(b"q".to_vec()), Value::Table(shared));

    let back = load_dump(&Value::Table(top));
    let Value::Table(bt) = back else { panic!() };
    let Value::Table(p) = get(&bt, &Key::Str(b"p".to_vec())).unwrap() else {
        panic!()
    };
    let Value::Table(q) = get(&bt, &Key::Str(b"q".to_vec())).unwrap() else {
        panic!()
    };
    assert_eq!(p.id(), q.id(), "shared reference not preserved");
}

#[test]
fn self_reference_reconstructs() {
    // a.c = a
    let a = Table::new();
    a.set(Key::Str(b"c".to_vec()), Value::Table(a.clone()));
    let back = load_dump(&Value::Table(a));
    let Value::Table(bt) = back else { panic!() };
    let Value::Table(c) = get(&bt, &Key::Str(b"c".to_vec())).unwrap() else {
        panic!()
    };
    assert_eq!(c.id(), bt.id(), "self reference not reconstructed");
}

#[test]
fn table_as_key_reconstructs() {
    // a[{}] = "v"
    let key_tab = Table::new();
    let a = Table::new();
    a.set(Key::Table(key_tab), vs("v"));
    let back = load_dump(&Value::Table(a));
    let Value::Table(bt) = back else { panic!() };
    // The single entry has a table key mapping to "v".
    let e = bt.entries();
    assert_eq!(e.len(), 1);
    assert!(matches!(&e[0].0, Key::Table(_)));
    assert!(matches!(&e[0].1, Value::Str(s) if s == b"v"));
}

#[test]
fn circular_in_selfref_section() {
    // a[table1] = {}; a[table1].rec1 = a[table1]
    let table1 = Table::new();
    let inner = Table::new();
    inner.set(Key::Str(b"rec1".to_vec()), Value::Table(inner.clone()));
    let a = Table::new();
    a.set(Key::Table(table1), Value::Table(inner));

    let back = load_dump(&Value::Table(a));
    let Value::Table(bt) = back else { panic!() };
    let e = bt.entries();
    let (_, v) = &e[0];
    let Value::Table(iv) = v else { panic!() };
    let Value::Table(rec) = get(iv, &Key::Str(b"rec1".to_vec())).unwrap() else {
        panic!()
    };
    assert_eq!(rec.id(), iv.id(), "circular ref in self-ref section broken");
}

#[test]
fn mixed_array_and_boolean_keys() {
    // {1, 2, 3, 4, [false] = 0, [true] = 0}
    let a = Table::new();
    for n in 1..=4 {
        a.push(Value::Number(n as f64));
    }
    a.set(Key::Bool(false), Value::Number(0.0));
    a.set(Key::Bool(true), Value::Number(0.0));

    let src = format!(
        "return {}",
        serpent_serializer::line(&Value::Table(a.clone())).unwrap()
    );
    // Strip the leading "return " the loader adds itself.
    let src = src.strip_prefix("return ").unwrap();
    let back = load(src, &LoadOptions::default()).unwrap();
    let Value::Table(bt) = back else { panic!() };
    assert_eq!(bt.border(), 4);
    assert_num(&bt, &Key::Number(3.0), 3.0);
    assert_num(&bt, &Key::Number(4.0), 4.0);
}

#[test]
fn function_value_round_trips_nocode() {
    // With nocode the function becomes an empty stub. Reloading gives nil in
    // this loader, but the surrounding structure survives.
    let a = Table::new();
    a.set(Key::Str(b"z".to_vec()), func(Some(vec![1, 2, 3])));
    a.set(Key::Str(b"x".to_vec()), Value::Number(1.0));
    let back = load_dump_nocode(&Value::Table(a), Options::dump());
    let Value::Table(bt) = back else { panic!() };
    assert_num(&bt, &Key::Str(b"x".to_vec()), 1.0);
}

#[test]
fn master_fixture_round_trips() {
    // A large fixture with many features at once. Boolean keys,
    // a keyword string key, a global value, shared references, a table-as-key,
    // NaN value, huge key with -huge value, negative and string-number indices,
    // and self references.
    let b = Table::new();
    b.set(Key::Str(b"text".to_vec()), vs("ha'ns"));

    let list = Table::new();
    list.set(Key::Number(1.0), vs("a"));
    list.set(Key::Number(4.0), vs("f"));
    list.set(Key::Number(5.0), vs("g"));
    list.set(Key::Number(7.0), Value::Table(Table::new()));
    list.set(Key::Str(b"3".to_vec()), Value::Number(33.0));
    list.set(Key::Number(-1.0), Value::Number(-1.0));

    let a = Table::new();
    a.set(Key::Str(b"x".to_vec()), Value::Number(1.0));
    a.set(Key::Bool(true), Value::Table(b.clone()));
    a.set(Key::Bool(false), Value::Number(2.0));
    a.set(Key::Str(b"true".to_vec()), vs("some value"));
    a.set(Key::Str(b"list".to_vec()), Value::Table(list));
    a.set(
        Key::Global(Global {
            name: "io.stdin".to_string(),
            id: 900,
        }),
        Value::Number(3.0),
    );
    a.set(Key::Str(b"label 2".to_vec()), Value::Table(b.clone()));
    a.set(Key::Table(b.clone()), Value::Number(f64::NAN));
    a.set(Key::Number(f64::INFINITY), Value::Number(f64::NEG_INFINITY));
    a.set(Key::Str(b"c".to_vec()), Value::Nil);
    // self-reference
    a.set(Key::Str(b"c".to_vec()), Value::Table(a.clone()));

    let opts = Options {
        sparse: false,
        nocode: true,
        ..Options::dump()
    };
    let src = dump_with(&Value::Table(a.clone()), opts).unwrap();
    let back = load(&src, &LoadOptions::default()).unwrap();
    let Value::Table(bt) = back else {
        panic!("not a table: {src}")
    };

    // boolean value as key
    assert_num(&bt, &Key::Bool(false), 2.0);
    // huge key -> -huge value
    match get(&bt, &Key::Number(f64::INFINITY)) {
        Some(Value::Number(n)) => assert_eq!(n, f64::NEG_INFINITY),
        other => panic!("huge key value: {other:?}"),
    }
    // string-number key stays distinct
    let Value::Table(bl) = get(&bt, &Key::Str(b"list".to_vec())).unwrap() else {
        panic!()
    };
    assert_num(&bl, &Key::Str(b"3".to_vec()), 33.0);
    assert_num(&bl, &Key::Number(-1.0), -1.0);
    assert_str(&bl, &Key::Number(4.0), b"f");
    // self-reference
    let Value::Table(c) = get(&bt, &Key::Str(b"c".to_vec())).unwrap() else {
        panic!()
    };
    assert_eq!(c.id(), bt.id());
    // shared reference: [true] and ['label 2'] are the same table
    let Value::Table(t_true) = get(&bt, &Key::Bool(true)).unwrap() else {
        panic!()
    };
    let Value::Table(t_label) = get(&bt, &Key::Str(b"label 2".to_vec())).unwrap() else {
        panic!()
    };
    assert_eq!(t_true.id(), t_label.id(), "shared ref b not preserved");
}

#[test]
fn block_determinism() {
    // block(a) equals block(load(block(a))) for a stable, diffable option set.
    let a = Table::new();
    a.set(Key::Str(b"a".to_vec()), vs("a"));
    a.set(Key::Str(b"b".to_vec()), vs("b"));
    a.push(Value::Number(1.0));
    a.push(Value::Number(2.0));

    let first = block(&Value::Table(a.clone())).unwrap();
    let reloaded = load(&first, &LoadOptions::default()).unwrap();
    let second = block(&reloaded).unwrap();
    // Compare with addresses normalized, since comment addresses differ.
    assert_eq!(common::norm_addr(&first), common::norm_addr(&second));
    assert!(deep_eq(&Value::Table(a), &reloaded));
}