serpent-serializer 0.2.0

Serialize Lua values to round-trippable Lua source with cycle and shared-reference handling
Documentation
//! Option behavior.
//!
//! Table-driven checks for the filters, limits, custom sort, custom formatter,
//! fatal mode, and the raw `serialize` entry point. Reference strings come from
//! the Lua serializer for the same inputs.

mod common;

use common::norm_addr;
use serpent_serializer::options::Options;
use serpent_serializer::value::{Global, Key, Table, Value};
use serpent_serializer::{block_with, dump_with, line_with, serialize, SerError};
use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, Ordering};

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

fn set(items: &[&str]) -> HashSet<String> {
    items.iter().map(|s| s.to_string()).collect()
}

fn str_keys(items: &[&str]) -> Vec<Key> {
    items
        .iter()
        .map(|s| Key::Str(s.as_bytes().to_vec()))
        .collect()
}

/// A table with a function value, a number, a nested table, and a string.
fn mixed_table() -> Table {
    let a = Table::new();
    a.set(Key::Str(b"z".to_vec()), func_stub());
    a.set(Key::Str(b"x".to_vec()), Value::Number(1.0));
    let list = Table::new();
    list.push(Value::Number(1.0));
    a.set(Key::Str(b"list".to_vec()), Value::Table(list));
    a.set(Key::Str(b"true".to_vec()), vs("v"));
    a
}

fn func_stub() -> Value {
    Value::Function(serpent_serializer::value::Func {
        id: 1,
        bytecode: Some(vec![9, 9]),
    })
}

#[test]
fn valtypeignore_drops_function_and_table() {
    let opts = Options {
        valtypeignore: Some(set(&["function", "table"])),
        sortkeys: true,
        ..Options::dump()
    };
    let got = dump_with(&Value::Table(mixed_table()), opts).unwrap();
    assert_eq!(got, "do local _={[\"true\"]=\"v\",x=1};return _;end");
}

#[test]
fn keyallow_keeps_only_listed() {
    let opts = Options {
        keyallow: Some(str_keys(&["x", "list"])),
        sortkeys: true,
        ..Options::dump()
    };
    let got = dump_with(&Value::Table(mixed_table()), opts).unwrap();
    // The list value survives but its numeric key is not allowed, so it empties.
    assert_eq!(got, "do local _={list={},x=1};return _;end");
}

#[test]
fn keyignore_drops_named_key() {
    let a = Table::new();
    a.set(Key::Str(b"a".to_vec()), Value::Number(1.0));
    a.set(Key::Str(b"b".to_vec()), Value::Number(2.0));
    let opts = Options {
        keyignore: Some(str_keys(&["b"])),
        sortkeys: true,
        ..Options::dump()
    };
    let got = dump_with(&Value::Table(a), opts).unwrap();
    assert_eq!(got, "do local _={a=1};return _;end");
}

#[test]
fn valignore_by_scalar() {
    let a = Table::new();
    a.set(Key::Str(b"keep".to_vec()), Value::Number(1.0));
    a.set(Key::Str(b"drop".to_vec()), Value::Number(2.0));
    let opts = Options {
        valignore_scalar: Some(vec![Value::Number(2.0)]),
        sortkeys: true,
        ..Options::dump()
    };
    let got = dump_with(&Value::Table(a), opts).unwrap();
    assert_eq!(got, "do local _={keep=1};return _;end");
}

#[test]
fn maxlength_negative_empties_table() {
    let a = Table::new();
    a.push(Value::Number(1.0));
    a.push(Value::Number(2.0));
    a.push(Value::Number(3.0));
    let opts = Options {
        maxlength: Some(-1),
        ..Options::line()
    };
    let got = line_with(&Value::Table(a), opts).unwrap();
    assert_eq!(norm_addr(&got), "{} --[[maxlen]]");
}

#[test]
fn maxlength_positive_keeps_within_budget() {
    let a = Table::new();
    for n in 1..=5 {
        a.push(Value::Number(n as f64));
    }
    let opts = Options {
        maxlength: Some(6),
        ..Options::block()
    };
    let got = block_with(&Value::Table(a), opts).unwrap();
    let want = "{\n  1,\n  2,\n  3,\n  4,\n  5\n} --[[table: 0xADDR]]";
    assert_eq!(norm_addr(&got), want);
}

#[test]
fn custom_formatter_reshapes_output() {
    let a = Table::new();
    a.push(Value::Number(1.0));
    a.push(Value::Number(2.0));
    let opts = Options {
        custom: Some(Box::new(|tag, head, body, tail, _level| {
            format!("{tag}Foo{head}{body}{tail}")
        })),
        ..Options::line()
    };
    let got = line_with(&Value::Table(a), opts).unwrap();
    assert_eq!(norm_addr(&got), "Foo{1, 2} --[[table: 0xADDR]]");
}

#[test]
fn custom_sort_orders_keys() {
    let a = Table::new();
    a.set(Key::Str(b"a".to_vec()), Value::Number(1.0));
    a.set(Key::Str(b"b".to_vec()), Value::Number(2.0));
    a.set(Key::Str(b"c".to_vec()), Value::Number(3.0));
    let opts = Options {
        sortfn: Some(Box::new(
            |keys: &mut Vec<Key>, _entries: &[(Key, Value)]| {
                keys.sort_by_key(|k| std::cmp::Reverse(key_text(k)));
            },
        )),
        ..Options::line()
    };
    let got = line_with(&Value::Table(a), opts).unwrap();
    assert_eq!(norm_addr(&got), "{c = 3, b = 2, a = 1} --[[table: 0xADDR]]");
}

fn key_text(k: &Key) -> String {
    match k {
        Key::Str(s) => String::from_utf8_lossy(s).into_owned(),
        Key::Number(n) => n.to_string(),
        _ => String::new(),
    }
}

#[test]
fn custom_sort_can_rank_by_value() {
    // The comparator ranks keys by their associated value, which needs the
    // source entries. Order the keys by descending value: c(3), b(2), a(1).
    let a = Table::new();
    a.set(Key::Str(b"a".to_vec()), Value::Number(1.0));
    a.set(Key::Str(b"b".to_vec()), Value::Number(2.0));
    a.set(Key::Str(b"c".to_vec()), Value::Number(3.0));
    let opts = Options {
        sortfn: Some(Box::new(|keys: &mut Vec<Key>, entries: &[(Key, Value)]| {
            let value_of = |k: &Key| -> f64 {
                entries
                    .iter()
                    .find(|(ek, _)| ek == k)
                    .and_then(|(_, v)| match v {
                        Value::Number(n) => Some(*n),
                        _ => None,
                    })
                    .unwrap_or(0.0)
            };
            keys.sort_by(|x, y| value_of(y).partial_cmp(&value_of(x)).unwrap());
        })),
        ..Options::line()
    };
    let got = line_with(&Value::Table(a), opts).unwrap();
    assert_eq!(norm_addr(&got), "{c = 3, b = 2, a = 1} --[[table: 0xADDR]]");
}

#[test]
fn keyallow_matches_numeric_key() {
    // A numeric hash key can be allow-listed. Only [20] survives.
    let a = Table::new();
    a.set(Key::Number(10.0), Value::Str(b"ten".to_vec()));
    a.set(Key::Number(20.0), Value::Str(b"twenty".to_vec()));
    let opts = Options {
        keyallow: Some(vec![Key::Number(20.0)]),
        sortkeys: true,
        ..Options::dump()
    };
    let got = dump_with(&Value::Table(a), opts).unwrap();
    assert_eq!(got, "do local _={[20]=\"twenty\"};return _;end");
}

#[test]
fn keyignore_matches_numeric_key() {
    // keyignore = {[10] = true} drops the numeric key, which a string-only set
    // cannot express.
    let a = Table::new();
    a.set(Key::Number(10.0), Value::Str(b"ten".to_vec()));
    a.set(Key::Number(20.0), Value::Str(b"twenty".to_vec()));
    let opts = Options {
        keyignore: Some(vec![Key::Number(10.0)]),
        sortkeys: true,
        ..Options::dump()
    };
    let got = dump_with(&Value::Table(a), opts).unwrap();
    assert_eq!(got, "do local _={[20]=\"twenty\"};return _;end");
}

static CALLED: AtomicBool = AtomicBool::new(false);

#[test]
fn sort_not_called_on_numeric_only() {
    let a = Table::new();
    for n in 1..=5 {
        a.push(Value::Number(n as f64));
    }
    // A custom sort that records if it ran. Numeric-only tables must not
    // trigger it, matching serpent.
    CALLED.store(false, Ordering::Relaxed);
    let opts = Options {
        sortfn: Some(Box::new(
            |_keys: &mut Vec<Key>, _entries: &[(Key, Value)]| {
                CALLED.store(true, Ordering::Relaxed);
            },
        )),
        sparse: false,
        ..Options::dump()
    };
    let _ = dump_with(&Value::Table(a.clone()), opts).unwrap();
    assert!(
        !CALLED.load(Ordering::Relaxed),
        "sort ran on numeric-only table"
    );

    // Also with maxnum set.
    CALLED.store(false, Ordering::Relaxed);
    let opts = Options {
        sortfn: Some(Box::new(
            |_keys: &mut Vec<Key>, _entries: &[(Key, Value)]| {
                CALLED.store(true, Ordering::Relaxed);
            },
        )),
        sparse: false,
        maxnum: Some(3),
        ..Options::dump()
    };
    let _ = dump_with(&Value::Table(a), opts).unwrap();
    assert!(!CALLED.load(Ordering::Relaxed), "sort ran with maxnum");
}

#[test]
fn fatal_errors_on_non_serializable() {
    // A global with no bytecode cannot be serialized as data. With fatal set,
    // serialize returns an error instead of a fallback string.
    let a = Table::new();
    a.set(
        Key::Str(b"g".to_vec()),
        Value::Global(Global {
            name: String::new(),
            id: 5,
        }),
    );
    let opts = Options {
        fatal: true,
        ..Options::dump()
    };
    let err = serialize(&Value::Table(a), &opts).unwrap_err();
    let SerError::NotSerializable(value) = &err;
    // The message carries the value's tostring, matching serpent's
    // `error("Can't serialize "..tostring(s))`.
    assert!(value.starts_with("userdata: 0x") || !value.is_empty());
    assert!(err.to_string().starts_with("Can't serialize "));
}

#[test]
fn comment_level_bounds_depth() {
    // comment=1: the top-level table gets a comment, the nested one does not.
    let inner = Table::new();
    inner.set(Key::Str(b"y".to_vec()), Value::Number(2.0));
    let a = Table::new();
    a.set(Key::Str(b"list".to_vec()), Value::Table(inner));
    a.set(Key::Str(b"x".to_vec()), Value::Number(1.0));
    let opts = Options {
        comment: Some(1),
        nocode: true,
        sortkeys: true,
        ..Options::block()
    };
    let got = block_with(&Value::Table(a), opts).unwrap();
    assert_eq!(got.matches("--[[table:").count(), 1);
}

#[test]
fn nocode_emits_empty_stub() {
    let a = Table::new();
    a.set(Key::Str(b"f".to_vec()), func_stub());
    let opts = Options {
        nocode: true,
        ..Options::line()
    };
    let got = line_with(&Value::Table(a), opts).unwrap();
    assert!(got.contains("function() --[[..skipped..]] end"), "{got}");
}

#[test]
fn serialize_direct_matches_dump_options() {
    // The raw serialize entry with dump's options equals dump output.
    let a = Table::new();
    a.push(Value::Number(1.0));
    a.push(Value::Number(2.0));
    a.push(Value::Number(3.0));
    let opts = Options {
        name: Some("_".to_string()),
        compact: true,
        sparse: true,
        ..Options::serialize()
    };
    let got = serialize(&Value::Table(a), &opts).unwrap();
    assert_eq!(got, "do local _={1,2,3};return _;end");
}