use std::path::Path;
use crate::build_ast_with;
pub fn run_format(input_path: &Path) -> Result<String, String> {
let ast = build_ast_with(
input_path,
m3l_core::ResolveOptions {
inline_inherited: false,
},
)?;
Ok(format_ast(&ast))
}
fn format_ast(ast: &m3l_core::M3lAst) -> String {
let mut lines: Vec<String> = Vec::new();
if let Some(ref name) = ast.project.name {
lines.push(format!("# Namespace: {name}"));
lines.push(String::new());
}
for model in &ast.models {
format_model(&mut lines, model);
lines.push(String::new());
}
for e in &ast.enums {
format_enum(&mut lines, e);
lines.push(String::new());
}
for iface in &ast.interfaces {
lines.push(format!("## {} ::interface", iface.name));
format_model_body(&mut lines, iface);
lines.push(String::new());
}
for view in &ast.views {
lines.push(format!("## {} ::view", view.name));
format_model_body(&mut lines, view);
lines.push(String::new());
}
while lines.last().is_some_and(|l| l.is_empty()) {
lines.pop();
}
lines.join("\n")
}
fn format_model(lines: &mut Vec<String>, model: &m3l_core::ModelNode) {
let mut header = format!("## {}", model.name);
if !model.inherits.is_empty() {
header.push_str(&format!(" : {}", model.inherits.join(", ")));
}
if !model.attributes.is_empty() {
for attr in &model.attributes {
header.push_str(&format!(" @{}", attr.name));
if let Some(args_str) = format_attr_args(attr) {
header.push_str(&args_str);
}
}
}
lines.push(header);
format_model_body(lines, model);
}
fn push_description(lines: &mut Vec<String>, desc: &str, prefix: &str) {
for desc_line in desc.split('\n') {
lines.push(format!("{prefix}> {desc_line}"));
}
}
fn format_model_body(lines: &mut Vec<String>, model: &m3l_core::ModelNode) {
if let Some(ref desc) = model.description {
push_description(lines, desc, "");
}
for field in &model.fields {
format_field(lines, field, 0);
}
}
fn format_field(lines: &mut Vec<String>, field: &m3l_core::FieldNode, indent: usize) {
let prefix = " ".repeat(indent);
let mut line = format!("{prefix}- {}", field.name);
if let Some(ref ft) = field.field_type {
line.push_str(&format!(": {ft}"));
if let Some(ref params) = field.params {
let param_strs: Vec<String> = params
.iter()
.map(|p| match p {
m3l_core::ParamValue::String(s) => s.clone(),
m3l_core::ParamValue::Number(n) => n.to_string(),
})
.collect();
line.push_str(&format!("({})", param_strs.join(", ")));
}
}
if field.array {
if field.array_item_nullable {
line.push('?');
}
line.push_str("[]");
if field.nullable {
line.push('?');
}
} else if field.nullable {
line.push('?');
}
if let Some(ref dv) = field.default_value {
if field.default_value_quoted == Some(true) {
line.push_str(&format!(" = \"{dv}\""));
} else if field.default_value_backtick == Some(true) {
line.push_str(&format!(" = `{dv}`"));
} else {
line.push_str(&format!(" = {dv}"));
}
}
for attr in &field.attributes {
line.push_str(&format!(" @{}", attr.name));
if let Some(args_str) = format_attr_args(attr) {
line.push_str(&args_str);
}
}
lines.push(line);
if let Some(ref desc) = field.description {
push_description(lines, desc, &format!("{prefix} "));
}
if let Some(ref enum_values) = field.enum_values {
lines.push(format!("{prefix} - values:"));
for val in enum_values {
lines.push(format_enum_value(val, indent + 2));
}
}
if let Some(ref sub_fields) = field.fields {
for sf in sub_fields {
format_field(lines, sf, indent + 1);
}
}
}
fn format_enum(lines: &mut Vec<String>, e: &m3l_core::EnumNode) {
lines.push(format!("## {} ::enum", e.name));
if let Some(ref desc) = e.description {
push_description(lines, desc, "");
}
for val in &e.values {
lines.push(format_enum_value(val, 0));
}
}
fn format_enum_value(val: &m3l_core::EnumValue, indent: usize) -> String {
let prefix = " ".repeat(indent);
let mut rhs = String::new();
match (&val.value_type, &val.value) {
(Some(t), Some(v)) => rhs.push_str(&format!("{t} = {}", render_json_scalar(v))),
(Some(t), None) => rhs.push_str(t),
(None, Some(v)) => rhs.push_str(&format!("= {}", render_json_scalar(v))),
(None, None) => {}
}
if let Some(ref desc) = val.description {
if !rhs.is_empty() {
rhs.push(' ');
}
rhs.push_str(&format!("\"{desc}\""));
}
if let Some(ref attrs) = val.attributes {
for attr in attrs {
if !rhs.is_empty() {
rhs.push(' ');
}
rhs.push_str(&format!("@{}", attr.name));
if let Some(args_str) = format_attr_args(attr) {
rhs.push_str(&args_str);
}
}
}
if rhs.is_empty() {
format!("{prefix}- {}", val.name)
} else {
format!("{prefix}- {}: {rhs}", val.name)
}
}
fn render_json_scalar(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
}
}
fn format_arg(arg: &m3l_core::AttrArgValue, quoted: bool) -> String {
match arg {
m3l_core::AttrArgValue::String(s) if quoted => format!("\"{s}\""),
m3l_core::AttrArgValue::String(s) => s.clone(),
m3l_core::AttrArgValue::Number(n) => n.to_string(),
m3l_core::AttrArgValue::Bool(b) => b.to_string(),
}
}
fn format_attr_args(attr: &m3l_core::FieldAttribute) -> Option<String> {
let args = attr.args.as_ref()?;
let arg_strs: Vec<String> = args
.iter()
.enumerate()
.map(|(i, arg)| {
let quoted = attr
.args_quoted
.as_ref()
.and_then(|q| q.get(i))
.copied()
.unwrap_or(false);
format_arg(arg, quoted)
})
.collect();
Some(format!("({})", arg_strs.join(", ")))
}
#[cfg(test)]
mod tests {
use super::*;
fn roundtrip_enum_values(src: &str) -> (Vec<m3l_core::EnumValue>, Vec<m3l_core::EnumValue>) {
let ast = m3l_core::resolve(&[m3l_core::parse_string(src, "t.m3l.md")], None);
let formatted = format_ast(&ast);
let reparsed = m3l_core::resolve(&[m3l_core::parse_string(&formatted, "t.m3l.md")], None);
(
ast.enums
.first()
.map(|e| e.values.clone())
.unwrap_or_default(),
reparsed
.enums
.first()
.map(|e| e.values.clone())
.unwrap_or_default(),
)
}
#[test]
fn format_preserves_enum_value_attributes() {
let (before, after) = roundtrip_enum_values(
"## PaymentMethod ::enum\n- cash: \"현금\"\n- legacy: \"이관 정리\" @system",
);
assert_eq!(before, after, "enum value attributes must survive format");
}
#[test]
fn format_preserves_enum_value_type_and_stored_value() {
let (before, after) = roundtrip_enum_values(
"## OrderStatus ::enum\n- pending: integer = 100 \"대기\"\n- done: integer = 200 \"완료\" @system",
);
assert_eq!(
before, after,
"enum value type/stored value must survive format"
);
}
#[test]
fn format_preserves_enum_attribute_args() {
let (before, after) = roundtrip_enum_values(
"## Status ::enum\n- archived: \"보관\" @deprecated(\"use closed\")",
);
assert_eq!(before, after, "attribute args must survive format");
}
#[test]
fn format_preserves_stored_value_without_type() {
let (before, after) = roundtrip_enum_values("## Code ::enum\n - a: A_1\n - b: B_2");
assert_eq!(before, after, "untyped stored value must survive format");
}
#[test]
fn format_preserves_inline_enum_values() {
let src = "## Order\n- method: enum = \"cash\"\n - values:\n - cash: \"현금\"\n - legacy: \"이관\" @system";
let ast = m3l_core::resolve(&[m3l_core::parse_string(src, "t.m3l.md")], None);
let formatted = format_ast(&ast);
let reparsed = m3l_core::resolve(&[m3l_core::parse_string(&formatted, "t.m3l.md")], None);
assert_eq!(
ast.models[0].fields[0].enum_values, reparsed.models[0].fields[0].enum_values,
"inline enum values must survive format:\n{formatted}"
);
}
fn roundtrip_array_flags(field_decl: &str) -> (bool, bool) {
let src = format!("## T\n- f: {field_decl}");
let ast = m3l_core::resolve(&[m3l_core::parse_string(&src, "t.m3l.md")], None);
let formatted = format_ast(&ast);
let reparsed = m3l_core::resolve(&[m3l_core::parse_string(&formatted, "t.m3l.md")], None);
let field = &reparsed.models[0].fields[0];
(field.nullable, field.array_item_nullable)
}
#[test]
fn format_preserves_item_nullable_without_array_nullable() {
let (nullable, item_nullable) = roundtrip_array_flags("string?[]");
assert!(!nullable, "array itself must stay non-nullable");
assert!(item_nullable, "item-nullable marker must survive format");
}
#[test]
fn format_preserves_array_nullable_without_item_nullable() {
let (nullable, item_nullable) = roundtrip_array_flags("string[]?");
assert!(nullable, "array-nullable marker must survive format");
assert!(!item_nullable, "items must stay non-nullable");
}
#[test]
fn format_preserves_both_nullable_markers_independently() {
let (nullable, item_nullable) = roundtrip_array_flags("string?[]?");
assert!(nullable, "array-nullable marker must survive format");
assert!(item_nullable, "item-nullable marker must survive format");
}
fn roundtrip_attr_args(
field_decl: &str,
) -> (Option<Vec<m3l_core::AttrArgValue>>, Option<Vec<bool>>) {
let src = format!("## T\n- f: {field_decl}");
let ast = m3l_core::resolve(&[m3l_core::parse_string(&src, "t.m3l.md")], None);
let formatted = format_ast(&ast);
let reparsed = m3l_core::resolve(&[m3l_core::parse_string(&formatted, "t.m3l.md")], None);
let attr = &reparsed.models[0].fields[0].attributes[0];
(attr.args.clone(), attr.args_quoted.clone())
}
#[test]
fn format_preserves_quoted_attribute_arg() {
let (args, quoted) = roundtrip_attr_args(r#"string @reference("Category")"#);
assert_eq!(
args,
Some(vec![m3l_core::AttrArgValue::String("Category".into())])
);
assert_eq!(quoted, Some(vec![true]), "quoted arg must stay quoted");
}
#[test]
fn format_preserves_unquoted_attribute_arg() {
let (args, quoted) = roundtrip_attr_args("string @reference(Category)");
assert_eq!(
args,
Some(vec![m3l_core::AttrArgValue::String("Category".into())])
);
assert!(
quoted.is_none_or(|q| !q[0]),
"bareword arg must not gain quotes"
);
}
fn roundtrip_default_value(
field_decl: &str,
) -> (
Option<String>,
Option<m3l_core::DefaultValueType>,
Option<bool>,
Option<bool>,
) {
let src = format!("## T\n- f: {field_decl}");
let ast = m3l_core::resolve(&[m3l_core::parse_string(&src, "t.m3l.md")], None);
let formatted = format_ast(&ast);
let reparsed = m3l_core::resolve(&[m3l_core::parse_string(&formatted, "t.m3l.md")], None);
let field = &reparsed.models[0].fields[0];
(
field.default_value.clone(),
field.default_value_type.clone(),
field.default_value_quoted,
field.default_value_backtick,
)
}
#[test]
fn format_preserves_quoted_default_literal() {
let (value, ty, quoted, backtick) = roundtrip_default_value(r#"string = "active""#);
assert_eq!(value.as_deref(), Some("active"));
assert_eq!(ty, Some(m3l_core::DefaultValueType::Literal));
assert_eq!(quoted, Some(true), "quoted default must stay quoted");
assert_eq!(backtick, None);
}
#[test]
fn format_preserves_bareword_default_literal() {
let (value, ty, quoted, backtick) = roundtrip_default_value("string = active");
assert_eq!(value.as_deref(), Some("active"));
assert_eq!(ty, Some(m3l_core::DefaultValueType::Literal));
assert_eq!(quoted, None, "bareword default must not gain quotes");
assert_eq!(backtick, None);
}
#[test]
fn format_preserves_backtick_default_expression() {
let (value, ty, quoted, backtick) = roundtrip_default_value("decimal = `price * qty`");
assert_eq!(value.as_deref(), Some("price * qty"));
assert_eq!(ty, Some(m3l_core::DefaultValueType::Expression));
assert_eq!(quoted, None);
assert_eq!(
backtick,
Some(true),
"backtick expression must keep its delimiters"
);
}
#[test]
fn format_preserves_paren_default_expression() {
let (value, ty, quoted, backtick) = roundtrip_default_value("timestamp = now()");
assert_eq!(value.as_deref(), Some("now()"));
assert_eq!(ty, Some(m3l_core::DefaultValueType::Expression));
assert_eq!(quoted, None);
assert_eq!(
backtick, None,
"paren-call expression must not gain backticks"
);
}
}