nested-text 0.1.0

A fully spec-compliant NestedText v3.8 parser and serializer
Documentation
use crate::value::Value;

/// Options for controlling NestedText output formatting.
#[derive(Debug, Clone)]
pub struct DumpOptions {
    /// Number of spaces per indentation level (default: 4).
    pub indent: usize,
    /// Sort dictionary keys alphabetically (default: false).
    pub sort_keys: bool,
}

impl Default for DumpOptions {
    fn default() -> Self {
        DumpOptions {
            indent: 4,
            sort_keys: false,
        }
    }
}

/// Serialize a Value to a NestedText string.
pub fn dumps(value: &Value, options: &DumpOptions) -> String {
    let mut lines = Vec::new();
    render_value(value, 0, options, &mut lines);
    if lines.is_empty() {
        String::new()
    } else {
        lines.join("\n") + "\n"
    }
}

/// Serialize a Value to a writer in NestedText format.
pub fn dump<W: std::io::Write>(
    value: &Value,
    options: &DumpOptions,
    mut writer: W,
) -> Result<(), std::io::Error> {
    let s = dumps(value, options);
    writer.write_all(s.as_bytes())
}

fn render_value(value: &Value, depth: usize, options: &DumpOptions, lines: &mut Vec<String>) {
    match value {
        Value::String(s) => render_string(s, depth, lines),
        Value::List(items) => render_list(items, depth, options, lines),
        Value::Dict(pairs) => render_dict(pairs, depth, options, lines),
    }
}

fn render_string(s: &str, depth: usize, lines: &mut Vec<String>) {
    let indent = " ".repeat(depth);
    if s.is_empty() {
        // Empty string is represented as a single ">"
        lines.push(format!("{}>", indent));
    } else {
        for line in s.split('\n') {
            lines.push(format!("{}> {}", indent, line));
        }
    }
}

fn render_list(items: &[Value], depth: usize, options: &DumpOptions, lines: &mut Vec<String>) {
    let indent = " ".repeat(depth);
    if items.is_empty() {
        lines.push(format!("{}[]", indent));
        return;
    }

    for item in items {
        match item {
            Value::String(s) if s.is_empty() => {
                lines.push(format!("{}-", indent));
            }
            Value::String(s) if !value_needs_multiline(s) => {
                lines.push(format!("{}- {}", indent, s));
            }
            _ => {
                lines.push(format!("{}-", indent));
                render_value(item, depth + options.indent, options, lines);
            }
        }
    }
}

fn render_dict(
    pairs: &[(String, Value)],
    depth: usize,
    options: &DumpOptions,
    lines: &mut Vec<String>,
) {
    let indent = " ".repeat(depth);
    if pairs.is_empty() {
        lines.push(format!("{}{{}}", indent));
        return;
    }

    let pairs: Vec<&(String, Value)> = if options.sort_keys {
        let mut sorted: Vec<&(String, Value)> = pairs.iter().collect();
        sorted.sort_by(|a, b| a.0.cmp(&b.0));
        sorted
    } else {
        pairs.iter().collect()
    };

    for (key, value) in pairs {
        let key_needs_multiline = key_requires_multiline(key);

        if key_needs_multiline {
            // Multiline key using ": " prefix
            for key_line in key.split('\n') {
                if key_line.is_empty() {
                    lines.push(format!("{}:", indent));
                } else {
                    lines.push(format!("{}: {}", indent, key_line));
                }
            }
            // Value must be indented
            render_value(value, depth + options.indent, options, lines);
        } else {
            match value {
                Value::String(s) if s.is_empty() => {
                    lines.push(format!("{}{}:", indent, key));
                }
                Value::String(s) if !value_needs_multiline(s) => {
                    lines.push(format!("{}{}: {}", indent, key, s));
                }
                _ => {
                    // Complex or multiline value: key on its own line, value indented
                    lines.push(format!("{}{}:", indent, key));
                    render_value(value, depth + options.indent, options, lines);
                }
            }
        }
    }
}

/// Check if a key requires multiline ": " syntax.
fn key_requires_multiline(key: &str) -> bool {
    if key.is_empty() || key.contains('\n') {
        return true;
    }
    // Keys with leading or trailing whitespace
    if key != key.trim() {
        return true;
    }
    // Keys containing ": " would split wrong when re-parsed as a dict item
    if key.contains(": ") || key.ends_with(':') {
        return true;
    }
    // Keys starting with a character that would be interpreted as a tag
    if key.starts_with("- ")
        || key == "-"
        || key.starts_with("> ")
        || key == ">"
        || key.starts_with(": ")
        || key == ":"
        || key.starts_with('#')
        || key.starts_with('[')
        || key.starts_with('{')
    {
        return true;
    }
    false
}

/// Check if a string value needs to be rendered as a multiline string (using "> ")
/// rather than placed inline after "key: " or "- ".
/// Values that start with tag-like patterns would be misinterpreted on re-parse
/// if placed inline in certain contexts (e.g., as an indented value under a key).
fn value_needs_multiline(s: &str) -> bool {
    if s.contains('\n') {
        return true;
    }
    // Values with leading or trailing whitespace
    if !s.is_empty() && s != s.trim() {
        return true;
    }
    false
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_dump_simple_string() {
        let v = Value::String("hello".to_string());
        assert_eq!(dumps(&v, &DumpOptions::default()), "> hello\n");
    }

    #[test]
    fn test_dump_multiline_string() {
        let v = Value::String("line 1\nline 2".to_string());
        assert_eq!(dumps(&v, &DumpOptions::default()), "> line 1\n> line 2\n");
    }

    #[test]
    fn test_dump_empty_string() {
        let v = Value::String(String::new());
        assert_eq!(dumps(&v, &DumpOptions::default()), ">\n");
    }

    #[test]
    fn test_dump_simple_list() {
        let v = Value::List(vec![
            Value::String("a".to_string()),
            Value::String("b".to_string()),
        ]);
        assert_eq!(dumps(&v, &DumpOptions::default()), "- a\n- b\n");
    }

    #[test]
    fn test_dump_empty_list() {
        let v = Value::List(vec![]);
        assert_eq!(dumps(&v, &DumpOptions::default()), "[]\n");
    }

    #[test]
    fn test_dump_simple_dict() {
        let v = Value::Dict(vec![
            ("name".to_string(), Value::String("John".to_string())),
            ("age".to_string(), Value::String("30".to_string())),
        ]);
        assert_eq!(
            dumps(&v, &DumpOptions::default()),
            "name: John\nage: 30\n"
        );
    }

    #[test]
    fn test_dump_empty_dict() {
        let v = Value::Dict(vec![]);
        assert_eq!(dumps(&v, &DumpOptions::default()), "{}\n");
    }

    #[test]
    fn test_dump_nested() {
        let v = Value::Dict(vec![(
            "items".to_string(),
            Value::List(vec![
                Value::String("a".to_string()),
                Value::String("b".to_string()),
            ]),
        )]);
        assert_eq!(
            dumps(&v, &DumpOptions::default()),
            "items:\n    - a\n    - b\n"
        );
    }

    #[test]
    fn test_dump_multiline_key() {
        let v = Value::Dict(vec![(
            "".to_string(),
            Value::String("value".to_string()),
        )]);
        let result = dumps(&v, &DumpOptions::default());
        assert_eq!(result, ":\n    > value\n");
    }

    #[test]
    fn test_dump_sorted_keys() {
        let v = Value::Dict(vec![
            ("b".to_string(), Value::String("2".to_string())),
            ("a".to_string(), Value::String("1".to_string())),
        ]);
        let opts = DumpOptions {
            sort_keys: true,
            ..Default::default()
        };
        assert_eq!(dumps(&v, &opts), "a: 1\nb: 2\n");
    }

    #[test]
    fn test_roundtrip_simple() {
        use crate::parser::{loads, Top};
        let input = "name: John\nage: 30\n";
        let v = loads(input, Top::Any).unwrap().unwrap();
        let output = dumps(&v, &DumpOptions::default());
        let v2 = loads(&output, Top::Any).unwrap().unwrap();
        assert_eq!(v, v2);
    }
}