use std::{error::Error, fmt}; use decodal::{ Ast, BinaryOp, CompareOp, Expr, ExprId, Field, ObjectRest, Param, SourceForm, Span, SyntaxToken, SyntaxTokenKind, ast::{MatchArm, UnaryOp}, parse_source, tokenize_source, }; use wasm_bindgen::prelude::*; const INDENT: usize = 4; #[derive(Debug, Clone, PartialEq, Eq)] pub struct FormatError { message: String, } impl FormatError { fn new(message: impl Into) -> Self { Self { message: message.into(), } } pub fn message(&self) -> &str { &self.message } } impl fmt::Display for FormatError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.message) } } impl Error for FormatError {} /// Formats a complete Decodal source with the canonical parser and syntax tokens. /// /// Native and WebAssembly callers execute this exact implementation. Comments /// are recovered from the lossless token stream while expression structure and /// precedence come from the same AST used by evaluation. pub fn format_source(source: &str) -> Result { let parsed = parse_source(source) .map_err(|diagnostic| FormatError::new(format!("parse error: {}", diagnostic.message)))?; let tokens = tokenize_source(source) .map_err(|diagnostic| FormatError::new(format!("lex error: {}", diagnostic.message)))?; Ok(Formatter::new(source, &parsed.ast, tokens).format(parsed.root, parsed.source_form)) } #[wasm_bindgen(js_name = formatSource)] pub fn format_source_json(source: &str) -> String { match format_source(source) { Ok(formatted) => serde_json::json!({ "ok": true, "source": formatted }).to_string(), Err(error) => serde_json::json!({ "ok": false, "error": error.message() }).to_string(), } } struct Formatter<'a> { source: &'a str, ast: &'a Ast, tokens: Vec, comments: Vec, } impl<'a> Formatter<'a> { fn new(source: &'a str, ast: &'a Ast, tokens: Vec) -> Self { let comments = tokens .iter() .filter(|token| token.kind == SyntaxTokenKind::Comment) .map(|token| token.span) .collect(); Self { source, ast, tokens, comments, } } fn format(&self, root: ExprId, source_form: SourceForm) -> String { let mut out = String::new(); if source_form == SourceForm::Fields { let Expr::Object { fields, .. } = &self.ast.get(root).expr else { unreachable!("field-form sources parse to an object") }; self.write_field_list(&mut out, fields, 0, self.source.len(), 0); } else { let span = self.ast.span(root); self.write_between(&mut out, 0, span.start as usize, None, 0); self.write_expr(&mut out, root, 0, 0); self.write_between( &mut out, span.end as usize, self.source.len(), Some(span.end as usize), 0, ); } trim_trailing_whitespace(&mut out); out.push('\n'); out } fn write_field_list( &self, out: &mut String, fields: &[Field], start: usize, end: usize, indent: usize, ) { let mut cursor = start; let mut previous_end = None; for field in fields { let field_start = field.span.start as usize; self.write_between(out, cursor, field_start, previous_end, indent); write_indent(out, indent); self.write_field(out, field, indent); out.push(';'); cursor = field.span.end as usize; previous_end = Some(cursor); } self.write_between(out, cursor, end, previous_end, indent); } fn write_field(&self, out: &mut String, field: &Field, indent: usize) { let value_start = self.ast.span(field.value).start as usize; if self.has_comment_between(field.span.start as usize, value_start) { out.push_str(self.raw(field.span)); return; } for (index, part) in field.path.iter().enumerate() { if index > 0 { out.push('.'); } out.push_str(part); } out.push_str(" = "); self.write_expr(out, field.value, indent, 0); } fn write_expr(&self, out: &mut String, id: ExprId, indent: usize, parent_prec: u8) { let node = self.ast.get(id); if self.has_comment(node.span) && !matches!( node.expr, Expr::Object { .. } | Expr::Array(_) | Expr::Let { .. } | Expr::Match { .. } ) { out.push_str(self.raw(node.span)); return; } let precedence = self.precedence(id); let parenthesize = precedence < parent_prec; if parenthesize { out.push('('); } match &node.expr { Expr::Literal(_) | Expr::Ident(_) | Expr::RegexConstraint(_) | Expr::Wildcard => { out.push_str(self.raw(node.span)); } Expr::Object { fields, rest } => { self.write_object(out, node.span, fields, rest.as_ref(), indent) } Expr::Array(items) => self.write_array(out, node.span, items, indent), Expr::ArrayConstraint { item } => { out.push_str("[..."); self.write_expr(out, *item, indent, 0); out.push(']'); } Expr::MapConstraint { value } => { out.push_str("{..."); self.write_expr(out, *value, indent, 0); out.push('}'); } Expr::Let { bindings, body } => self.write_let(out, node.span, bindings, *body, indent), Expr::Import(_) => { out.push_str("import "); let raw = self.raw(node.span); out.push_str(raw.strip_prefix("import").unwrap_or(raw).trim()); } Expr::Path { base, field } => { self.write_expr(out, *base, indent, precedence); out.push('.'); out.push_str(field); } Expr::Call { callee, args } => { self.write_expr(out, *callee, indent, precedence); out.push('('); for (index, argument) in args.iter().enumerate() { if index > 0 { out.push_str(", "); } self.write_expr(out, *argument, indent, 0); } out.push(')'); } Expr::Function { params, body } => self.write_function(out, params, *body, indent), Expr::Parenthesized { expr } => { out.push('('); self.write_expr(out, *expr, indent, 0); out.push(')'); } Expr::Match { scrutinee, arms } => { self.write_match(out, node.span, *scrutinee, arms, indent) } Expr::Unary { op, expr } => { out.push_str(match op { UnaryOp::Neg => "-", UnaryOp::Not => "!", }); self.write_expr(out, *expr, indent, precedence); } Expr::Binary { op, lhs, rhs } => { self.write_expr(out, *lhs, indent, precedence); out.push(' '); out.push_str(binary_operator(*op)); out.push(' '); self.write_expr(out, *rhs, indent, precedence + 1); } Expr::Default { base, fallback } => { self.write_expr(out, *base, indent, precedence); out.push_str(" default "); self.write_expr(out, *fallback, indent, precedence + 1); } Expr::As { narrower, wider } => { self.write_expr(out, *narrower, indent, precedence); out.push_str(" as "); self.write_expr(out, *wider, indent, precedence + 1); } Expr::CompareConstraint { op, value } => { out.push_str(compare_operator(*op)); out.push(' '); self.write_expr(out, *value, indent, precedence); } } if parenthesize { out.push(')'); } } fn write_object( &self, out: &mut String, span: Span, fields: &[Field], rest: Option<&ObjectRest>, indent: usize, ) { let (start, end) = self.delimited_range(span, SyntaxTokenKind::LBrace, SyntaxTokenKind::RBrace); if fields.is_empty() && rest.is_none() && !self.has_comment_between(start, end) { out.push_str("{}"); return; } out.push_str("{\n"); let field_end = rest.map_or(end, |rest| rest.span.start as usize); self.write_field_list(out, fields, start, field_end, indent + INDENT); if let Some(rest) = rest { write_indent(out, indent + INDENT); out.push_str("..."); let value_start = self.ast.span(rest.value).start as usize; let ellipsis = self .tokens_between(rest.span.start as usize, value_start) .find(|token| token.kind == SyntaxTokenKind::Ellipsis) .expect("parsed object rest constraints contain `...`"); if self.has_comment_between(ellipsis.span.end as usize, value_start) { self.write_between( out, ellipsis.span.end as usize, value_start, Some(ellipsis.span.end as usize), indent + INDENT, ); write_indent(out, indent + INDENT); } self.write_expr(out, rest.value, indent + INDENT, 0); self.write_between( out, rest.span.end as usize, end, Some(rest.span.end as usize), indent + INDENT, ); } write_indent(out, indent); out.push('}'); } fn write_array(&self, out: &mut String, span: Span, items: &[ExprId], indent: usize) { let (start, end) = self.delimited_range(span, SyntaxTokenKind::LBracket, SyntaxTokenKind::RBracket); if items.is_empty() && !self.has_comment_between(start, end) { out.push_str("[]"); return; } let inline = !self.has_comment_between(start, end) && items.iter().all(|item| self.is_inline_expr(*item)); if inline { out.push('['); for (index, item) in items.iter().enumerate() { if index > 0 { out.push_str(", "); } self.write_expr(out, *item, indent, 0); } out.push(']'); return; } out.push_str("[\n"); let mut cursor = start; let mut previous_end = None; for item in items { let item_span = self.ast.span(*item); self.write_between( out, cursor, item_span.start as usize, previous_end, indent + INDENT, ); write_indent(out, indent + INDENT); self.write_expr(out, *item, indent + INDENT, 0); out.push(','); cursor = item_span.end as usize; previous_end = Some(cursor); } self.write_between(out, cursor, end, previous_end, indent + INDENT); write_indent(out, indent); out.push(']'); } fn write_let( &self, out: &mut String, span: Span, bindings: &[Field], body: ExprId, indent: usize, ) { let body_span = self.ast.span(body); let in_token = self .tokens_between(span.start as usize, body_span.start as usize) .rev() .find(|token| token.kind == SyntaxTokenKind::In) .expect("parsed let expressions contain `in`"); let let_token = self .tokens_between(span.start as usize, span.end as usize) .find(|token| token.kind == SyntaxTokenKind::Let) .expect("parsed let expressions contain `let`"); out.push_str("let\n"); self.write_field_list( out, bindings, let_token.span.end as usize, in_token.span.start as usize, indent + INDENT, ); write_indent(out, indent); out.push_str("in"); self.write_between( out, in_token.span.end as usize, body_span.start as usize, Some(in_token.span.end as usize), indent + INDENT, ); write_indent(out, indent + INDENT); self.write_expr(out, body, indent + INDENT, 0); } fn write_function(&self, out: &mut String, params: &[Param], body: ExprId, indent: usize) { out.push('('); for (index, parameter) in params.iter().enumerate() { if index > 0 { out.push_str(", "); } out.push_str(¶meter.name); if let Some(constraint) = parameter.constraint { out.push_str(": "); self.write_expr(out, constraint, indent, 0); } } out.push_str(") =>"); if self.is_inline_expr(body) { out.push(' '); self.write_expr(out, body, indent, 0); } else { out.push('\n'); write_indent(out, indent + INDENT); self.write_expr(out, body, indent + INDENT, 0); } } fn write_match( &self, out: &mut String, span: Span, scrutinee: ExprId, arms: &[MatchArm], indent: usize, ) { out.push_str("match "); self.write_expr(out, scrutinee, indent, 0); let scrutinee_end = self.ast.span(scrutinee).end as usize; let open = self .tokens_between(scrutinee_end, span.end as usize) .find(|token| token.kind == SyntaxTokenKind::LBrace) .expect("parsed match expressions contain `{`"); let close = self .tokens_between(open.span.end as usize, span.end as usize) .rev() .find(|token| token.kind == SyntaxTokenKind::RBrace) .expect("parsed match expressions contain `}`"); let start = open.span.end as usize; let end = close.span.start as usize; if self.has_comment_between(scrutinee_end, open.span.start as usize) { self.write_between( out, scrutinee_end, open.span.start as usize, Some(scrutinee_end), indent, ); write_indent(out, indent); out.push('{'); } else { out.push_str(" {"); } if arms.is_empty() && !self.has_comment_between(start, end) { out.push('}'); return; } out.push('\n'); let mut cursor = start; let mut previous_end = None; for arm in arms { self.write_between( out, cursor, arm.span.start as usize, previous_end, indent + INDENT, ); write_indent(out, indent + INDENT); if self.has_comment_between( self.ast.span(arm.pattern).end as usize, self.ast.span(arm.body).start as usize, ) { out.push_str(self.raw(arm.span)); } else { self.write_expr(out, arm.pattern, indent + INDENT, 0); out.push_str(": "); self.write_expr(out, arm.body, indent + INDENT, 0); } out.push(';'); cursor = arm.span.end as usize; previous_end = Some(cursor); } self.write_between(out, cursor, end, previous_end, indent + INDENT); write_indent(out, indent); out.push('}'); } fn write_between( &self, out: &mut String, start: usize, end: usize, previous_end: Option, indent: usize, ) { let mut cursor = start.min(end); for comment in self.comments_between(start, end) { let comment_start = comment.start as usize; let trailing = previous_end.is_some_and(|previous| { !out.ends_with('\n') && self.same_line(previous, comment_start) }); if trailing { out.push(' '); out.push_str(self.raw(*comment)); out.push('\n'); } else { ensure_line_break(out); if self.has_blank_line(cursor, comment_start) { ensure_blank_line(out); } write_indent(out, indent); out.push_str(self.raw(*comment)); out.push('\n'); } cursor = comment.end as usize; } if previous_end.is_some() { ensure_line_break(out); } if self.has_blank_line(cursor, end) { ensure_blank_line(out); } } fn precedence(&self, id: ExprId) -> u8 { match &self.ast.get(id).expr { Expr::As { .. } => 0, Expr::Default { .. } => 1, Expr::Binary { op, .. } => match op { BinaryOp::Patch => 3, BinaryOp::And => 5, BinaryOp::LogicalOr => 7, BinaryOp::LogicalAnd => 9, BinaryOp::Equal | BinaryOp::NotEqual | BinaryOp::Greater | BinaryOp::GreaterEqual | BinaryOp::Less | BinaryOp::LessEqual => 11, BinaryOp::Concat => 12, BinaryOp::Add | BinaryOp::Sub => 13, BinaryOp::Mul | BinaryOp::Div => 15, }, Expr::Unary { .. } | Expr::CompareConstraint { .. } => 17, Expr::Call { .. } | Expr::Path { .. } => 19, Expr::Parenthesized { .. } => 20, _ => 20, } } fn is_inline_expr(&self, id: ExprId) -> bool { let node = self.ast.get(id); if self.has_comment(node.span) { return false; } match &node.expr { Expr::Literal(_) | Expr::Ident(_) | Expr::RegexConstraint(_) | Expr::Wildcard | Expr::Import(_) | Expr::CompareConstraint { .. } => true, Expr::Path { base, .. } => self.is_inline_expr(*base), Expr::Call { callee, args } => { self.is_inline_expr(*callee) && args.iter().all(|argument| self.is_inline_expr(*argument)) } Expr::Unary { expr, .. } => self.is_inline_expr(*expr), Expr::Binary { lhs, rhs, .. } => self.is_inline_expr(*lhs) && self.is_inline_expr(*rhs), Expr::Default { base, fallback } => { self.is_inline_expr(*base) && self.is_inline_expr(*fallback) } Expr::Array(items) => items.iter().all(|item| self.is_inline_expr(*item)), Expr::ArrayConstraint { item } => self.is_inline_expr(*item), Expr::MapConstraint { value } => self.is_inline_expr(*value), Expr::Parenthesized { expr } => self.is_inline_expr(*expr), Expr::As { narrower, wider } => { self.is_inline_expr(*narrower) && self.is_inline_expr(*wider) } Expr::Object { .. } | Expr::Let { .. } | Expr::Function { .. } | Expr::Match { .. } => { false } } } fn delimited_range( &self, span: Span, open_kind: SyntaxTokenKind, close_kind: SyntaxTokenKind, ) -> (usize, usize) { let open = self .tokens_between(span.start as usize, span.end as usize) .find(|token| same_kind(&token.kind, &open_kind)) .expect("parsed delimited expressions contain an opening token"); let close = self .tokens_between(open.span.end as usize, span.end as usize) .rev() .find(|token| same_kind(&token.kind, &close_kind)) .expect("parsed delimited expressions contain a closing token"); (open.span.end as usize, close.span.start as usize) } fn tokens_between( &self, start: usize, end: usize, ) -> impl DoubleEndedIterator { self.tokens.iter().filter(move |token| { token.span.start as usize >= start && token.span.end as usize <= end }) } fn comments_between(&self, start: usize, end: usize) -> impl Iterator { self.comments .iter() .filter(move |comment| comment.start as usize >= start && comment.end as usize <= end) } fn has_comment(&self, span: Span) -> bool { self.has_comment_between(span.start as usize, span.end as usize) } fn has_comment_between(&self, start: usize, end: usize) -> bool { self.comments_between(start, end).next().is_some() } fn same_line(&self, start: usize, end: usize) -> bool { !self.slice(start, end).contains('\n') } fn has_blank_line(&self, start: usize, end: usize) -> bool { self.slice(start, end) .bytes() .filter(|byte| *byte == b'\n') .count() >= 2 } fn raw(&self, span: Span) -> &'a str { self.slice(span.start as usize, span.end as usize).trim() } fn slice(&self, start: usize, end: usize) -> &'a str { &self.source[start.min(self.source.len())..end.min(self.source.len())] } } fn binary_operator(operator: BinaryOp) -> &'static str { match operator { BinaryOp::Add => "+", BinaryOp::Sub => "-", BinaryOp::Mul => "*", BinaryOp::Div => "/", BinaryOp::Concat => "++", BinaryOp::Equal => "==", BinaryOp::NotEqual => "!=", BinaryOp::Greater => ">", BinaryOp::GreaterEqual => ">=", BinaryOp::Less => "<", BinaryOp::LessEqual => "<=", BinaryOp::LogicalAnd => "&&", BinaryOp::LogicalOr => "||", BinaryOp::And => "&", BinaryOp::Patch => "//", } } fn compare_operator(operator: CompareOp) -> &'static str { match operator { CompareOp::Gt => ">", CompareOp::Gte => ">=", CompareOp::Lt => "<", CompareOp::Lte => "<=", CompareOp::Eq => "==", } } fn same_kind(left: &SyntaxTokenKind, right: &SyntaxTokenKind) -> bool { core::mem::discriminant(left) == core::mem::discriminant(right) } fn write_indent(out: &mut String, indent: usize) { out.extend(core::iter::repeat_n(' ', indent)); } fn ensure_line_break(out: &mut String) { if !out.is_empty() && !out.ends_with('\n') { out.push('\n'); } } fn ensure_blank_line(out: &mut String) { if !out.is_empty() && !out.ends_with("\n\n") { ensure_line_break(out); out.push('\n'); } } fn trim_trailing_whitespace(out: &mut String) { while out.ends_with(char::is_whitespace) { out.pop(); } } #[cfg(test)] mod tests { use super::*; #[test] fn formats_fields_and_preserves_comments() { let source = "Server={\n# host\nhost=String default \"localhost\"; # trailing\n\nport=Int&>0;\n};"; let formatted = format_source(source).unwrap(); assert_eq!( formatted, "Server = {\n # host\n host = String default \"localhost\"; # trailing\n\n port = Int & > 0;\n};\n" ); } #[test] fn formats_arrays_and_match_comments() { let source = "result=match x{# c\n_: [1,{a=2;}];};"; let formatted = format_source(source).unwrap(); assert_eq!( formatted, "result = match x {\n # c\n _: [\n 1,\n {\n a = 2;\n },\n ];\n};\n" ); } #[test] fn formats_array_constraints() { let source = "tags=[...String];ports=[...(Int&>=1)];"; let formatted = format_source(source).unwrap(); assert_eq!( formatted, "tags = [...String];\nports = [...(Int & >= 1)];\n" ); } #[test] fn formats_map_constraints_and_range_ascription() { let source = "config={service={port=8080;};}as{service={port=Int;};};labels={a=1;b=2;}as{...Int};"; let formatted = format_source(source).unwrap(); assert_eq!( formatted, "config = {\n service = {\n port = 8080;\n };\n} as {\n service = {\n port = Int;\n };\n};\nlabels = {\n a = 1;\n b = 2;\n} as {...Int};\n" ); assert_eq!(format_source(&formatted).unwrap(), formatted); } #[test] fn formats_let_functions_and_precedence_from_the_canonical_ast() { let source = "value=let x=1+2*3;in(a:Int)=>{result=(x+a)*2;};"; let formatted = format_source(source).unwrap(); assert_eq!( formatted, "value = let\n x = 1 + 2 * 3;\nin\n (a: Int) =>\n {\n result = (x + a) * 2;\n };\n" ); } #[test] fn formats_unknown_and_object_rest_constraints() { let source = "value={hoge=Int;fuga=String;# remaining fields\n...# unconstrained\nUnknown};"; let formatted = format_source(source).unwrap(); assert_eq!( formatted, "value = {\n hoge = Int;\n fuga = String; # remaining fields\n ... # unconstrained\n Unknown\n};\n" ); } #[test] fn preserves_regex_and_escaped_strings() { let source = r#"value={pattern=/^api\/.+$/;text="a\n\"b";};"#; let formatted = format_source(source).unwrap(); assert!(formatted.contains(r#"pattern = /^api\/.+$/;"#)); assert!(formatted.contains(r#"text = "a\n\"b";"#)); } #[test] fn preserves_comments_inside_grouping_and_between_structural_tokens() { let source = "value = (# grouped\n 1 + 2); result = match x # before brace\n { _ # before body\n : value; };"; let formatted = format_source(source).unwrap(); assert!(formatted.contains("(# grouped\n 1 + 2)")); assert!(formatted.contains("match x # before brace\n{")); assert!(formatted.contains("_ # before body\n : value;")); assert_eq!(format_source(&formatted).unwrap(), formatted); } #[test] fn wasm_export_returns_the_same_formatter_result() { let source = "value={a=1;};"; let expected = format_source(source).unwrap(); let output: serde_json::Value = serde_json::from_str(&format_source_json(source)).unwrap(); assert_eq!(output["source"], expected); } #[test] fn formats_all_repository_examples_idempotently() { let examples = [ ( "advanced/main", include_str!("../../../examples/advanced/main.dcdl"), ), ( "advanced/profiles", include_str!("../../../examples/advanced/profiles.dcdl"), ), ( "advanced/schema", include_str!("../../../examples/advanced/schema.dcdl"), ), ( "arithmetic", include_str!("../../../examples/arithmetic.dcdl"), ), ( "array-concat", include_str!("../../../examples/array-concat.dcdl"), ), ( "array-constraint", include_str!("../../../examples/array-constraint.dcdl"), ), ("basic", include_str!("../../../examples/basic.dcdl")), ( "import/main", include_str!("../../../examples/import/main.dcdl"), ), ( "import/schema", include_str!("../../../examples/import/schema.dcdl"), ), ("logical", include_str!("../../../examples/logical.dcdl")), ( "map-ascription", include_str!("../../../examples/map-ascription.dcdl"), ), ( "regex/main", include_str!("../../../examples/regex/main.dcdl"), ), ]; for (name, source) in examples { let once = format_source(source).unwrap_or_else(|error| panic!("{name}: {error}")); let twice = format_source(&once).unwrap_or_else(|error| { panic!("{name} reformatted to invalid source: {error}\n{once}") }); assert_eq!(once, twice, "{name}"); } } }