Add Decodal formatter

This commit is contained in:
2026-07-09 17:39:32 +09:00
parent 16006d000a
commit 22f6bc1ab9
16 changed files with 606 additions and 24 deletions
+442
View File
@@ -0,0 +1,442 @@
use alloc::string::{String, ToString};
use core::fmt::Write;
use crate::{
Ast, Diagnostic, Expr, ExprId, Field, Literal, Param, SourceForm, Span,
ast::{BinaryOp, CompareOp, MatchArm, UnaryOp},
diagnostic::Result,
parse_source_with_source_id,
span::SourceId,
};
const INDENT: usize = 4;
pub fn format_source(source: &str) -> Result<String> {
format_source_with_source_id(SourceId(0), source)
}
pub fn format_source_with_source_id(source_id: SourceId, source: &str) -> Result<String> {
reject_comments(source_id, source)?;
let parsed = parse_source_with_source_id(source_id, source)?;
Ok(Formatter::new(&parsed.ast).format_root(parsed.root, parsed.source_form))
}
struct Formatter<'a> {
ast: &'a Ast,
}
impl<'a> Formatter<'a> {
fn new(ast: &'a Ast) -> Self {
Self { ast }
}
fn format_root(&self, root: ExprId, source_form: SourceForm) -> String {
let mut out = String::new();
match source_form {
SourceForm::Fields => {
if let Expr::Object(fields) = &self.ast.get(root).expr {
self.write_fields(&mut out, fields, 0);
} else {
self.write_expr(&mut out, root, 0, 0);
}
}
SourceForm::Expr => self.write_expr(&mut out, root, 0, 0),
}
if !out.ends_with('\n') {
out.push('\n');
}
out
}
fn write_expr(&self, out: &mut String, id: ExprId, indent: usize, parent_prec: u8) {
let expr = &self.ast.get(id).expr;
let prec = expr_prec(expr);
let paren = prec < parent_prec;
if paren {
out.push('(');
}
match expr {
Expr::Literal(literal) => self.write_literal(out, literal),
Expr::Ident(name) => out.push_str(name),
Expr::Object(fields) => self.write_object(out, fields, indent),
Expr::Array(items) => self.write_array(out, items, indent),
Expr::Let { bindings, body } => self.write_let(out, bindings, *body, indent),
Expr::Import(path) => {
out.push_str("import ");
write_quoted(out, path);
}
Expr::Path { base, field } => {
self.write_expr(out, *base, indent, prec);
out.push('.');
out.push_str(field);
}
Expr::Call { callee, args } => {
self.write_expr(out, *callee, indent, prec);
out.push('(');
for (index, arg) in args.iter().enumerate() {
if index > 0 {
out.push_str(", ");
}
self.write_expr(out, *arg, indent, 0);
}
out.push(')');
}
Expr::Function { params, body } => self.write_function(out, params, *body, indent),
Expr::Match { scrutinee, arms } => self.write_match(out, *scrutinee, arms, indent),
Expr::Unary { op, expr } => {
out.push_str(match op {
UnaryOp::Neg => "-",
UnaryOp::Not => "!",
});
self.write_expr(out, *expr, indent, prec);
}
Expr::Binary { op, lhs, rhs } => {
self.write_expr(out, *lhs, indent, prec);
out.push(' ');
out.push_str(binary_op_text(*op));
out.push(' ');
self.write_expr(out, *rhs, indent, prec + 1);
}
Expr::Default { base, fallback } => {
self.write_expr(out, *base, indent, prec);
out.push_str(" default ");
self.write_expr(out, *fallback, indent, prec + 1);
}
Expr::CompareConstraint { op, value } => {
out.push_str(compare_op_text(*op));
out.push(' ');
self.write_expr(out, *value, indent, prec);
}
Expr::RegexConstraint(pattern) => {
out.push('/');
out.push_str(pattern);
out.push('/');
}
Expr::Wildcard => out.push('_'),
}
if paren {
out.push(')');
}
}
fn write_literal(&self, out: &mut String, literal: &Literal) {
match literal {
Literal::String(value) => write_quoted(out, value),
Literal::Int(value) => {
let _ = write!(out, "{value}");
}
Literal::Float(value) => {
let text = value.to_string();
out.push_str(&text);
if !text.contains('.') && !text.contains('e') && !text.contains('E') {
out.push_str(".0");
}
}
Literal::Bool(value) => out.push_str(if *value { "true" } else { "false" }),
}
}
fn write_object(&self, out: &mut String, fields: &[Field], indent: usize) {
if fields.is_empty() {
out.push_str("{}");
return;
}
out.push('{');
out.push('\n');
self.write_fields(out, fields, indent + INDENT);
write_indent(out, indent);
out.push('}');
}
fn write_fields(&self, out: &mut String, fields: &[Field], indent: usize) {
for field in fields {
write_indent(out, indent);
self.write_field(out, field, indent);
out.push_str(";\n");
}
}
fn write_field(&self, out: &mut String, field: &Field, indent: usize) {
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_array(&self, out: &mut String, items: &[ExprId], indent: usize) {
if items.is_empty() {
out.push_str("[]");
return;
}
if items.iter().all(|item| self.is_inline(*item)) {
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('[');
out.push('\n');
for item in items {
write_indent(out, indent + INDENT);
self.write_expr(out, *item, indent + INDENT, 0);
out.push_str(",\n");
}
write_indent(out, indent);
out.push(']');
}
fn write_let(&self, out: &mut String, bindings: &[Field], body: ExprId, indent: usize) {
out.push_str("let");
out.push('\n');
self.write_fields(out, bindings, indent + INDENT);
write_indent(out, indent);
out.push_str("in");
out.push('\n');
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, param) in params.iter().enumerate() {
if index > 0 {
out.push_str(", ");
}
self.write_param(out, param, indent);
}
out.push(')');
out.push_str(" =>");
if self.is_inline(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_param(&self, out: &mut String, param: &Param, indent: usize) {
out.push_str(&param.name);
if let Some(constraint) = param.constraint {
out.push_str(": ");
self.write_expr(out, constraint, indent, 0);
}
}
fn write_match(&self, out: &mut String, scrutinee: ExprId, arms: &[MatchArm], indent: usize) {
out.push_str("match ");
self.write_expr(out, scrutinee, indent, 0);
out.push_str(" {");
if !arms.is_empty() {
out.push('\n');
for arm in arms {
write_indent(out, indent + INDENT);
self.write_expr(out, arm.pattern, indent + INDENT, 0);
out.push_str(": ");
self.write_expr(out, arm.body, indent + INDENT, 0);
out.push_str(";\n");
}
write_indent(out, indent);
}
out.push('}');
}
fn is_inline(&self, id: ExprId) -> bool {
match &self.ast.get(id).expr {
Expr::Literal(_)
| Expr::Ident(_)
| Expr::Import(_)
| Expr::RegexConstraint(_)
| Expr::Wildcard => true,
Expr::Path { base, .. } => self.is_inline(*base),
Expr::Call { callee, args } => {
self.is_inline(*callee) && args.iter().all(|arg| self.is_inline(*arg))
}
Expr::Unary { expr, .. } => self.is_inline(*expr),
Expr::Binary { lhs, rhs, .. } => self.is_inline(*lhs) && self.is_inline(*rhs),
Expr::Default { base, fallback } => self.is_inline(*base) && self.is_inline(*fallback),
Expr::CompareConstraint { value, .. } => self.is_inline(*value),
Expr::Array(items) => items.iter().all(|item| self.is_inline(*item)),
Expr::Object(fields) => fields.is_empty(),
Expr::Let { .. } | Expr::Function { .. } | Expr::Match { .. } => false,
}
}
}
fn expr_prec(expr: &Expr) -> u8 {
match expr {
Expr::Default { .. } => 1,
Expr::Binary { op, .. } => binary_prec(*op),
Expr::Unary { .. } | Expr::CompareConstraint { .. } => 17,
Expr::Path { .. } | Expr::Call { .. } => 19,
_ => 20,
}
}
fn binary_prec(op: BinaryOp) -> u8 {
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,
}
}
fn binary_op_text(op: BinaryOp) -> &'static str {
match op {
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_op_text(op: CompareOp) -> &'static str {
match op {
CompareOp::Gt => ">",
CompareOp::Gte => ">=",
CompareOp::Lt => "<",
CompareOp::Lte => "<=",
CompareOp::Eq => "==",
}
}
fn write_indent(out: &mut String, indent: usize) {
for _ in 0..indent {
out.push(' ');
}
}
fn write_quoted(out: &mut String, value: &str) {
out.push('"');
for ch in value.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
ch if ch.is_control() => {
let _ = write!(out, "\\u{:04x}", ch as u32);
}
ch => out.push(ch),
}
}
out.push('"');
}
fn reject_comments(source_id: SourceId, source: &str) -> Result<()> {
if let Some(offset) = comment_offset(source) {
return Err(Diagnostic::syntax(
Span::new(source_id, offset, offset + 1),
"formatter does not yet preserve comments",
));
}
Ok(())
}
fn comment_offset(source: &str) -> Option<usize> {
let bytes = source.as_bytes();
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
b'"' => {
index += 1;
while index < bytes.len() {
match bytes[index] {
b'\\' => index += 2,
b'"' => {
index += 1;
break;
}
_ => index += 1,
}
}
}
b'#' => return Some(index),
_ => index += 1,
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parse_source;
#[test]
fn formats_top_level_fields() {
let formatted = format_source("port=Int&>=1 default 8080;name=String;").unwrap();
assert_eq!(
formatted,
"port = Int & >= 1 default 8080;\nname = String;\n"
);
parse_source(&formatted).unwrap();
}
#[test]
fn formats_nested_objects() {
let formatted =
format_source("Server={host=String default \"localhost\";port=Int&>0;};").unwrap();
assert_eq!(
formatted,
"Server = {\n host = String default \"localhost\";\n port = Int & > 0;\n};\n"
);
parse_source(&formatted).unwrap();
}
#[test]
fn formats_let_function_match_and_array() {
let source = "let f=(x:Int)=>match x{_: [1,{a=2;}];};in f(1)";
let formatted = format_source(source).unwrap();
assert_eq!(
formatted,
"let\n f = (x: Int) =>\n match x {\n _: [\n 1,\n {\n a = 2;\n },\n ];\n };\nin\n f(1)\n"
);
parse_source(&formatted).unwrap();
}
#[test]
fn preserves_needed_parentheses() {
let formatted = format_source("value=(1+2)*3;").unwrap();
assert_eq!(formatted, "value = (1 + 2) * 3;\n");
parse_source(&formatted).unwrap();
}
#[test]
fn rejects_comments_instead_of_dropping_them() {
let error = format_source("# keep me\nvalue = 1;").unwrap_err();
assert_eq!(error.message, "formatter does not yet preserve comments");
}
}
+2
View File
@@ -7,6 +7,7 @@ pub mod constraints;
pub mod diagnostic;
pub mod embedding;
pub mod eval;
pub mod formatter;
mod lexer;
pub mod module;
pub mod parser;
@@ -21,6 +22,7 @@ pub use decodal_derive::Decodal;
pub use diagnostic::{Diagnostic, DiagnosticKind, Result};
pub use embedding::{HostField, HostValue};
pub use eval::{Engine, format_diagnostic_with};
pub use formatter::{format_source, format_source_with_source_id};
pub use module::{EmptyLoader, LoadedSource, Module, SourceLoader};
pub use parser::{ParseOutput, Parser, SourceForm, parse_source, parse_source_with_source_id};
pub use runtime::{Constraint, Data, ExprRef, LiteralValue, ModuleId, PrimitiveType, RuntimeValue};