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,
merge_extends: 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());
}
let prefix = ast
.models
.iter()
.filter_map(|m| m.prefix.as_deref())
.chain(
ast.extensions
.values()
.flatten()
.filter_map(|m| m.prefix.as_deref()),
)
.chain(ast.enums.iter().filter_map(|e| e.prefix.as_deref()))
.chain(ast.interfaces.iter().filter_map(|m| m.prefix.as_deref()))
.chain(ast.views.iter().filter_map(|m| m.prefix.as_deref()))
.chain(ast.flows.iter().filter_map(|m| m.prefix.as_deref()))
.next();
if let Some(p) = prefix {
let at = if ast.project.name.is_some() { 1 } else { 0 };
lines.insert(at, format!("# Prefix: {p}"));
if at == 0 {
lines.insert(1, 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());
}
if let Some(blocks) = ast.extensions.get("extend") {
for block in blocks {
lines.push(format!("## {} ::extend", block.name));
format_model_body(&mut lines, block);
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 let Some(base) = &model.base {
header.push_str(&format!(" ::{}({})", base.kind, base.model));
}
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 format_source(src: &str) -> String {
static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let id = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("m3l-cli-format-unit-test-{id}.m3l.md"));
std::fs::write(&path, src).expect("write test fixture");
let result = run_format(&path).expect("run_format");
std::fs::remove_file(&path).ok();
result
}
#[test]
fn format_keeps_prefix_extend_blocks_and_based_models() {
let src = "# Namespace: ex.insp\n# Prefix: insp\n\n## InspProfile ::aspect(Asset) : Timestampable\n- level: integer?\n\n## Asset ::extend\n- insp_grade: string(20)?\n";
let out = format_source(src);
assert!(out.contains("# Prefix: insp"), "{out}");
assert!(
out.contains("## InspProfile ::aspect(Asset) : Timestampable"),
"{out}"
);
assert!(
out.contains("## Asset ::extend\n- insp_grade: string(20)?"),
"{out}"
);
assert_eq!(format_source(&out), out, "formatting must be idempotent");
}
#[test]
fn format_keeps_prefix_on_an_interface_only_file() {
let src = "# Namespace: ex.insp\n# Prefix: insp\n\n## Timestampable ::interface\n- created_at: timestamp = now()\n";
let out = format_source(src);
assert!(out.contains("# Prefix: insp"), "{out}");
assert_eq!(format_source(&out), out, "formatting must be idempotent");
}
#[test]
fn format_keeps_prefix_on_a_view_only_file() {
let src = "# Namespace: ex.insp\n# Prefix: insp\n\n## ActiveAssets ::view\n### Source\n- from: Asset\n- name_label: string @from(Asset.name)\n";
let out = format_source(src);
assert!(out.contains("# Prefix: insp"), "{out}");
assert_eq!(format_source(&out), out, "formatting must be idempotent");
}
#[test]
fn format_keeps_prefix_with_no_namespace_line() {
let src = "# Prefix: insp\n\n## InspRecord\n- id: identifier @pk\n";
let out = format_source(src);
assert!(out.starts_with("# Prefix: insp"), "{out}");
assert_eq!(format_source(&out), out, "formatting must be idempotent");
}
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"
);
}
}