use std::fmt::Write;
use crate::{
BorderMode, BoxSpec, ColumnSpec, ColumnWidth, ColumnsSpec, Document, Node, Padding, WidthMode,
};
#[derive(Clone, Debug, Default)]
pub struct Formatter {
_private: (),
}
impl Formatter {
pub const fn new() -> Self {
Self { _private: () }
}
pub fn format(&self, source: &str) -> Result<String, crate::ParseError> {
crate::parse(source).map(|document| self.format_document(&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
}
}
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]"
)
);
}
}