serpent-serializer 0.1.0

Serialize Lua values to round-trippable Lua source with cycle and shared-reference handling
Documentation
//! Coverage for escaping, key ordering, limits, and comment depth.
//!
//! These pin behaviors that match the Lua serializer but had no test. A hostile
//! edit could regress them without a failure. Reference strings come from the
//! Lua serializer for the same inputs, with table addresses normalized.

mod common;

use common::norm_addr;
use serpent_serializer::options::Options;
use serpent_serializer::quote::quote;
use serpent_serializer::value::{Func, Key, Table, Value};
use serpent_serializer::{line, line_with, load, serialize, LoadOptions};

fn line_norm(t: &Table) -> String {
    norm_addr(&line(&Value::Table(t.clone())).unwrap())
}

// --- Control-byte and high-byte escaping ---

#[test]
fn escape_control_bytes() {
    assert_eq!(quote(&[26]), "\"\\026\"");
    assert_eq!(quote(&[13]), "\"\\r\"");
    assert_eq!(quote(&[0]), "\"\\0\"");
    // Tab (9) is not escaped by the Lua 5.1 subset serpent uses. It stays raw.
    assert_eq!(quote(&[9]), "\"\t\"");
}

#[test]
fn nul_before_digit_uses_padded_form() {
    // A bare \0 before a digit would parse as one decimal escape and lose bytes.
    // The padded \000 keeps the NUL and the digit separate.
    assert_eq!(quote(&[0, b'5']), "\"\\0005\"");
    assert_eq!(quote(&[0, 0]), "\"\\0\\0\"");
}

#[test]
fn high_bytes_escape_to_decimal() {
    assert_eq!(quote(&[128]), "\"\\128\"");
    assert_eq!(quote(&[200]), "\"\\200\"");
    assert_eq!(quote(&[255]), "\"\\255\"");
}

#[test]
fn high_and_control_bytes_round_trip() {
    // Every byte class in one string, round-tripped through load.
    let blob = vec![0u8, 10, 13, 26, 34, 92, 200, 255];
    let t = Table::new();
    t.set(Key::Str(b"s".to_vec()), Value::Str(blob.clone()));
    let src = line(&Value::Table(t)).unwrap();
    let back = load(&src, &LoadOptions::default()).unwrap();
    let Value::Table(bt) = back else { panic!() };
    assert_eq!(bt.get(&Key::Str(b"s".to_vec())), Some(Value::Str(blob)));
}

#[test]
fn single_high_bytes_round_trip() {
    for byte in [128u8, 200, 255] {
        let t = Table::new();
        t.set(Key::Str(b"s".to_vec()), Value::Str(vec![byte]));
        let src = line(&Value::Table(t)).unwrap();
        let back = load(&src, &LoadOptions::default()).unwrap();
        let Value::Table(bt) = back else { panic!() };
        assert_eq!(
            bt.get(&Key::Str(b"s".to_vec())),
            Some(Value::Str(vec![byte])),
            "byte {byte}"
        );
    }
}

#[test]
fn bytecode_blob_does_not_panic() {
    // Realistic bytecode spans 0..255 and starts with the ESC byte. Serializing
    // it must not panic and must produce a reload stub.
    let code: Vec<u8> = (0u8..=255).collect();
    let f = Value::Function(Func {
        id: 1,
        bytecode: Some(code),
    });
    let t = Table::new();
    t.set(Key::Str(b"f".to_vec()), f);
    let got = line(&Value::Table(t)).unwrap();
    assert!(got.contains("(loadstring or load)("), "{got}");
}

// --- maxlength mid-break ---

#[test]
fn maxlength_breaks_partway_strings() {
    let t = Table::new();
    for s in ["aaa", "bbb", "ccc", "ddd"] {
        t.push(Value::Str(s.as_bytes().to_vec()));
    }
    let opts = Options {
        maxlength: Some(8),
        ..Options::line()
    };
    assert_eq!(
        norm_addr(&line_with(&Value::Table(t), opts).unwrap()),
        "{\"aaa\", \"bbb\"} --[[table: 0xADDR]]"
    );
}

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

// --- special number as a key ---

#[test]
fn special_number_key_comment_inside_brackets() {
    let t = Table::new();
    t.set(Key::Number(f64::INFINITY), Value::Number(f64::NEG_INFINITY));
    assert_eq!(
        line_norm(&t),
        "{[1/0 --[[math.huge]]] = -1/0 --[[-math.huge]]} --[[table: 0xADDR]]"
    );
}

// --- boolean key order ---

#[test]
fn boolean_keys_sort_false_before_true() {
    let t = Table::new();
    t.set(Key::Bool(true), Value::Str(b"t".to_vec()));
    t.set(Key::Bool(false), Value::Str(b"f".to_vec()));
    assert_eq!(
        line_norm(&t),
        "{[false] = \"f\", [true] = \"t\"} --[[table: 0xADDR]]"
    );
}

// --- empty string key ---

#[test]
fn empty_string_key_uses_brackets() {
    let t = Table::new();
    t.set(Key::Str(b"".to_vec()), Value::Str(b"v".to_vec()));
    assert_eq!(line_norm(&t), "{[\"\"] = \"v\"} --[[table: 0xADDR]]");
}

// --- comment as a numeric depth on nested tables ---

#[test]
fn comment_depth_two_stops_below_level_two() {
    // block({x={y={z=1}}}, comment=2): address comments on the level-0 and
    // level-1 tables, none deeper.
    let z = Table::new();
    z.set(Key::Str(b"z".to_vec()), Value::Number(1.0));
    let y = Table::new();
    y.set(Key::Str(b"y".to_vec()), Value::Table(z));
    let x = Table::new();
    x.set(Key::Str(b"x".to_vec()), Value::Table(y));
    let opts = Options {
        comment: Some(2),
        ..Options::block()
    };
    let got = serialize(&Value::Table(x), &opts).unwrap();
    assert_eq!(got.matches("--[[table:").count(), 2, "{got}");
}

// --- nohuge with inf/nan inside a table ---

#[test]
fn nohuge_formats_inf_and_nan_in_table() {
    let t = Table::new();
    t.set(Key::Str(b"a".to_vec()), Value::Number(f64::INFINITY));
    t.set(Key::Str(b"b".to_vec()), Value::Number(f64::NAN));
    let opts = Options {
        nohuge: true,
        sortkeys: true,
        ..Options::line()
    };
    let got = norm_addr(&line_with(&Value::Table(t), opts).unwrap());
    assert!(got.contains("a = inf"), "{got}");
    assert!(got.contains("b = nan"), "{got}");
    assert!(!got.contains("1/0"), "{got}");
}