Add Decodal formatter
This commit is contained in:
@@ -6,7 +6,7 @@ use std::{
|
||||
|
||||
use decodal::{
|
||||
Data, Diagnostic, DiagnosticKind, Engine, LoadedSource, SourceId, SourceLoader, Span,
|
||||
format_diagnostic_with,
|
||||
format_diagnostic_with, format_source_with_source_id,
|
||||
};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
@@ -38,6 +38,17 @@ fn run() -> Result<(), String> {
|
||||
println!("ok");
|
||||
Ok(())
|
||||
}
|
||||
"fmt" | "format" => {
|
||||
let next = args.next().unwrap_or_else(|| String::from("-"));
|
||||
if next == "--write" || next == "-w" {
|
||||
let path = args
|
||||
.next()
|
||||
.ok_or_else(|| String::from("missing file path for `decodal fmt --write`"))?;
|
||||
format_path(&path, true)
|
||||
} else {
|
||||
format_path(&next, false)
|
||||
}
|
||||
}
|
||||
"eval" | "materialize" => {
|
||||
let path = args.next().unwrap_or_else(|| String::from("-"));
|
||||
let data = materialize_path(&path)?;
|
||||
@@ -54,6 +65,21 @@ fn run() -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_path(path: &str, write: bool) -> Result<(), String> {
|
||||
if write && path == "-" {
|
||||
return Err(String::from("cannot use --write with stdin"));
|
||||
}
|
||||
let root = read_root_source(path).map_err(format_raw_diagnostic)?;
|
||||
let formatted = format_source_with_source_id(SourceId(0), &root.source)
|
||||
.map_err(|error| format_diagnostic_with_root(&error, &root.name))?;
|
||||
if write {
|
||||
fs::write(path, formatted).map_err(|error| format!("failed to write `{path}`: {error}"))?;
|
||||
} else {
|
||||
print!("{formatted}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn materialize_path(path: &str) -> Result<Data, String> {
|
||||
let root = read_root_source(path).map_err(format_raw_diagnostic)?;
|
||||
let root_name = root.name.clone();
|
||||
@@ -143,10 +169,11 @@ fn print_help() {
|
||||
println!("Decodal - Deferred Constraint Data Language");
|
||||
println!();
|
||||
println!("Usage:");
|
||||
println!(" decodal <file.dcdl> Materialize a DCDL file");
|
||||
println!(" decodal eval <file.dcdl> Materialize a DCDL file");
|
||||
println!(" decodal check <file.dcdl> Evaluate and materialize without printing data");
|
||||
println!(" decodal - Read DCDL source from stdin");
|
||||
println!(" decodal <file.dcdl> Materialize a DCDL file");
|
||||
println!(" decodal eval <file.dcdl> Materialize a DCDL file");
|
||||
println!(" decodal check <file.dcdl> Evaluate and materialize without printing data");
|
||||
println!(" decodal fmt [--write] <file.dcdl> Format a DCDL file");
|
||||
println!(" decodal - Read DCDL source from stdin");
|
||||
}
|
||||
|
||||
fn format_diagnostic_with_root(error: &Diagnostic, root_name: &str) -> String {
|
||||
|
||||
@@ -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(¶m.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");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use decodal::{
|
||||
Data, Diagnostic, DiagnosticKind, EmptyLoader, Engine, LoadedSource, SourceId, SourceLoader,
|
||||
Span, format_diagnostic_with,
|
||||
Span, format_diagnostic_with, format_source,
|
||||
};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
@@ -11,6 +11,17 @@ pub fn evaluate(source: &str) -> String {
|
||||
encode_result(evaluate_inner(source))
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = formatSource)]
|
||||
pub fn format_source_json(source: &str) -> String {
|
||||
match format_source(source) {
|
||||
Ok(formatted) => format!("{{\"ok\":true,\"source\":{}}}", json_string(&formatted)),
|
||||
Err(error) => format!(
|
||||
"{{\"ok\":false,\"error\":{}}}",
|
||||
json_string(&format_diagnostic_with_root(&error, "playground"))
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = evaluateProject)]
|
||||
pub fn evaluate_project(entry: &str, files_json: &str) -> String {
|
||||
encode_result(evaluate_project_inner(entry, files_json))
|
||||
@@ -214,6 +225,13 @@ fn json_string(value: &str) -> String {
|
||||
mod tests {
|
||||
use super::{evaluate_project_inner, normalize_path, resolve_import};
|
||||
|
||||
#[test]
|
||||
fn formats_source_for_web() {
|
||||
let output = super::format_source_json("value={a=1;};");
|
||||
assert!(output.contains("\"ok\":true"));
|
||||
assert!(output.contains("value = {\\n a = 1;\\n};\\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_virtual_paths() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user