Skip to main content

lemma/formatting/
mod.rs

1//! Lemma source code formatting.
2//!
3//! Formats parsed specs into canonical Lemma source text. Uses `AsLemmaSource`
4//! and `Expression::Display` for syntax; this module handles layout only.
5//! Canonical source includes ASCII-lowercase logical identifier names.
6
7use crate::parsing::ast::{
8    arithmetic_associativity, expression_precedence, operand_needs_parentheses, AsLemmaSource,
9    Associativity, Constraint, DataValue, Expression, ExpressionKind, LemmaData, LemmaRule,
10    LemmaSpec, OperandSide,
11};
12use crate::parsing::{parse, ParseResult};
13use crate::{Error, ResourceLimits};
14
15/// Soft line length limit. Longer lines may be wrapped (unless clauses, expressions).
16/// Data and other constructs are not broken if they exceed this.
17/// 56 has been chosen to fit on an average mobile screen with an 11pt font.
18pub const MAX_COLS: usize = 56;
19
20// =============================================================================
21// Public entry points
22// =============================================================================
23
24/// Format a sequence of parsed specs into canonical Lemma source.
25///
26/// specs are separated by two blank lines.
27/// The result ends with a single newline.
28#[must_use]
29pub fn format_specs(specs: &[LemmaSpec]) -> String {
30    let refs: Vec<&LemmaSpec> = specs.iter().collect();
31    format_spec_refs(&refs)
32}
33
34/// Like [`format_specs`] for borrowed specs (e.g. from Context storage).
35#[must_use]
36pub fn format_spec_refs(specs: &[&LemmaSpec]) -> String {
37    let mut out = String::new();
38    for (index, spec) in specs.iter().enumerate() {
39        if index > 0 {
40            out.push_str("\n\n");
41        }
42        out.push_str(&format_spec(spec, MAX_COLS));
43    }
44    if !out.ends_with('\n') {
45        out.push('\n');
46    }
47    out
48}
49
50/// Format a [`ParseResult`] (repository groups + specs) into canonical Lemma source.
51#[must_use]
52pub fn format_parse_result(result: &ParseResult) -> String {
53    let mut blocks: Vec<String> = Vec::new();
54    for (repo, specs) in &result.repositories {
55        let mut prefix = String::new();
56        if let Some(name) = repo.name.as_deref() {
57            prefix.push_str("repo ");
58            prefix.push_str(name);
59            prefix.push_str("\n\n");
60        }
61        if specs.is_empty() {
62            if !prefix.is_empty() {
63                blocks.push(prefix);
64            }
65            continue;
66        }
67        let body = format_specs(specs.as_slice());
68        if prefix.is_empty() {
69            blocks.push(body);
70        } else {
71            prefix.push_str(&body);
72            blocks.push(prefix);
73        }
74    }
75    let mut out = blocks.join("\n\n");
76    if !out.ends_with('\n') {
77        out.push('\n');
78    }
79    out
80}
81
82/// Parse a source string and format it to canonical Lemma source.
83///
84/// Returns an error if the source does not parse.
85pub fn format_source(
86    source: &str,
87    source_type: crate::parsing::source::SourceType,
88) -> Result<String, Error> {
89    let limits = ResourceLimits::default();
90    let result = parse(source, source_type, &limits)?;
91    Ok(format_parse_result(&result))
92}
93
94// =============================================================================
95// Spec
96// =============================================================================
97
98pub(crate) fn format_spec(spec: &LemmaSpec, max_cols: usize) -> String {
99    let mut out = String::new();
100    out.push_str("spec ");
101    out.push_str(&spec.name);
102    if let crate::parsing::ast::EffectiveDate::DateTimeValue(ref af) = spec.effective_from {
103        out.push(' ');
104        out.push_str(&af.to_string());
105    }
106    out.push('\n');
107
108    if let Some(ref commentary) = spec.commentary {
109        out.push_str("\"\"\"\n");
110        out.push_str(commentary);
111        out.push_str("\n\"\"\"\n");
112    }
113
114    for meta in &spec.meta_fields {
115        out.push_str(&format!(
116            "meta {}: {}\n",
117            meta.key,
118            AsLemmaSource(&meta.value)
119        ));
120    }
121
122    if !spec.data.is_empty() {
123        format_sorted_data(&spec.data, &mut out, "");
124    }
125
126    if !spec.rules.is_empty() {
127        if !spec.data.is_empty() {
128            out.push_str("\n\n");
129        } else {
130            out.push('\n');
131        }
132        for (index, rule) in spec.rules.iter().enumerate() {
133            if index > 0 {
134                out.push('\n');
135            }
136            let rule_text = format_rule(rule, max_cols);
137            for line in rule_text.lines() {
138                out.push_str(line);
139                out.push('\n');
140            }
141        }
142    }
143
144    out
145}
146
147// =============================================================================
148// Data
149// =============================================================================
150
151/// Two spaces after `line_prefix` for each `-> ...` constraint line under `data ...: ...`.
152const DATA_CONSTRAINT_INDENT: &str = "  ";
153
154fn data_constraints_nonempty(constraints: &Option<Vec<Constraint>>) -> bool {
155    constraints.as_ref().is_some_and(|v| !v.is_empty())
156}
157
158fn data_value_has_arrow_constraints(value: &DataValue) -> bool {
159    match value {
160        DataValue::Definition { constraints, .. } => data_constraints_nonempty(constraints),
161        DataValue::Import { .. } => false,
162    }
163}
164
165fn format_with_rhs(rhs: &crate::parsing::ast::WithRhs) -> String {
166    match rhs {
167        crate::parsing::ast::WithRhs::Literal(v) => format!("{}", AsLemmaSource(v)),
168        crate::parsing::ast::WithRhs::Reference { target } => target.to_string(),
169    }
170}
171
172fn data_value_rhs_for_spec_body(value: &DataValue, continuation_prefix: &str) -> String {
173    match value {
174        DataValue::Definition {
175            base,
176            constraints,
177            value,
178        } if data_constraints_nonempty(constraints) => {
179            let cs = constraints
180                .as_ref()
181                .expect("BUG: constraints checked above");
182            let head: String = if base.is_none() {
183                match value {
184                    Some(v) => format!("{}", AsLemmaSource(v)),
185                    None => String::new(),
186                }
187            } else {
188                match base.as_ref() {
189                    Some(b) => format!("{}", b),
190                    None => String::new(),
191                }
192            };
193            let mut out = head;
194            for row in cs {
195                out.push('\n');
196                out.push_str(continuation_prefix);
197                out.push_str("-> ");
198                out.push_str(&crate::parsing::ast::format_constraint_as_source(
199                    &row.command,
200                    &row.args,
201                ));
202            }
203            out
204        }
205        DataValue::Definition { .. } => format!("{}", AsLemmaSource(value)),
206        DataValue::Import { .. } => unreachable!("BUG: format_data called on Import row"),
207    }
208}
209
210fn data_declaration_keyword(data: &LemmaData) -> &'static str {
211    match &data.value {
212        DataValue::Import { .. } => unreachable!("BUG: format_data called on Import row"),
213        DataValue::Definition { .. } => "data",
214    }
215}
216
217fn format_data(data: &LemmaData, line_prefix: &str) -> String {
218    let kw = data_declaration_keyword(data);
219    let ref_str = format!("{}", data.reference);
220    let continuation = format!("{line_prefix}{DATA_CONSTRAINT_INDENT}");
221    let rhs = data_value_rhs_for_spec_body(&data.value, &continuation);
222    if let Some((first, rest)) = rhs.split_once('\n') {
223        format!("{kw} {}: {}\n{}", ref_str, first, rest)
224    } else {
225        format!("{kw} {}: {}", ref_str, rhs)
226    }
227}
228
229/// Byte length from start of `data ` or `with ` through the single space after `:` (same layout as [`format_data`]).
230fn data_line_prefix_len_before_rhs(keyword: &str, ref_str: &str) -> usize {
231    keyword.len() + 1 + ref_str.len() + 2
232}
233
234fn data_is_simple_single_line(data: &LemmaData, line_prefix: &str) -> bool {
235    if data_value_has_arrow_constraints(&data.value) {
236        return false;
237    }
238    let continuation = format!("{line_prefix}{DATA_CONSTRAINT_INDENT}");
239    let rhs = data_value_rhs_for_spec_body(&data.value, &continuation);
240    !rhs.contains('\n')
241}
242
243fn push_formatted_simple_data_line_padded(
244    out: &mut String,
245    data: &LemmaData,
246    line_prefix: &str,
247    target_prefix_len_before_rhs: usize,
248) {
249    let kw = data_declaration_keyword(data);
250    let ref_str = format!("{}", data.reference);
251    let continuation = format!("{line_prefix}{DATA_CONSTRAINT_INDENT}");
252    let rhs = data_value_rhs_for_spec_body(&data.value, &continuation);
253    let base = data_line_prefix_len_before_rhs(kw, &ref_str);
254    let gap = 1 + target_prefix_len_before_rhs.saturating_sub(base);
255    out.push_str(line_prefix);
256    out.push_str(kw);
257    out.push(' ');
258    out.push_str(&ref_str);
259    out.push(':');
260    out.push_str(&" ".repeat(gap));
261    out.push_str(&rhs);
262}
263
264fn emit_data_row_group(rows: &[&LemmaData], line_prefix: &str, out: &mut String) {
265    let mut i = 0;
266    while i < rows.len() {
267        if data_is_simple_single_line(rows[i], line_prefix) {
268            let run_start = i;
269            i += 1;
270            while i < rows.len() && data_is_simple_single_line(rows[i], line_prefix) {
271                i += 1;
272            }
273            let run_end = i;
274            let target = (run_start..run_end)
275                .map(|k| {
276                    let row = rows[k];
277                    let kw = data_declaration_keyword(row);
278                    let ref_str = format!("{}", row.reference);
279                    data_line_prefix_len_before_rhs(kw, &ref_str)
280                })
281                .max()
282                .expect("BUG: non-empty run");
283            for row in rows[run_start..run_end].iter().copied() {
284                push_formatted_simple_data_line_padded(out, row, line_prefix, target);
285                out.push('\n');
286            }
287        } else {
288            let row = rows[i];
289            out.push_str(line_prefix);
290            out.push_str(&format_data(row, line_prefix));
291            out.push('\n');
292            if data_value_has_arrow_constraints(&row.value) && i + 1 < rows.len() {
293                out.push('\n');
294            }
295            i += 1;
296        }
297    }
298}
299
300fn format_import_header(data: &LemmaData) -> String {
301    let alias = &data.reference.name;
302    let DataValue::Import { spec_ref, .. } = &data.value else {
303        unreachable!("BUG: format_import_header called on non-Import data");
304    };
305    let spec_name = &spec_ref.name;
306    let last_segment = spec_name.rsplit('/').next().unwrap_or(spec_name);
307    if alias == last_segment {
308        format!("uses {}", spec_ref)
309    } else {
310        format!("uses {}: {}", alias, spec_ref)
311    }
312}
313
314fn format_uses_block(data: &LemmaData, line_prefix: &str) -> String {
315    let mut out = format_import_header(data);
316    let DataValue::Import { bindings, .. } = &data.value else {
317        unreachable!("BUG: format_uses_block called on non-Import data");
318    };
319    for binding in bindings {
320        out.push('\n');
321        out.push_str(line_prefix);
322        out.push_str(DATA_CONSTRAINT_INDENT);
323        out.push_str("-> ");
324        out.push_str(&crate::parsing::ast::format_assignment_continuation(
325            "with",
326            &format!("{}", binding.path),
327            &format_with_rhs(&binding.rhs),
328        ));
329    }
330    out
331}
332
333/// Group data into sections separated by blank lines:
334///
335/// 1. Imports (`uses`) with `-> with` bindings — declaration order
336/// 2. Regular local `data` — declaration order
337fn format_sorted_data(data: &[LemmaData], out: &mut String, line_prefix: &str) {
338    let mut regular: Vec<&LemmaData> = Vec::new();
339    let mut imports: Vec<&LemmaData> = Vec::new();
340
341    for data in data {
342        if matches!(&data.value, DataValue::Import { .. }) {
343            imports.push(data);
344        } else {
345            regular.push(data);
346        }
347    }
348
349    let emit_group =
350        |rows: &[&LemmaData], out: &mut String| emit_data_row_group(rows, line_prefix, out);
351
352    if !imports.is_empty() {
353        out.push('\n');
354
355        for (i, row) in imports.iter().enumerate() {
356            if i > 0 {
357                out.push('\n');
358            }
359            out.push_str(line_prefix);
360            out.push_str(&format_uses_block(row, line_prefix));
361            out.push('\n');
362        }
363    }
364
365    if !regular.is_empty() {
366        out.push('\n');
367        emit_group(&regular, out);
368    }
369}
370
371// =============================================================================
372// Rules
373// =============================================================================
374
375const UNLESS_LINE_PREFIX: &str = "  unless ";
376
377/// Rule body always starts on the line after `rule name:`.
378///
379/// When every `unless` clause fits on one line under `max_cols`, `then` columns align across
380/// sisters. If any clause needs split `then` (wrapped condition, wrapped result, or oversize
381/// flat line), every clause on the rule uses split `then` at a fixed 4-space indent.
382fn format_rule(rule: &LemmaRule, max_cols: usize) -> String {
383    let expr_indent = "  ";
384    let body = format_expr_wrapped(&rule.expression, max_cols, expr_indent, 10);
385    let mut out = String::new();
386    out.push_str("rule ");
387    out.push_str(&rule.name);
388    out.push_str(":\n");
389    out.push_str(expr_indent);
390    out.push_str(&body);
391
392    let pl = UNLESS_LINE_PREFIX.len();
393    let naive_single_len = |cond: &str, res: &str| pl + cond.len() + 6 + res.len();
394    let aligned_single_len = |res: &str, max_end: usize| max_end + 6 + res.len();
395    let unless_condition_budget = max_cols.saturating_sub(pl);
396
397    let mut clauses: Vec<(String, String)> = Vec::new();
398    for unless_clause in &rule.unless_clauses {
399        let condition = format_expr_wrapped(
400            &unless_clause.condition,
401            unless_condition_budget,
402            "    ",
403            10,
404        );
405        let result = format_expr_wrapped(&unless_clause.result, max_cols, "    ", 10);
406        clauses.push((condition, result));
407    }
408
409    let clause_needs_split_then = |condition: &str, result: &str| {
410        condition.contains('\n')
411            || result.contains('\n')
412            || naive_single_len(condition, result) > max_cols
413    };
414
415    let any_split = clauses.iter().any(|(c, r)| clause_needs_split_then(c, r));
416
417    const SPLIT_THEN_INDENT_SPACES: usize = 4;
418
419    if any_split {
420        for (condition, result) in &clauses {
421            out.push_str("\n  unless ");
422            out.push_str(condition);
423            out.push('\n');
424            out.push_str(&" ".repeat(SPLIT_THEN_INDENT_SPACES));
425            out.push_str("then ");
426            out.push_str(result);
427        }
428    } else {
429        let mut singles: Vec<usize> = clauses
430            .iter()
431            .enumerate()
432            .filter(|(_, (c, r))| naive_single_len(c, r) <= max_cols)
433            .map(|(i, _)| i)
434            .collect();
435
436        loop {
437            if singles.is_empty() {
438                break;
439            }
440            let max_end = singles
441                .iter()
442                .map(|&i| pl + clauses[i].0.len())
443                .max()
444                .expect("BUG: singles non-empty");
445            let before = singles.len();
446            singles.retain(|&i| aligned_single_len(&clauses[i].1, max_end) <= max_cols);
447            if singles.len() == before {
448                break;
449            }
450        }
451
452        let align_max_end = singles.iter().map(|&i| pl + clauses[i].0.len()).max();
453
454        for (i, (condition, result)) in clauses.iter().enumerate() {
455            if singles.contains(&i) {
456                let max_end = align_max_end.expect("BUG: singles.contains but align_max_end empty");
457                let gap = 1 + max_end.saturating_sub(pl + condition.len());
458                out.push('\n');
459                out.push_str(UNLESS_LINE_PREFIX);
460                out.push_str(condition);
461                out.push_str(&" ".repeat(gap));
462                out.push_str("then ");
463                out.push_str(result);
464            } else {
465                out.push_str("\n  unless ");
466                out.push_str(condition);
467                out.push('\n');
468                out.push_str(&" ".repeat(SPLIT_THEN_INDENT_SPACES));
469                out.push_str("then ");
470                out.push_str(result);
471            }
472        }
473    }
474    out.push('\n');
475    out
476}
477
478// =============================================================================
479// Expression wrapping (soft line breaking at max_cols)
480// =============================================================================
481
482/// Indent every line after the first by `indent`.
483fn indent_after_first_line(s: &str, indent: &str) -> String {
484    let mut first = true;
485    let mut out = String::new();
486    for line in s.lines() {
487        if first {
488            first = false;
489            out.push_str(line);
490        } else {
491            out.push('\n');
492            out.push_str(indent);
493            out.push_str(line);
494        }
495    }
496    if s.ends_with('\n') {
497        out.push('\n');
498    }
499    out
500}
501
502struct BinaryWrapContext<'a> {
503    max_cols: usize,
504    indent: &'a str,
505    parent_prec: u8,
506    my_prec: u8,
507    assoc: Option<Associativity>,
508}
509
510fn format_binary_expr_wrapped(
511    left: &Expression,
512    op: &str,
513    right: &Expression,
514    ctx: BinaryWrapContext<'_>,
515) -> String {
516    let BinaryWrapContext {
517        max_cols,
518        indent,
519        parent_prec,
520        my_prec,
521        assoc,
522    } = ctx;
523    let left_inner = format_expr_wrapped(left, max_cols, indent, 10);
524    let right_inner = format_expr_wrapped(right, max_cols, indent, 10);
525    let left_str = if operand_needs_parentheses(
526        expression_precedence(&left.kind),
527        my_prec,
528        OperandSide::Left,
529        assoc,
530    ) {
531        format!("({})", left_inner)
532    } else {
533        left_inner
534    };
535    let right_str = if operand_needs_parentheses(
536        expression_precedence(&right.kind),
537        my_prec,
538        OperandSide::Right,
539        assoc,
540    ) {
541        format!("({})", right_inner)
542    } else {
543        right_inner
544    };
545    let single_line = format!("{} {} {}", left_str, op, right_str);
546    let body = if single_line.len() <= max_cols && !single_line.contains('\n') {
547        single_line
548    } else {
549        let continued_right = indent_after_first_line(&right_str, indent);
550        let continuation = format!("{}{} {}", indent, op, continued_right);
551        format!("{}\n{}", left_str, continuation)
552    };
553    if parent_prec < 10 && operand_needs_parentheses(my_prec, parent_prec, OperandSide::Left, None)
554    {
555        format!("({})", body)
556    } else {
557        body
558    }
559}
560
561/// Format an expression with optional wrapping at arithmetic and `and` operators when over max_cols.
562///
563/// Binary children use the same parenthesis policy as [`Expression`] display
564/// ([`operand_needs_parentheses`]). Pass `10` for top-level (no outer wrap).
565fn format_expr_wrapped(
566    expr: &Expression,
567    max_cols: usize,
568    indent: &str,
569    parent_prec: u8,
570) -> String {
571    let my_prec = expression_precedence(&expr.kind);
572
573    match &expr.kind {
574        ExpressionKind::Arithmetic(left, op, right) => format_binary_expr_wrapped(
575            left,
576            &op.to_string(),
577            right,
578            BinaryWrapContext {
579                max_cols,
580                indent,
581                parent_prec,
582                my_prec,
583                assoc: Some(arithmetic_associativity(op)),
584            },
585        ),
586        ExpressionKind::LogicalAnd(left, right) => format_binary_expr_wrapped(
587            left,
588            "and",
589            right,
590            BinaryWrapContext {
591                max_cols,
592                indent,
593                parent_prec,
594                my_prec,
595                assoc: Some(Associativity::Left),
596            },
597        ),
598        _ => {
599            let s = expr.to_string();
600            if parent_prec < 10
601                && operand_needs_parentheses(my_prec, parent_prec, OperandSide::Left, None)
602            {
603                format!("({})", s)
604            } else {
605                s
606            }
607        }
608    }
609}
610
611// =============================================================================
612// Tests
613// =============================================================================
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use crate::literals::DateGranularity;
619    use crate::parsing::ast::{
620        AsLemmaSource, BooleanValue, DateTimeValue, TimeValue, TimezoneValue, Value,
621    };
622    use rust_decimal::prelude::FromStr;
623    use rust_decimal::Decimal;
624
625    /// Helper: format a Value as canonical Lemma source via AsLemmaSource.
626    fn fmt_value(v: &Value) -> String {
627        format!("{}", AsLemmaSource(v))
628    }
629
630    #[test]
631    fn test_format_value_text_is_quoted() {
632        let v = Value::Text("light".to_string());
633        assert_eq!(fmt_value(&v), "\"light\"");
634    }
635
636    #[test]
637    fn test_format_value_text_escapes_quotes() {
638        let v = Value::Text("say \"hello\"".to_string());
639        assert_eq!(fmt_value(&v), "\"say \\\"hello\\\"\"");
640    }
641
642    #[test]
643    fn test_format_value_number() {
644        let v = Value::Number(Decimal::from_str("42.50").unwrap());
645        assert_eq!(fmt_value(&v), "42.50");
646    }
647
648    #[test]
649    fn test_format_value_number_integer() {
650        let v = Value::Number(Decimal::from_str("100.00").unwrap());
651        assert_eq!(fmt_value(&v), "100");
652    }
653
654    #[test]
655    fn test_format_value_boolean() {
656        assert_eq!(fmt_value(&Value::Boolean(BooleanValue::True)), "true");
657        assert_eq!(fmt_value(&Value::Boolean(BooleanValue::Yes)), "yes");
658        assert_eq!(fmt_value(&Value::Boolean(BooleanValue::No)), "no");
659    }
660
661    #[test]
662    fn test_format_value_measure() {
663        let v = Value::NumberWithUnit(Decimal::from_str("99.50").unwrap(), "eur".to_string());
664        assert_eq!(fmt_value(&v), "99.50 eur");
665    }
666
667    #[test]
668    fn test_format_value_duration_as_measure() {
669        let v = Value::NumberWithUnit(Decimal::from(40), "hour".to_string());
670        assert_eq!(fmt_value(&v), "40 hour");
671    }
672
673    #[test]
674    fn test_format_value_calendar() {
675        let v = Value::NumberWithUnit(Decimal::from(6), "month".to_string());
676        assert_eq!(fmt_value(&v), "6 month");
677    }
678
679    #[test]
680    fn test_format_value_ratio_percent() {
681        let v = Value::NumberWithUnit(Decimal::from_str("10").unwrap(), "percent".to_string());
682        assert_eq!(fmt_value(&v), "10%");
683    }
684
685    #[test]
686    fn test_format_value_ratio_permille() {
687        let v = Value::NumberWithUnit(Decimal::from_str("5").unwrap(), "permille".to_string());
688        assert_eq!(fmt_value(&v), "5%%");
689    }
690
691    #[test]
692    fn test_format_value_number_with_unit_named() {
693        let v = Value::NumberWithUnit(
694            Decimal::from_str("500").unwrap(),
695            "basis_points".to_string(),
696        );
697        assert_eq!(fmt_value(&v), "500 basis_points");
698    }
699
700    #[test]
701    fn test_format_value_date_only() {
702        let v = Value::Date(DateTimeValue {
703            year: 2024,
704            month: 1,
705            day: 15,
706            hour: 0,
707            minute: 0,
708            second: 0,
709            microsecond: 0,
710            timezone: None,
711
712            granularity: DateGranularity::Full,
713        });
714        assert_eq!(fmt_value(&v), "2024-01-15");
715    }
716
717    #[test]
718    fn test_format_value_datetime_with_tz() {
719        let v = Value::Date(DateTimeValue {
720            year: 2024,
721            month: 1,
722            day: 15,
723            hour: 14,
724            minute: 30,
725            second: 0,
726            microsecond: 0,
727            timezone: Some(TimezoneValue {
728                offset_hours: 0,
729                offset_minutes: 0,
730            }),
731
732            granularity: DateGranularity::DateTime,
733        });
734        assert_eq!(fmt_value(&v), "2024-01-15T14:30:00Z");
735    }
736
737    #[test]
738    fn test_format_value_time() {
739        let v = Value::Time(TimeValue {
740            hour: 14,
741            minute: 30,
742            second: 45,
743            microsecond: 0,
744            timezone: None,
745        });
746        assert_eq!(fmt_value(&v), "14:30:45");
747    }
748
749    #[test]
750    fn test_format_source_preserves_date_granularity() {
751        let formatted = format_source(
752            "spec x 2026\n",
753            crate::parsing::source::SourceType::Volatile,
754        )
755        .expect("spec x 2026 should format");
756        assert!(
757            formatted.contains("spec x 2026\n"),
758            "year-only effective date must round-trip, got: {formatted}"
759        );
760        assert!(
761            !formatted.contains("2026-01-01"),
762            "year-only effective date must not expand, got: {formatted}"
763        );
764        let reformatted = format_source(&formatted, crate::parsing::source::SourceType::Volatile)
765            .expect("reformat");
766        assert_eq!(formatted, reformatted, "spec x 2026 must be idempotent");
767
768        let formatted = format_source(
769            "spec x 2026-03\n",
770            crate::parsing::source::SourceType::Volatile,
771        )
772        .expect("spec x 2026-03 should format");
773        assert!(
774            formatted.contains("spec x 2026-03\n"),
775            "year-month effective date must round-trip, got: {formatted}"
776        );
777
778        let formatted = format_source(
779            "spec x 2026-W34\n",
780            crate::parsing::source::SourceType::Volatile,
781        )
782        .expect("spec x 2026-W34 should format");
783        assert!(
784            formatted.contains("spec x 2026-W34\n"),
785            "iso week effective date must round-trip, got: {formatted}"
786        );
787
788        let source = "spec consumer\nuses finance 2026\n";
789        let formatted = format_source(source, crate::parsing::source::SourceType::Volatile)
790            .expect("uses with year should format");
791        assert!(
792            formatted.contains("uses finance 2026"),
793            "uses effective pin must preserve year-only date, got: {formatted}"
794        );
795        assert!(
796            !formatted.contains("2026-01-01"),
797            "uses effective pin must not expand year-only date, got: {formatted}"
798        );
799    }
800
801    #[test]
802    fn test_format_source_lowercases_logical_identifiers() {
803        let source = r#"spec Test
804data Price: number -> suggest 1
805rule Total: price
806"#;
807        let formatted =
808            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
809        assert!(formatted.contains("spec test"), "got: {formatted}");
810        assert!(formatted.contains("data price"), "got: {formatted}");
811        assert!(formatted.contains("rule total"), "got: {formatted}");
812    }
813
814    #[test]
815    fn test_format_source_round_trips_text() {
816        let source = r#"spec test
817
818data name: "Alice"
819
820rule greeting: "hello"
821"#;
822        let formatted =
823            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
824        assert!(formatted.contains("\"Alice\""), "data text must be quoted");
825        assert!(formatted.contains("\"hello\""), "rule text must be quoted");
826    }
827
828    #[test]
829    fn test_format_source_preserves_percent() {
830        let source = r#"spec test
831
832data rate: 10 percent
833
834rule tax: rate * 21%
835"#;
836        let formatted =
837            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
838        assert!(
839            formatted.contains("10%"),
840            "data percent must use shorthand %, got: {}",
841            formatted
842        );
843    }
844
845    #[test]
846    fn test_format_groups_data_preserving_order() {
847        // Data are deliberately mixed: the formatter keeps all regular data together
848        // in original order, aligned
849        let source = r#"spec test
850
851data income: number -> minimum 0
852data filing_status: filing_status_type -> suggest "single"
853data country: "NL"
854data deductions: number -> minimum 0
855data name: text
856
857rule total: income
858"#;
859        let formatted =
860            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
861        let data_section = formatted
862            .split("rule total")
863            .next()
864            .unwrap()
865            .split("spec test\n")
866            .nth(1)
867            .unwrap();
868        let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
869        // Constrained rows: one blank line after each when more `data` follows.
870        assert_eq!(lines[0], "data income: number");
871        assert_eq!(lines[1], "  -> minimum 0");
872        assert_eq!(lines[2], "data filing_status: filing_status_type");
873        assert_eq!(lines[3], "  -> suggest \"single\"");
874        assert_eq!(lines[4], "data country: \"NL\"");
875        assert_eq!(lines[5], "data deductions: number");
876        assert_eq!(lines[6], "  -> minimum 0");
877        assert_eq!(lines[7], "data name: text");
878    }
879
880    #[test]
881    fn test_format_groups_spec_refs_with_overrides() {
882        let source = r#"spec test
883
884uses order wholesale
885  -> with quantity: 100
886uses order retail
887  -> with quantity: 5
888data base_price: 50
889
890rule total: base_price
891"#;
892        let formatted =
893            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
894        let data_section = formatted
895            .split("rule total")
896            .next()
897            .unwrap()
898            .split("spec test\n")
899            .nth(1)
900            .unwrap();
901        let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
902        assert_eq!(lines[0], "uses order wholesale");
903        assert_eq!(lines[1], "  -> with quantity: 100");
904        assert_eq!(lines[2], "uses order retail");
905        assert_eq!(lines[3], "  -> with quantity: 5");
906        assert_eq!(lines[4], "data base_price: 50");
907    }
908
909    #[test]
910    fn test_format_groups_with_literals_under_each_uses() {
911        let source = r#"spec test
912
913uses x
914  -> with name: "Ben"
915uses y
916  -> with age: 15
917
918rule r: 1
919"#;
920        let formatted =
921            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
922        let data_section = formatted
923            .split("rule r")
924            .next()
925            .unwrap()
926            .split("spec test\n")
927            .nth(1)
928            .unwrap();
929        let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
930        assert_eq!(lines[0], "uses x");
931        assert_eq!(lines[1], "  -> with name: \"Ben\"");
932        assert_eq!(lines[2], "uses y");
933        assert_eq!(lines[3], "  -> with age: 15");
934    }
935
936    #[test]
937    fn test_format_source_weather_clothing_text_quoted() {
938        let source = r#"spec weather_clothing
939
940data clothing_style: text
941  -> option "light"
942  -> option "warm"
943
944data temperature: number
945
946rule clothing_layer: "light"
947  unless temperature < 5 then "warm"
948"#;
949        let formatted =
950            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
951        assert!(
952            formatted.contains("\"light\""),
953            "text in rule must be quoted, got: {}",
954            formatted
955        );
956        assert!(
957            formatted.contains("\"warm\""),
958            "text in unless must be quoted, got: {}",
959            formatted
960        );
961    }
962
963    // NOTE: Default value type validation (e.g. rejecting "10 $$" as a number
964    // default) is tested at the planning level in engine.rs, not here. The
965    // formatter only parses — it does not validate types. Planning catches
966    // invalid defaults for both primitives and named types.
967
968    #[test]
969    fn test_format_text_option_round_trips() {
970        let source = r#"spec test
971
972data status: text
973  -> option "active"
974  -> option "inactive"
975
976data s: status
977
978rule out: s
979"#;
980        let formatted =
981            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
982        assert!(
983            formatted.contains("option \"active\""),
984            "text option must be quoted, got: {}",
985            formatted
986        );
987        assert!(
988            formatted.contains("option \"inactive\""),
989            "text option must be quoted, got: {}",
990            formatted
991        );
992        // Round-trip
993        let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
994        assert!(reparsed.is_ok(), "formatted output should re-parse");
995    }
996
997    #[test]
998    fn test_format_help_round_trips() {
999        let source = r#"spec test
1000data quantity: number -> help "Number of items to order"
1001rule total: quantity
1002"#;
1003        let formatted =
1004            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1005        assert!(
1006            formatted.contains("help \"Number of items to order\""),
1007            "help must be quoted, got: {}",
1008            formatted
1009        );
1010        // Round-trip
1011        let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
1012        assert!(reparsed.is_ok(), "formatted output should re-parse");
1013    }
1014
1015    #[test]
1016    fn test_format_measure_type_def_round_trips() {
1017        let source = r#"spec test
1018
1019data money: measure
1020  -> unit eur: 1.00
1021  -> unit usd: 0.91
1022  -> decimals 2
1023  -> minimum 0
1024
1025data price: money
1026
1027rule total: price
1028"#;
1029        let formatted =
1030            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1031        assert!(
1032            formatted.contains("unit eur: 1.00"),
1033            "measure unit should not be quoted, got: {}",
1034            formatted
1035        );
1036        // Round-trip
1037        let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
1038        assert!(
1039            reparsed.is_ok(),
1040            "formatted output should re-parse, got: {:?}",
1041            reparsed
1042        );
1043    }
1044
1045    #[test]
1046    fn format_deprecated_unit_space_emits_assignment_colon() {
1047        let source = r#"spec test
1048uses lemma units
1049
1050data money: measure
1051  -> unit eur: 1.00
1052
1053data rate: measure
1054  -> unit eur_per_hour: eur/hour
1055"#;
1056        let formatted =
1057            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1058        assert!(
1059            formatted.contains("unit eur: 1.00"),
1060            "formatter must emit assignment colon for unit, got: {}",
1061            formatted
1062        );
1063        assert!(
1064            formatted.contains("unit eur_per_hour: eur/hour"),
1065            "formatter must emit assignment colon for compound unit, got: {}",
1066            formatted
1067        );
1068        assert!(
1069            !formatted.contains("unit eur 1.00"),
1070            "formatter must not emit deprecated space unit syntax, got: {}",
1071            formatted
1072        );
1073    }
1074
1075    #[test]
1076    fn format_canonical_unit_colon_round_trips() {
1077        let source = r#"spec test
1078uses lemma units
1079
1080data money: measure
1081  -> unit eur: 1.00
1082
1083data rate: measure
1084  -> unit eur_per_hour: eur/hour
1085"#;
1086        let formatted =
1087            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1088        assert!(
1089            formatted.contains("unit eur: 1.00"),
1090            "canonical unit syntax must survive format, got: {}",
1091            formatted
1092        );
1093        let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
1094        assert!(
1095            reparsed.is_ok(),
1096            "formatted canonical unit syntax should re-parse, got: {:?}",
1097            reparsed
1098        );
1099    }
1100
1101    #[test]
1102    fn test_format_expression_display_stable_round_trip() {
1103        let source = r#"spec test
1104data a: 1.00
1105rule r: a + 2.00 * 3
1106"#;
1107        let formatted =
1108            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1109        let again =
1110            format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
1111        assert_eq!(
1112            formatted, again,
1113            "AST Display-based format must be idempotent under parse/format"
1114        );
1115    }
1116
1117    #[test]
1118    fn test_format_past_future_range_no_duplicate_in() {
1119        let source = r#"spec test
1120data start: date
1121data length: duration
1122rule valid: start in past length
1123rule window: past length
1124"#;
1125        let formatted =
1126            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1127        assert!(
1128            formatted.contains("rule valid:\n  start in past length"),
1129            "RangeContainment+PastFutureRange must not emit duplicate 'in', got:\n{formatted}"
1130        );
1131        assert!(
1132            formatted.contains("rule window:\n  past length"),
1133            "bare PastFutureRange must print 'past' not 'in past', got:\n{formatted}"
1134        );
1135        assert!(
1136            !formatted.contains("in in past"),
1137            "must not contain duplicate 'in', got:\n{formatted}"
1138        );
1139    }
1140
1141    #[test]
1142    fn test_format_rule_body_on_next_line() {
1143        let source = "spec test\nrule r: 1\n";
1144        let formatted =
1145            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1146        assert!(
1147            formatted.contains("rule r:\n  1\n"),
1148            "rule body must start on next line, got:\n{formatted}"
1149        );
1150    }
1151
1152    #[test]
1153    fn test_format_blank_line_between_data_and_rules() {
1154        let source = "spec test\ndata x: 1\nrule r: x\n";
1155        let formatted =
1156            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1157        assert!(
1158            formatted.contains("data x: 1\n\n\nrule r:\n"),
1159            "two blank lines required between data block and rules block, got:\n{formatted:?}"
1160        );
1161    }
1162
1163    #[test]
1164    fn test_format_rule_unless_single_line_when_short() {
1165        let source = r#"spec test
1166data a: number
1167data b: boolean
1168
1169rule r: no
1170  unless a < 1 then yes
1171  unless b then yes
1172"#;
1173        let formatted =
1174            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1175        assert!(
1176            formatted.contains("unless a < 1 then yes")
1177                && formatted.contains("unless b     then yes"),
1178            "unless stays on one line when under MAX_COLS, got:\n{formatted}"
1179        );
1180    }
1181
1182    #[test]
1183    fn test_format_rule_unless_child_premium_applies_inline_aligned() {
1184        let source = r#"spec child_premium_applies
1185data child_age_years: number
1186data is_male: boolean
1187data child_has_own_children: boolean
1188data child_is_oldest_insured: boolean
1189
1190rule child_premium_applies:
1191  yes
1192  unless child_age_years >= 25 and is_male then no
1193  unless child_has_own_children then no
1194  unless child_is_oldest_insured then no
1195"#;
1196        let formatted =
1197            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1198        assert!(
1199            formatted.contains("unless child_age_years >= 25 and is_male then no")
1200                && formatted.contains("unless child_has_own_children            then no")
1201                && formatted.contains("unless child_is_oldest_insured           then no"),
1202            "all-single unless clauses align then, got:\n{formatted}"
1203        );
1204        let twice =
1205            format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
1206        assert_eq!(formatted, twice);
1207    }
1208
1209    #[test]
1210    fn test_format_rule_unless_can_request_reinstatement_and_wrap() {
1211        let source = r#"spec can_request_reinstatement
1212data days_since_policy_stopped: number
1213data arrears_paid: boolean
1214data surrender_value_repaid: boolean
1215data all_insured_persons_alive: boolean
1216
1217rule can_request_reinstatement:
1218  no
1219  unless days_since_policy_stopped <= 365 and arrears_paid and surrender_value_repaid and all_insured_persons_alive then yes
1220"#;
1221        let formatted =
1222            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1223        assert!(
1224            formatted.contains(
1225                "unless days_since_policy_stopped <= 365\n    and arrears_paid\n    and surrender_value_repaid\n    and all_insured_persons_alive\n    then yes"
1226            ),
1227            "long and chain wraps one operand per line, got:\n{formatted}"
1228        );
1229        let twice =
1230            format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
1231        assert_eq!(formatted, twice);
1232    }
1233
1234    #[test]
1235    fn test_format_rule_unless_foreign_transport_uniform_split_then() {
1236        let source = r#"spec foreign_transport_covered
1237data death_location: text
1238data trip_duration_months: number
1239data negative_travel_advisory_at_departure: boolean
1240data left_area_asap_after_advisory: boolean
1241
1242rule foreign_transport_covered:
1243  yes
1244  unless death_location is "abroad" then no
1245  unless death_location is "abroad" and trip_duration_months <= 2 then yes
1246  unless death_location is "abroad" and trip_duration_months <= 2 and negative_travel_advisory_at_departure then no
1247  unless death_location is "abroad" and trip_duration_months <= 2 and negative_travel_advisory_at_departure and left_area_asap_after_advisory then yes
1248"#;
1249        let formatted =
1250            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1251        assert!(
1252            formatted.contains("unless death_location is \"abroad\"\n    then no"),
1253            "short sister uses split then when any clause needs it, got:\n{formatted}"
1254        );
1255        assert!(
1256            formatted.contains(
1257                "unless death_location is \"abroad\"\n    and trip_duration_months <= 2\n    then yes"
1258            ),
1259            "wrapped and chain with split then, got:\n{formatted}"
1260        );
1261        let twice =
1262            format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
1263        assert_eq!(formatted, twice);
1264    }
1265
1266    #[test]
1267    fn test_format_rule_unless_child_auto_covered_service_only() {
1268        let source = r#"spec child_auto_covered_service_only
1269data days_since_birth: number
1270data birth_reported_within_60_days: boolean
1271
1272rule child_auto_covered_service_only:
1273  yes
1274  unless days_since_birth >= 60 and not birth_reported_within_60_days then no
1275"#;
1276        let formatted =
1277            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1278        assert!(
1279            formatted.contains(
1280                "unless days_since_birth >= 60\n    and not birth_reported_within_60_days\n    then no"
1281            ),
1282            "multiline unless condition with split then, got:\n{formatted}"
1283        );
1284        let twice =
1285            format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
1286        assert_eq!(formatted, twice);
1287    }
1288}