use crate::parsing::ast::{
expression_precedence, AsLemmaSource, Constraint, DataValue, Expression, ExpressionKind,
LemmaData, LemmaRule, LemmaSpec,
};
use crate::{parse, Error, ParseResult, ResourceLimits};
pub const MAX_COLS: usize = 56;
#[must_use]
pub fn format_specs(specs: &[LemmaSpec]) -> String {
let mut out = String::new();
for (index, spec) in specs.iter().enumerate() {
if index > 0 {
out.push_str("\n\n");
}
out.push_str(&format_spec(spec, MAX_COLS));
}
if !out.ends_with('\n') {
out.push('\n');
}
out
}
#[must_use]
pub fn format_parse_result(result: &ParseResult) -> String {
let mut blocks: Vec<String> = Vec::new();
for (repo, specs) in &result.repositories {
let mut prefix = String::new();
if let Some(name) = repo.name.as_deref() {
prefix.push_str("repo ");
prefix.push_str(name);
prefix.push_str("\n\n");
}
if specs.is_empty() {
if !prefix.is_empty() {
blocks.push(prefix);
}
continue;
}
let body = format_specs(specs.as_slice());
if prefix.is_empty() {
blocks.push(body);
} else {
prefix.push_str(&body);
blocks.push(prefix);
}
}
let mut out = blocks.join("\n\n");
if !out.ends_with('\n') {
out.push('\n');
}
out
}
pub fn format_source(
source: &str,
source_type: crate::parsing::source::SourceType,
) -> Result<String, Error> {
let limits = ResourceLimits::default();
let result = parse(source, source_type, &limits)?;
Ok(format_parse_result(&result))
}
pub(crate) fn format_spec(spec: &LemmaSpec, max_cols: usize) -> String {
let mut out = String::new();
out.push_str("spec ");
out.push_str(&spec.name);
if let crate::parsing::ast::EffectiveDate::DateTimeValue(ref af) = spec.effective_from {
out.push(' ');
out.push_str(&af.to_string());
}
out.push('\n');
if let Some(ref commentary) = spec.commentary {
out.push_str("\"\"\"\n");
out.push_str(commentary);
out.push_str("\n\"\"\"\n");
}
for meta in &spec.meta_fields {
out.push_str(&format!(
"meta {}: {}\n",
meta.key,
AsLemmaSource(&meta.value)
));
}
if !spec.data.is_empty() {
format_sorted_data(&spec.data, &mut out, "");
}
if !spec.rules.is_empty() {
out.push('\n');
for (index, rule) in spec.rules.iter().enumerate() {
if index > 0 {
out.push('\n');
}
let rule_text = format_rule(rule, max_cols);
for line in rule_text.lines() {
out.push_str(line);
out.push('\n');
}
}
}
out
}
const DATA_CONSTRAINT_INDENT: &str = " ";
fn data_constraints_nonempty(constraints: &Option<Vec<Constraint>>) -> bool {
constraints.as_ref().is_some_and(|v| !v.is_empty())
}
fn data_value_has_arrow_constraints(value: &DataValue) -> bool {
match value {
DataValue::Definition { constraints, .. } | DataValue::Reference { constraints, .. } => {
data_constraints_nonempty(constraints)
}
_ => false,
}
}
fn data_value_rhs_for_spec_body(value: &DataValue, continuation_prefix: &str) -> String {
match value {
DataValue::Definition {
base,
constraints,
from,
value,
} if data_constraints_nonempty(constraints) => {
let cs = constraints
.as_ref()
.expect("BUG: constraints checked above");
let head: String = if base.is_none() && from.is_none() {
match value {
Some(v) => format!("{}", AsLemmaSource(v)),
None => String::new(),
}
} else {
match (base.as_ref(), from.as_ref()) {
(Some(b), Some(spec)) => format!("{} from {}", b, spec),
(Some(b), None) => format!("{}", b),
(None, Some(spec)) => format!("<type> from {}", spec),
(None, None) => String::new(),
}
};
let mut out = head;
for (cmd, args) in cs {
out.push('\n');
out.push_str(continuation_prefix);
out.push_str("-> ");
out.push_str(&crate::parsing::ast::format_constraint_as_source(cmd, args));
}
out
}
DataValue::Reference {
target,
constraints,
} if data_constraints_nonempty(constraints) => {
let cs = constraints
.as_ref()
.expect("BUG: constraints checked above");
let mut out = target.to_string();
for (cmd, args) in cs {
out.push('\n');
out.push_str(continuation_prefix);
out.push_str("-> ");
out.push_str(&crate::parsing::ast::format_constraint_as_source(cmd, args));
}
out
}
_ => format!("{}", AsLemmaSource(value)),
}
}
fn format_data(data: &LemmaData, line_prefix: &str) -> String {
let ref_str = format!("{}", data.reference);
let continuation = format!("{line_prefix}{DATA_CONSTRAINT_INDENT}");
let rhs = data_value_rhs_for_spec_body(&data.value, &continuation);
if let Some((first, rest)) = rhs.split_once('\n') {
format!("data {}: {}\n{}", ref_str, first, rest)
} else {
format!("data {}: {}", ref_str, rhs)
}
}
fn data_line_prefix_len_before_rhs(ref_str: &str) -> usize {
5 + ref_str.len() + 2
}
fn data_is_simple_single_line(data: &LemmaData, line_prefix: &str) -> bool {
if data_value_has_arrow_constraints(&data.value) {
return false;
}
let continuation = format!("{line_prefix}{DATA_CONSTRAINT_INDENT}");
let rhs = data_value_rhs_for_spec_body(&data.value, &continuation);
!rhs.contains('\n')
}
fn push_formatted_simple_data_line_padded(
out: &mut String,
data: &LemmaData,
line_prefix: &str,
target_prefix_len_before_rhs: usize,
) {
let ref_str = format!("{}", data.reference);
let continuation = format!("{line_prefix}{DATA_CONSTRAINT_INDENT}");
let rhs = data_value_rhs_for_spec_body(&data.value, &continuation);
let base = data_line_prefix_len_before_rhs(&ref_str);
let gap = 1 + target_prefix_len_before_rhs.saturating_sub(base);
out.push_str(line_prefix);
out.push_str("data ");
out.push_str(&ref_str);
out.push(':');
out.push_str(&" ".repeat(gap));
out.push_str(&rhs);
}
fn emit_data_row_group(rows: &[&LemmaData], line_prefix: &str, out: &mut String) {
let mut i = 0;
while i < rows.len() {
if data_is_simple_single_line(rows[i], line_prefix) {
let run_start = i;
i += 1;
while i < rows.len() && data_is_simple_single_line(rows[i], line_prefix) {
i += 1;
}
let run_end = i;
let target = (run_start..run_end)
.map(|k| data_line_prefix_len_before_rhs(&format!("{}", rows[k].reference)))
.max()
.expect("BUG: non-empty run");
for row in rows[run_start..run_end].iter().copied() {
push_formatted_simple_data_line_padded(out, row, line_prefix, target);
out.push('\n');
}
} else {
let row = rows[i];
out.push_str(line_prefix);
out.push_str(&format_data(row, line_prefix));
out.push('\n');
if data_value_has_arrow_constraints(&row.value) && i + 1 < rows.len() {
out.push('\n');
}
i += 1;
}
}
}
fn format_import_row(data: &LemmaData) -> String {
let alias = &data.reference.name;
if let DataValue::Import(spec_ref) = &data.value {
let spec_name = &spec_ref.name;
let last_segment = spec_name.rsplit('/').next().unwrap_or(spec_name);
if alias == last_segment {
format!("uses {}", spec_ref)
} else {
format!("uses {}: {}", alias, spec_ref)
}
} else {
unreachable!("BUG: format_import_row called on non-Import data")
}
}
fn format_sorted_data(data: &[LemmaData], out: &mut String, line_prefix: &str) {
let mut regular: Vec<&LemmaData> = Vec::new();
let mut imports: Vec<&LemmaData> = Vec::new();
let mut overrides: Vec<&LemmaData> = Vec::new();
for data in data {
if !data.reference.is_local() {
overrides.push(data);
} else if matches!(&data.value, DataValue::Import(_)) {
imports.push(data);
} else {
regular.push(data);
}
}
let emit_group =
|rows: &[&LemmaData], out: &mut String| emit_data_row_group(rows, line_prefix, out);
if !imports.is_empty() {
out.push('\n');
let has_overrides = |row: &LemmaData| -> bool {
let ref_name = &row.reference.name;
overrides.iter().any(|o| {
o.reference.segments.first().map(|s| s.as_str()) == Some(ref_name.as_str())
})
};
let is_bare = |row: &LemmaData| -> bool {
if let DataValue::Import(sr) = &row.value {
let last = sr.name.rsplit('/').next().unwrap_or(&sr.name);
row.reference.name == last && sr.effective.is_none() && !has_overrides(row)
} else {
false
}
};
let mut i = 0;
while i < imports.len() {
if i > 0 {
out.push('\n');
}
if is_bare(imports[i]) {
let mut group_names = Vec::new();
while i < imports.len() && is_bare(imports[i]) {
if let DataValue::Import(sr) = &imports[i].value {
group_names.push(sr.to_string());
}
i += 1;
}
if group_names.len() == 1 {
out.push_str(line_prefix);
out.push_str(&format!("uses {}", group_names[0]));
} else {
out.push_str(line_prefix);
out.push_str(&format!("uses {}", group_names.join(", ")));
}
out.push('\n');
} else {
let row = imports[i];
out.push_str(line_prefix);
out.push_str(&format_import_row(row));
out.push('\n');
let ref_name = &row.reference.name;
let binding_overrides: Vec<&LemmaData> = overrides
.iter()
.filter(|o| {
o.reference.segments.first().map(|s| s.as_str()) == Some(ref_name.as_str())
})
.copied()
.collect();
if !binding_overrides.is_empty() {
emit_data_row_group(&binding_overrides, line_prefix, out);
}
i += 1;
}
}
}
if !regular.is_empty() {
out.push('\n');
emit_group(®ular, out);
}
let matched_prefixes: Vec<&str> = imports.iter().map(|f| f.reference.name.as_str()).collect();
let unmatched: Vec<&LemmaData> = overrides
.iter()
.filter(|o| {
o.reference
.segments
.first()
.map(|s| !matched_prefixes.contains(&s.as_str()))
.unwrap_or(true)
})
.copied()
.collect();
if !unmatched.is_empty() {
out.push('\n');
emit_group(&unmatched, out);
}
}
const UNLESS_LINE_PREFIX: &str = " unless ";
#[inline]
fn spec_line_len(line: &str) -> usize {
line.len()
}
fn format_rule(rule: &LemmaRule, max_cols: usize) -> String {
let expr_indent = " ";
let body = format_expr_wrapped(&rule.expression, max_cols, expr_indent, 10);
let mut out = String::new();
out.push_str("rule ");
out.push_str(&rule.name);
let body_single_line = !body.contains('\n');
let header_fits_on_one_line =
body_single_line && spec_line_len(&format!("rule {}: {}", rule.name, body)) <= max_cols;
if header_fits_on_one_line {
out.push_str(": ");
out.push_str(&body);
} else {
out.push_str(":\n");
out.push_str(expr_indent);
out.push_str(&body);
}
let pl = UNLESS_LINE_PREFIX.len();
let naive_single_len = |cond: &str, res: &str| pl + cond.len() + 6 + res.len();
let aligned_single_len = |res: &str, max_end: usize| max_end + 6 + res.len();
let mut clauses: Vec<(String, String, bool)> = Vec::new();
for unless_clause in &rule.unless_clauses {
let condition = format_expr_wrapped(&unless_clause.condition, max_cols, " ", 10);
let result = format_expr_wrapped(&unless_clause.result, max_cols, " ", 10);
let multiline = condition.contains('\n') || result.contains('\n');
clauses.push((condition, result, multiline));
}
let mut singles: Vec<usize> = clauses
.iter()
.enumerate()
.filter(|(_, (c, r, m))| !*m && naive_single_len(c, r) <= max_cols)
.map(|(i, _)| i)
.collect();
loop {
if singles.is_empty() {
break;
}
let max_end = singles
.iter()
.map(|&i| pl + clauses[i].0.len())
.max()
.expect("BUG: singles non-empty");
let before = singles.len();
singles.retain(|&i| aligned_single_len(&clauses[i].1, max_end) <= max_cols);
if singles.len() == before {
break;
}
}
let align_max_end = singles.iter().map(|&i| pl + clauses[i].0.len()).max();
const SPLIT_THEN_INDENT_SPACES: usize = 4;
for (i, (condition, result, multiline)) in clauses.iter().enumerate() {
if *multiline {
out.push_str("\n unless ");
out.push_str(condition);
out.push('\n');
out.push_str(&" ".repeat(SPLIT_THEN_INDENT_SPACES));
out.push_str("then ");
out.push_str(result);
continue;
}
if singles.contains(&i) {
let max_end = align_max_end.expect("BUG: singles.contains but align_max_end empty");
let gap = 1 + max_end.saturating_sub(pl + condition.len());
out.push('\n');
out.push_str(UNLESS_LINE_PREFIX);
out.push_str(condition);
out.push_str(&" ".repeat(gap));
out.push_str("then ");
out.push_str(result);
continue;
}
out.push_str("\n unless ");
out.push_str(condition);
out.push('\n');
out.push_str(&" ".repeat(SPLIT_THEN_INDENT_SPACES));
out.push_str("then ");
out.push_str(result);
}
out.push('\n');
out
}
fn indent_after_first_line(s: &str, indent: &str) -> String {
let mut first = true;
let mut out = String::new();
for line in s.lines() {
if first {
first = false;
out.push_str(line);
} else {
out.push('\n');
out.push_str(indent);
out.push_str(line);
}
}
if s.ends_with('\n') {
out.push('\n');
}
out
}
fn format_expr_wrapped(
expr: &Expression,
max_cols: usize,
indent: &str,
parent_prec: u8,
) -> String {
let my_prec = expression_precedence(&expr.kind);
let wrap_in_parens = |s: String| {
if parent_prec < 10 && my_prec < parent_prec {
format!("({})", s)
} else {
s
}
};
match &expr.kind {
ExpressionKind::Arithmetic(left, op, right) => {
let left_str = format_expr_wrapped(left.as_ref(), max_cols, indent, my_prec);
let right_str = format_expr_wrapped(right.as_ref(), max_cols, indent, my_prec);
let single_line = format!("{} {} {}", left_str, op, right_str);
if single_line.len() <= max_cols && !single_line.contains('\n') {
return wrap_in_parens(single_line);
}
let continued_right = indent_after_first_line(&right_str, indent);
let continuation = format!("{}{} {}", indent, op, continued_right);
let multi_line = format!("{}\n{}", left_str, continuation);
wrap_in_parens(multi_line)
}
_ => {
let s = expr.to_string();
wrap_in_parens(s)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parsing::ast::{
AsLemmaSource, BooleanValue, DateTimeValue, DurationUnit, TimeValue, TimezoneValue, Value,
};
use rust_decimal::prelude::FromStr;
use rust_decimal::Decimal;
fn fmt_value(v: &Value) -> String {
format!("{}", AsLemmaSource(v))
}
#[test]
fn test_format_value_text_is_quoted() {
let v = Value::Text("light".to_string());
assert_eq!(fmt_value(&v), "\"light\"");
}
#[test]
fn test_format_value_text_escapes_quotes() {
let v = Value::Text("say \"hello\"".to_string());
assert_eq!(fmt_value(&v), "\"say \\\"hello\\\"\"");
}
#[test]
fn test_format_value_number() {
let v = Value::Number(Decimal::from_str("42.50").unwrap());
assert_eq!(fmt_value(&v), "42.50");
}
#[test]
fn test_format_value_number_integer() {
let v = Value::Number(Decimal::from_str("100.00").unwrap());
assert_eq!(fmt_value(&v), "100");
}
#[test]
fn test_format_value_boolean() {
assert_eq!(fmt_value(&Value::Boolean(BooleanValue::True)), "true");
assert_eq!(fmt_value(&Value::Boolean(BooleanValue::Yes)), "yes");
assert_eq!(fmt_value(&Value::Boolean(BooleanValue::No)), "no");
assert_eq!(fmt_value(&Value::Boolean(BooleanValue::Accept)), "accept");
assert_eq!(fmt_value(&Value::Boolean(BooleanValue::Reject)), "reject");
}
#[test]
fn test_format_value_scale() {
let v = Value::Scale(Decimal::from_str("99.50").unwrap(), "eur".to_string());
assert_eq!(fmt_value(&v), "99.50 eur");
}
#[test]
fn test_format_value_duration() {
let v = Value::Duration(Decimal::from(40), DurationUnit::Hour);
assert_eq!(fmt_value(&v), "40 hours");
}
#[test]
fn test_format_value_ratio_percent() {
let v = Value::Ratio(
Decimal::from_str("0.10").unwrap(),
Some("percent".to_string()),
);
assert_eq!(fmt_value(&v), "10%");
}
#[test]
fn test_format_value_ratio_permille() {
let v = Value::Ratio(
Decimal::from_str("0.005").unwrap(),
Some("permille".to_string()),
);
assert_eq!(fmt_value(&v), "5%%");
}
#[test]
fn test_format_value_ratio_bare() {
let v = Value::Ratio(Decimal::from_str("0.25").unwrap(), None);
assert_eq!(fmt_value(&v), "0.25");
}
#[test]
fn test_format_value_date_only() {
let v = Value::Date(DateTimeValue {
year: 2024,
month: 1,
day: 15,
hour: 0,
minute: 0,
second: 0,
microsecond: 0,
timezone: None,
});
assert_eq!(fmt_value(&v), "2024-01-15");
}
#[test]
fn test_format_value_datetime_with_tz() {
let v = Value::Date(DateTimeValue {
year: 2024,
month: 1,
day: 15,
hour: 14,
minute: 30,
second: 0,
microsecond: 0,
timezone: Some(TimezoneValue {
offset_hours: 0,
offset_minutes: 0,
}),
});
assert_eq!(fmt_value(&v), "2024-01-15T14:30:00Z");
}
#[test]
fn test_format_value_time() {
let v = Value::Time(TimeValue {
hour: 14,
minute: 30,
second: 45,
timezone: None,
});
assert_eq!(fmt_value(&v), "14:30:45");
}
#[test]
fn test_format_source_round_trips_text() {
let source = r#"spec test
data name: "Alice"
rule greeting: "hello"
"#;
let formatted =
format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
assert!(formatted.contains("\"Alice\""), "data text must be quoted");
assert!(formatted.contains("\"hello\""), "rule text must be quoted");
}
#[test]
fn test_format_source_preserves_percent() {
let source = r#"spec test
data rate: 10 percent
rule tax: rate * 21%
"#;
let formatted =
format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
assert!(
formatted.contains("10%"),
"data percent must use shorthand %, got: {}",
formatted
);
}
#[test]
fn test_format_groups_data_preserving_order() {
let source = r#"spec test
data income: number -> minimum 0
data filing_status: filing_status_type -> default "single"
data country: "NL"
data deductions: number -> minimum 0
data name: text
rule total: income
"#;
let formatted =
format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
let data_section = formatted
.split("rule total")
.next()
.unwrap()
.split("spec test\n")
.nth(1)
.unwrap();
let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(lines[0], "data income: number");
assert_eq!(lines[1], " -> minimum 0");
assert_eq!(lines[2], "data filing_status: filing_status_type");
assert_eq!(lines[3], " -> default \"single\"");
assert_eq!(lines[4], "data country: \"NL\"");
assert_eq!(lines[5], "data deductions: number");
assert_eq!(lines[6], " -> minimum 0");
assert_eq!(lines[7], "data name: text");
}
#[test]
fn test_format_groups_spec_refs_with_overrides() {
let source = r#"spec test
data retail.quantity: 5
uses order wholesale
uses order retail
data wholesale.quantity: 100
data base_price: 50
rule total: base_price
"#;
let formatted =
format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
let data_section = formatted
.split("rule total")
.next()
.unwrap()
.split("spec test\n")
.nth(1)
.unwrap();
let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(lines[0], "uses order wholesale");
assert_eq!(lines[1], "data wholesale.quantity: 100");
assert_eq!(lines[2], "uses order retail");
assert_eq!(lines[3], "data retail.quantity: 5");
assert_eq!(lines[4], "data base_price: 50");
}
#[test]
fn test_format_source_weather_clothing_text_quoted() {
let source = r#"spec weather_clothing
data clothing_style: text
-> option "light"
-> option "warm"
data temperature: number
rule clothing_layer: "light"
unless temperature < 5 then "warm"
"#;
let formatted =
format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
assert!(
formatted.contains("\"light\""),
"text in rule must be quoted, got: {}",
formatted
);
assert!(
formatted.contains("\"warm\""),
"text in unless must be quoted, got: {}",
formatted
);
}
#[test]
fn test_format_text_option_round_trips() {
let source = r#"spec test
data status: text
-> option "active"
-> option "inactive"
data s: status
rule out: s
"#;
let formatted =
format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
assert!(
formatted.contains("option \"active\""),
"text option must be quoted, got: {}",
formatted
);
assert!(
formatted.contains("option \"inactive\""),
"text option must be quoted, got: {}",
formatted
);
let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
assert!(reparsed.is_ok(), "formatted output should re-parse");
}
#[test]
fn test_format_help_round_trips() {
let source = r#"spec test
data quantity: number -> help "Number of items to order"
rule total: quantity
"#;
let formatted =
format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
assert!(
formatted.contains("help \"Number of items to order\""),
"help must be quoted, got: {}",
formatted
);
let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
assert!(reparsed.is_ok(), "formatted output should re-parse");
}
#[test]
fn test_format_scale_type_def_round_trips() {
let source = r#"spec test
data money: scale
-> unit eur 1.00
-> unit usd 1.10
-> decimals 2
-> minimum 0
data price: money
rule total: price
"#;
let formatted =
format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
assert!(
formatted.contains("unit eur 1.00"),
"scale unit should not be quoted, got: {}",
formatted
);
let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
assert!(
reparsed.is_ok(),
"formatted output should re-parse, got: {:?}",
reparsed
);
}
#[test]
fn test_format_expression_display_stable_round_trip() {
let source = r#"spec test
data a: 1.00
rule r: a + 2.00 * 3
"#;
let formatted =
format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
let again =
format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
assert_eq!(
formatted, again,
"AST Display-based format must be idempotent under parse/format"
);
}
#[test]
fn test_format_rule_default_on_same_line_when_fits() {
let source = "spec test\nrule r: 1\n";
let formatted =
format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
assert!(
formatted.contains("rule r: 1\n"),
"default expr should stay on rule line when under MAX_COLS, got:\n{formatted}"
);
}
#[test]
fn test_format_rule_unless_single_line_when_short() {
let source = r#"spec test
data a: number
data b: boolean
rule r: no
unless a < 1 then yes
unless b then yes
"#;
let formatted =
format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
assert!(
formatted.contains("unless a < 1 then yes")
&& formatted.contains("unless b then yes"),
"unless stays on one line when under MAX_COLS, got:\n{formatted}"
);
}
}