mbdown 0.1.1

Parser and abstract syntax tree for the MBDown markup language
Documentation
use std::fmt::Write;

use crate::{
    BorderMode, BoxSpec, ColumnSpec, ColumnWidth, ColumnsSpec, Document, Node, Padding, WidthMode,
};

/// Formats an MBDown syntax tree back into canonical source text.
///
/// Markdown fragments are preserved byte-for-byte. Structural tags use their
/// long names, a stable attribute order, and omit attributes with default
/// values.
#[derive(Clone, Debug, Default)]
pub struct Formatter {
    _private: (),
}

impl Formatter {
    pub const fn new() -> Self {
        Self { _private: () }
    }

    /// Parse and format MBDown source.
    pub fn format(&self, source: &str) -> Result<String, crate::ParseError> {
        crate::parse(source).map(|document| self.format_document(&document))
    }

    /// Format an already parsed MBDown document.
    pub fn format_document(&self, document: &Document<'_>) -> String {
        let mut output = String::new();
        if let Some(front_matter) = document.front_matter() {
            output.push_str(front_matter.raw());
        }
        write_nodes(document.nodes(), &mut output);
        output
    }
}

/// Parse and format MBDown source using the default formatter.
pub fn format(source: &str) -> Result<String, crate::ParseError> {
    Formatter::new().format(source)
}

fn write_nodes(nodes: &[Node<'_>], output: &mut String) {
    for node in nodes {
        match node {
            Node::Markdown(markdown) => output.push_str(markdown.source()),
            Node::Box { spec, children } => {
                write_box_open(spec, output);
                write_nodes(children, output);
                output.push_str("[/box]");
            }
            Node::Center { children } => write_container("center", children, output),
            Node::Right { children } => write_container("right", children, output),
            Node::Indent { spec, children } => {
                write!(output, "[indent first={}]", spec.first)
                    .expect("writing to String cannot fail");
                write_nodes(children, output);
                output.push_str("[/indent]");
            }
            Node::Columns { spec, children } => {
                write_columns_open(spec, output);
                write_nodes(children, output);
                output.push_str("[/columns]");
            }
            Node::Column { spec, children } => {
                write_column_open(spec, output);
                write_nodes(children, output);
                output.push_str("[/column]");
            }
        }
    }
}

fn write_container(name: &str, children: &[Node<'_>], output: &mut String) {
    write!(output, "[{name}]").expect("writing to String cannot fail");
    write_nodes(children, output);
    write!(output, "[/{name}]").expect("writing to String cannot fail");
}

fn write_box_open(spec: &BoxSpec, output: &mut String) {
    output.push_str("[box");
    if !spec.title.is_empty() {
        write_attribute(output, "title", &spec.title);
    }
    if spec.border == BorderMode::None {
        output.push_str(" border=none");
    }
    if let Some(value) = &spec.border_color {
        write_attribute(output, "border-color", value);
    }
    if let Some(value) = &spec.bg {
        write_attribute(output, "bg", value);
    }
    match spec.width {
        WidthMode::Fit => {}
        WidthMode::Full => output.push_str(" width=full"),
        WidthMode::Exact(width) => {
            write!(output, " width={width}").expect("writing to String cannot fail");
        }
    }
    let default_padding = match spec.border {
        BorderMode::Single => Padding { x: 1, y: 0 },
        BorderMode::None => Padding { x: 0, y: 0 },
    };
    write_padding(spec.padding, default_padding, output);
    output.push(']');
}

fn write_columns_open(spec: &ColumnsSpec, output: &mut String) {
    output.push_str("[columns");
    if spec.gap != 2 {
        write!(output, " gap={}", spec.gap).expect("writing to String cannot fail");
    }
    if let Some(value) = &spec.bg {
        write_attribute(output, "bg", value);
    }
    write_padding(spec.padding, Padding::default(), output);
    output.push(']');
}

fn write_column_open(spec: &ColumnSpec, output: &mut String) {
    output.push_str("[column");
    match spec.width {
        ColumnWidth::Flex(1) => {}
        ColumnWidth::Flex(weight) => {
            write!(output, " width={weight}fr").expect("writing to String cannot fail");
        }
        ColumnWidth::Fixed(width) => {
            write!(output, " width={width}").expect("writing to String cannot fail");
        }
    }
    if let Some(value) = &spec.bg {
        write_attribute(output, "bg", value);
    }
    write_padding(spec.padding, Padding::default(), output);
    output.push(']');
}

fn write_padding(padding: Padding, default: Padding, output: &mut String) {
    if padding == default {
        return;
    }
    if padding.x == padding.y {
        write!(output, " p={}", padding.x).expect("writing to String cannot fail");
        return;
    }
    if padding.x != default.x {
        write!(output, " px={}", padding.x).expect("writing to String cannot fail");
    }
    if padding.y != default.y {
        write!(output, " py={}", padding.y).expect("writing to String cannot fail");
    }
}

fn write_attribute(output: &mut String, name: &str, value: &str) {
    output.push(' ');
    output.push_str(name);
    output.push('=');
    if is_safe_unquoted(value) {
        output.push_str(value);
        return;
    }
    output.push('"');
    for ch in value.chars() {
        if matches!(ch, '\\' | '"') {
            output.push('\\');
        }
        output.push(ch);
    }
    output.push('"');
}

fn is_safe_unquoted(value: &str) -> bool {
    !value.is_empty()
        && !value
            .chars()
            .any(|ch| ch.is_whitespace() || matches!(ch, ']' | '"'))
}

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

    #[test]
    fn canonicalizes_structural_tags_and_attributes() {
        let source = concat!(
            "[c]center[/c]",
            "[box py=0 px=1 border=single width=fit title=Info]body[/box]",
            "[cols px=2 gap=1]",
            "[col p=1 width=2fr]left[/col]",
            "[col width=20]right[/col]",
            "[/cols]"
        );

        assert_eq!(
            format(source).unwrap(),
            concat!(
                "[center]center[/center]",
                "[box title=Info]body[/box]",
                "[columns gap=1 px=2]",
                "[column width=2fr p=1]left[/column]",
                "[column width=20]right[/column]",
                "[/columns]"
            )
        );
    }

    #[test]
    fn preserves_markdown_and_code_fragments_exactly() {
        let source = concat!(
            "#  Heading\r\n\r\n",
            "[r]  [warning]keep  spacing[/warning]\r\n[/r]\r\n\r\n",
            "```mbdown\r\n[c]not structure[/c]\r\n```\r\n"
        );
        let expected = source.replace("[r]", "[right]").replace("[/r]", "[/right]");

        assert_eq!(format(source).unwrap(), expected);
    }

    #[test]
    fn quotes_and_escapes_structural_attribute_values() {
        let source = r#"[box title="A ] \"quote\" and \\ slash" bg="selected panel"]x[/box]"#;
        let formatted = format(source).unwrap();

        assert_eq!(
            formatted,
            r#"[box title="A ] \"quote\" and \\ slash" bg="selected panel"]x[/box]"#
        );
        assert!(crate::parse(&formatted).is_ok());
    }

    #[test]
    fn formatting_is_idempotent_and_rejects_invalid_source() {
        let source = "[box border=none p=2 bg=#102030]\ntext\n[/box]";
        let once = format(source).unwrap();
        let twice = format(&once).unwrap();

        assert_eq!(once, twice);
        assert!(format("[box]missing close").is_err());
    }

    #[test]
    fn preserves_front_matter_and_formats_the_body() {
        let source = concat!(
            "---\r\ntitle: Map\r\nlayout: \"[box nope=1]\"\r\n---\r\n",
            "[c][box width=fit]body[/box][/c]"
        );

        assert_eq!(
            format(source).unwrap(),
            concat!(
                "---\r\ntitle: Map\r\nlayout: \"[box nope=1]\"\r\n---\r\n",
                "[center][box]body[/box][/center]"
            )
        );
    }
}