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    if alias == spec_name {
307        format!("uses {}", spec_ref)
308    } else {
309        format!("uses {}: {}", alias, spec_ref)
310    }
311}
312
313fn format_uses_block(data: &LemmaData, line_prefix: &str) -> String {
314    let mut out = format_import_header(data);
315    let DataValue::Import { bindings, .. } = &data.value else {
316        unreachable!("BUG: format_uses_block called on non-Import data");
317    };
318    for binding in bindings {
319        out.push('\n');
320        out.push_str(line_prefix);
321        out.push_str(DATA_CONSTRAINT_INDENT);
322        out.push_str("-> ");
323        out.push_str(&crate::parsing::ast::format_assignment_continuation(
324            "with",
325            &format!("{}", binding.path),
326            &format_with_rhs(&binding.rhs),
327        ));
328    }
329    out
330}
331
332/// Group data into sections separated by blank lines:
333///
334/// 1. Imports (`uses`) with `-> with` bindings — declaration order
335/// 2. Regular local `data` — declaration order
336fn format_sorted_data(data: &[LemmaData], out: &mut String, line_prefix: &str) {
337    let mut regular: Vec<&LemmaData> = Vec::new();
338    let mut imports: Vec<&LemmaData> = Vec::new();
339
340    for data in data {
341        if matches!(&data.value, DataValue::Import { .. }) {
342            imports.push(data);
343        } else {
344            regular.push(data);
345        }
346    }
347
348    let emit_group =
349        |rows: &[&LemmaData], out: &mut String| emit_data_row_group(rows, line_prefix, out);
350
351    if !imports.is_empty() {
352        out.push('\n');
353
354        for (i, row) in imports.iter().enumerate() {
355            if i > 0 {
356                out.push('\n');
357            }
358            out.push_str(line_prefix);
359            out.push_str(&format_uses_block(row, line_prefix));
360            out.push('\n');
361        }
362    }
363
364    if !regular.is_empty() {
365        out.push('\n');
366        emit_group(&regular, out);
367    }
368}
369
370// =============================================================================
371// Rules
372// =============================================================================
373
374const UNLESS_LINE_PREFIX: &str = "  unless ";
375
376/// Rule body always starts on the line after `rule name:`.
377///
378/// When every `unless` clause fits on one line under `max_cols`, `then` columns align across
379/// sisters. If any clause needs split `then` (wrapped condition, wrapped result, or oversize
380/// flat line), every clause on the rule uses split `then` at a fixed 4-space indent.
381fn format_rule(rule: &LemmaRule, max_cols: usize) -> String {
382    let expr_indent = "  ";
383    let body = format_expr_wrapped(&rule.expression, max_cols, expr_indent, 10);
384    let mut out = String::new();
385    out.push_str("rule ");
386    out.push_str(&rule.name);
387    out.push_str(":\n");
388    out.push_str(expr_indent);
389    out.push_str(&body);
390
391    let pl = UNLESS_LINE_PREFIX.len();
392    let naive_single_len = |cond: &str, res: &str| pl + cond.len() + 6 + res.len();
393    let aligned_single_len = |res: &str, max_end: usize| max_end + 6 + res.len();
394    let unless_condition_budget = max_cols.saturating_sub(pl);
395
396    let mut clauses: Vec<(String, String)> = Vec::new();
397    for unless_clause in &rule.unless_clauses {
398        let condition = format_expr_wrapped(
399            &unless_clause.condition,
400            unless_condition_budget,
401            "    ",
402            10,
403        );
404        let result = format_expr_wrapped(&unless_clause.result, max_cols, "    ", 10);
405        clauses.push((condition, result));
406    }
407
408    let clause_needs_split_then = |condition: &str, result: &str| {
409        condition.contains('\n')
410            || result.contains('\n')
411            || naive_single_len(condition, result) > max_cols
412    };
413
414    let any_split = clauses.iter().any(|(c, r)| clause_needs_split_then(c, r));
415
416    const SPLIT_THEN_INDENT_SPACES: usize = 4;
417
418    if any_split {
419        for (condition, result) in &clauses {
420            out.push_str("\n  unless ");
421            out.push_str(condition);
422            out.push('\n');
423            out.push_str(&" ".repeat(SPLIT_THEN_INDENT_SPACES));
424            out.push_str("then ");
425            out.push_str(result);
426        }
427    } else {
428        let mut singles: Vec<usize> = clauses
429            .iter()
430            .enumerate()
431            .filter(|(_, (c, r))| naive_single_len(c, r) <= max_cols)
432            .map(|(i, _)| i)
433            .collect();
434
435        loop {
436            if singles.is_empty() {
437                break;
438            }
439            let max_end = singles
440                .iter()
441                .map(|&i| pl + clauses[i].0.len())
442                .max()
443                .expect("BUG: singles non-empty");
444            let before = singles.len();
445            singles.retain(|&i| aligned_single_len(&clauses[i].1, max_end) <= max_cols);
446            if singles.len() == before {
447                break;
448            }
449        }
450
451        let align_max_end = singles.iter().map(|&i| pl + clauses[i].0.len()).max();
452
453        for (i, (condition, result)) in clauses.iter().enumerate() {
454            if singles.contains(&i) {
455                let max_end = align_max_end.expect("BUG: singles.contains but align_max_end empty");
456                let gap = 1 + max_end.saturating_sub(pl + condition.len());
457                out.push('\n');
458                out.push_str(UNLESS_LINE_PREFIX);
459                out.push_str(condition);
460                out.push_str(&" ".repeat(gap));
461                out.push_str("then ");
462                out.push_str(result);
463            } else {
464                out.push_str("\n  unless ");
465                out.push_str(condition);
466                out.push('\n');
467                out.push_str(&" ".repeat(SPLIT_THEN_INDENT_SPACES));
468                out.push_str("then ");
469                out.push_str(result);
470            }
471        }
472    }
473    out.push('\n');
474    out
475}
476
477// =============================================================================
478// Expression wrapping (soft line breaking at max_cols)
479// =============================================================================
480
481/// Indent every line after the first by `indent`.
482fn indent_after_first_line(s: &str, indent: &str) -> String {
483    let mut first = true;
484    let mut out = String::new();
485    for line in s.lines() {
486        if first {
487            first = false;
488            out.push_str(line);
489        } else {
490            out.push('\n');
491            out.push_str(indent);
492            out.push_str(line);
493        }
494    }
495    if s.ends_with('\n') {
496        out.push('\n');
497    }
498    out
499}
500
501struct BinaryWrapContext<'a> {
502    max_cols: usize,
503    indent: &'a str,
504    parent_prec: u8,
505    my_prec: u8,
506    assoc: Option<Associativity>,
507}
508
509fn format_binary_expr_wrapped(
510    left: &Expression,
511    op: &str,
512    right: &Expression,
513    ctx: BinaryWrapContext<'_>,
514) -> String {
515    let BinaryWrapContext {
516        max_cols,
517        indent,
518        parent_prec,
519        my_prec,
520        assoc,
521    } = ctx;
522    let left_inner = format_expr_wrapped(left, max_cols, indent, 10);
523    let right_inner = format_expr_wrapped(right, max_cols, indent, 10);
524    let left_str = if operand_needs_parentheses(
525        expression_precedence(&left.kind),
526        my_prec,
527        OperandSide::Left,
528        assoc,
529    ) {
530        format!("({})", left_inner)
531    } else {
532        left_inner
533    };
534    let right_str = if operand_needs_parentheses(
535        expression_precedence(&right.kind),
536        my_prec,
537        OperandSide::Right,
538        assoc,
539    ) {
540        format!("({})", right_inner)
541    } else {
542        right_inner
543    };
544    let single_line = format!("{} {} {}", left_str, op, right_str);
545    let body = if single_line.len() <= max_cols && !single_line.contains('\n') {
546        single_line
547    } else {
548        let continued_right = indent_after_first_line(&right_str, indent);
549        let continuation = format!("{}{} {}", indent, op, continued_right);
550        format!("{}\n{}", left_str, continuation)
551    };
552    if parent_prec < 10 && operand_needs_parentheses(my_prec, parent_prec, OperandSide::Left, None)
553    {
554        format!("({})", body)
555    } else {
556        body
557    }
558}
559
560/// Format an expression with optional wrapping at arithmetic and `and` operators when over max_cols.
561///
562/// Binary children use the same parenthesis policy as [`Expression`] display
563/// ([`operand_needs_parentheses`]). Pass `10` for top-level (no outer wrap).
564fn format_expr_wrapped(
565    expr: &Expression,
566    max_cols: usize,
567    indent: &str,
568    parent_prec: u8,
569) -> String {
570    let my_prec = expression_precedence(&expr.kind);
571
572    match &expr.kind {
573        ExpressionKind::Arithmetic(left, op, right) => format_binary_expr_wrapped(
574            left,
575            &op.to_string(),
576            right,
577            BinaryWrapContext {
578                max_cols,
579                indent,
580                parent_prec,
581                my_prec,
582                assoc: Some(arithmetic_associativity(op)),
583            },
584        ),
585        ExpressionKind::LogicalAnd(left, right) => format_binary_expr_wrapped(
586            left,
587            "and",
588            right,
589            BinaryWrapContext {
590                max_cols,
591                indent,
592                parent_prec,
593                my_prec,
594                assoc: Some(Associativity::Left),
595            },
596        ),
597        _ => {
598            let s = expr.to_string();
599            if parent_prec < 10
600                && operand_needs_parentheses(my_prec, parent_prec, OperandSide::Left, None)
601            {
602                format!("({})", s)
603            } else {
604                s
605            }
606        }
607    }
608}
609
610// =============================================================================
611// Tests
612// =============================================================================
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617    use crate::literals::DateGranularity;
618    use crate::parsing::ast::{
619        AsLemmaSource, BooleanValue, DateTimeValue, TimeValue, TimezoneValue, Value,
620    };
621    use rust_decimal::prelude::FromStr;
622    use rust_decimal::Decimal;
623
624    /// Helper: format a Value as canonical Lemma source via AsLemmaSource.
625    fn fmt_value(v: &Value) -> String {
626        format!("{}", AsLemmaSource(v))
627    }
628
629    #[test]
630    fn test_format_value_text_is_quoted() {
631        let v = Value::Text("light".to_string());
632        assert_eq!(fmt_value(&v), "\"light\"");
633    }
634
635    #[test]
636    fn test_format_value_text_escapes_quotes() {
637        let v = Value::Text("say \"hello\"".to_string());
638        assert_eq!(fmt_value(&v), "\"say \\\"hello\\\"\"");
639    }
640
641    #[test]
642    fn test_format_value_number() {
643        let v = Value::Number(Decimal::from_str("42.50").unwrap());
644        assert_eq!(fmt_value(&v), "42.50");
645    }
646
647    #[test]
648    fn test_format_value_number_integer() {
649        let v = Value::Number(Decimal::from_str("100.00").unwrap());
650        assert_eq!(fmt_value(&v), "100");
651    }
652
653    #[test]
654    fn test_format_value_boolean() {
655        assert_eq!(fmt_value(&Value::Boolean(BooleanValue::True)), "true");
656        assert_eq!(fmt_value(&Value::Boolean(BooleanValue::Yes)), "yes");
657        assert_eq!(fmt_value(&Value::Boolean(BooleanValue::No)), "no");
658    }
659
660    #[test]
661    fn test_format_value_measure() {
662        let v = Value::NumberWithUnit(Decimal::from_str("99.50").unwrap(), "eur".to_string());
663        assert_eq!(fmt_value(&v), "99.50 eur");
664    }
665
666    #[test]
667    fn test_format_value_duration_as_measure() {
668        let v = Value::NumberWithUnit(Decimal::from(40), "hour".to_string());
669        assert_eq!(fmt_value(&v), "40 hour");
670    }
671
672    #[test]
673    fn test_format_value_calendar() {
674        let v = Value::NumberWithUnit(Decimal::from(6), "month".to_string());
675        assert_eq!(fmt_value(&v), "6 month");
676    }
677
678    #[test]
679    fn test_format_value_ratio_percent() {
680        let v = Value::NumberWithUnit(Decimal::from_str("10").unwrap(), "percent".to_string());
681        assert_eq!(fmt_value(&v), "10%");
682    }
683
684    #[test]
685    fn test_format_value_ratio_permille() {
686        let v = Value::NumberWithUnit(Decimal::from_str("5").unwrap(), "permille".to_string());
687        assert_eq!(fmt_value(&v), "5%%");
688    }
689
690    #[test]
691    fn test_format_value_number_with_unit_named() {
692        let v = Value::NumberWithUnit(
693            Decimal::from_str("500").unwrap(),
694            "basis_points".to_string(),
695        );
696        assert_eq!(fmt_value(&v), "500 basis_points");
697    }
698
699    #[test]
700    fn test_format_value_date_only() {
701        let v = Value::Date(DateTimeValue {
702            year: 2024,
703            month: 1,
704            day: 15,
705            hour: 0,
706            minute: 0,
707            second: 0,
708            microsecond: 0,
709            timezone: None,
710
711            granularity: DateGranularity::Full,
712        });
713        assert_eq!(fmt_value(&v), "2024-01-15");
714    }
715
716    #[test]
717    fn test_format_value_datetime_with_tz() {
718        let v = Value::Date(DateTimeValue {
719            year: 2024,
720            month: 1,
721            day: 15,
722            hour: 14,
723            minute: 30,
724            second: 0,
725            microsecond: 0,
726            timezone: Some(TimezoneValue {
727                offset_hours: 0,
728                offset_minutes: 0,
729            }),
730
731            granularity: DateGranularity::DateTime,
732        });
733        assert_eq!(fmt_value(&v), "2024-01-15T14:30:00Z");
734    }
735
736    #[test]
737    fn test_format_value_time() {
738        let v = Value::Time(TimeValue {
739            hour: 14,
740            minute: 30,
741            second: 45,
742            microsecond: 0,
743            timezone: None,
744        });
745        assert_eq!(fmt_value(&v), "14:30:45");
746    }
747
748    #[test]
749    fn test_format_source_preserves_date_granularity() {
750        let formatted = format_source(
751            "spec x 2026\n",
752            crate::parsing::source::SourceType::Volatile,
753        )
754        .expect("spec x 2026 should format");
755        assert!(
756            formatted.contains("spec x 2026\n"),
757            "year-only effective date must round-trip, got: {formatted}"
758        );
759        assert!(
760            !formatted.contains("2026-01-01"),
761            "year-only effective date must not expand, got: {formatted}"
762        );
763        let reformatted = format_source(&formatted, crate::parsing::source::SourceType::Volatile)
764            .expect("reformat");
765        assert_eq!(formatted, reformatted, "spec x 2026 must be idempotent");
766
767        let formatted = format_source(
768            "spec x 2026-03\n",
769            crate::parsing::source::SourceType::Volatile,
770        )
771        .expect("spec x 2026-03 should format");
772        assert!(
773            formatted.contains("spec x 2026-03\n"),
774            "year-month effective date must round-trip, got: {formatted}"
775        );
776
777        let formatted = format_source(
778            "spec x 2026-W34\n",
779            crate::parsing::source::SourceType::Volatile,
780        )
781        .expect("spec x 2026-W34 should format");
782        assert!(
783            formatted.contains("spec x 2026-W34\n"),
784            "iso week effective date must round-trip, got: {formatted}"
785        );
786
787        let source = "spec consumer\nuses finance 2026\n";
788        let formatted = format_source(source, crate::parsing::source::SourceType::Volatile)
789            .expect("uses with year should format");
790        assert!(
791            formatted.contains("uses finance 2026"),
792            "uses effective pin must preserve year-only date, got: {formatted}"
793        );
794        assert!(
795            !formatted.contains("2026-01-01"),
796            "uses effective pin must not expand year-only date, got: {formatted}"
797        );
798    }
799
800    #[test]
801    fn test_format_source_lowercases_logical_identifiers() {
802        let source = r#"spec Test
803data Price: number -> suggest 1
804rule Total: price
805"#;
806        let formatted =
807            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
808        assert!(formatted.contains("spec test"), "got: {formatted}");
809        assert!(formatted.contains("data price"), "got: {formatted}");
810        assert!(formatted.contains("rule total"), "got: {formatted}");
811    }
812
813    #[test]
814    fn test_format_source_round_trips_text() {
815        let source = r#"spec test
816
817data name: "Alice"
818
819rule greeting: "hello"
820"#;
821        let formatted =
822            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
823        assert!(formatted.contains("\"Alice\""), "data text must be quoted");
824        assert!(formatted.contains("\"hello\""), "rule text must be quoted");
825    }
826
827    #[test]
828    fn test_format_source_preserves_percent() {
829        let source = r#"spec test
830
831data rate: 10 percent
832
833rule tax: rate * 21%
834"#;
835        let formatted =
836            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
837        assert!(
838            formatted.contains("10%"),
839            "data percent must use shorthand %, got: {}",
840            formatted
841        );
842    }
843
844    #[test]
845    fn test_format_groups_data_preserving_order() {
846        // Data are deliberately mixed: the formatter keeps all regular data together
847        // in original order, aligned
848        let source = r#"spec test
849
850data income: number -> minimum 0
851data filing_status: filing_status_type -> suggest "single"
852data country: "NL"
853data deductions: number -> minimum 0
854data name: text
855
856rule total: income
857"#;
858        let formatted =
859            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
860        let data_section = formatted
861            .split("rule total")
862            .next()
863            .unwrap()
864            .split("spec test\n")
865            .nth(1)
866            .unwrap();
867        let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
868        // Constrained rows: one blank line after each when more `data` follows.
869        assert_eq!(lines[0], "data income: number");
870        assert_eq!(lines[1], "  -> minimum 0");
871        assert_eq!(lines[2], "data filing_status: filing_status_type");
872        assert_eq!(lines[3], "  -> suggest \"single\"");
873        assert_eq!(lines[4], "data country: \"NL\"");
874        assert_eq!(lines[5], "data deductions: number");
875        assert_eq!(lines[6], "  -> minimum 0");
876        assert_eq!(lines[7], "data name: text");
877    }
878
879    #[test]
880    fn test_format_groups_spec_refs_with_overrides() {
881        let source = r#"spec test
882
883uses order wholesale
884  -> with quantity: 100
885uses order retail
886  -> with quantity: 5
887data base_price: 50
888
889rule total: base_price
890"#;
891        let formatted =
892            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
893        let data_section = formatted
894            .split("rule total")
895            .next()
896            .unwrap()
897            .split("spec test\n")
898            .nth(1)
899            .unwrap();
900        let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
901        assert_eq!(lines[0], "uses order wholesale");
902        assert_eq!(lines[1], "  -> with quantity: 100");
903        assert_eq!(lines[2], "uses order retail");
904        assert_eq!(lines[3], "  -> with quantity: 5");
905        assert_eq!(lines[4], "data base_price: 50");
906    }
907
908    #[test]
909    fn test_format_groups_with_literals_under_each_uses() {
910        let source = r#"spec test
911
912uses x
913  -> with name: "Ben"
914uses y
915  -> with age: 15
916
917rule r: 1
918"#;
919        let formatted =
920            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
921        let data_section = formatted
922            .split("rule r")
923            .next()
924            .unwrap()
925            .split("spec test\n")
926            .nth(1)
927            .unwrap();
928        let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
929        assert_eq!(lines[0], "uses x");
930        assert_eq!(lines[1], "  -> with name: \"Ben\"");
931        assert_eq!(lines[2], "uses y");
932        assert_eq!(lines[3], "  -> with age: 15");
933    }
934
935    #[test]
936    fn test_format_source_weather_clothing_text_quoted() {
937        let source = r#"spec weather_clothing
938
939data clothing_style: text
940  -> option "light"
941  -> option "warm"
942
943data temperature: number
944
945rule clothing_layer: "light"
946  unless temperature < 5 then "warm"
947"#;
948        let formatted =
949            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
950        assert!(
951            formatted.contains("\"light\""),
952            "text in rule must be quoted, got: {}",
953            formatted
954        );
955        assert!(
956            formatted.contains("\"warm\""),
957            "text in unless must be quoted, got: {}",
958            formatted
959        );
960    }
961
962    // NOTE: Default value type validation (e.g. rejecting "10 $$" as a number
963    // default) is tested at the planning level in engine.rs, not here. The
964    // formatter only parses — it does not validate types. Planning catches
965    // invalid defaults for both primitives and named types.
966
967    #[test]
968    fn test_format_text_option_round_trips() {
969        let source = r#"spec test
970
971data status: text
972  -> option "active"
973  -> option "inactive"
974
975data s: status
976
977rule out: s
978"#;
979        let formatted =
980            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
981        assert!(
982            formatted.contains("option \"active\""),
983            "text option must be quoted, got: {}",
984            formatted
985        );
986        assert!(
987            formatted.contains("option \"inactive\""),
988            "text option must be quoted, got: {}",
989            formatted
990        );
991        // Round-trip
992        let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
993        assert!(reparsed.is_ok(), "formatted output should re-parse");
994    }
995
996    #[test]
997    fn test_format_help_round_trips() {
998        let source = r#"spec test
999data quantity: number -> help "Number of items to order"
1000rule total: quantity
1001"#;
1002        let formatted =
1003            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1004        assert!(
1005            formatted.contains("help \"Number of items to order\""),
1006            "help must be quoted, got: {}",
1007            formatted
1008        );
1009        // Round-trip
1010        let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
1011        assert!(reparsed.is_ok(), "formatted output should re-parse");
1012    }
1013
1014    #[test]
1015    fn test_format_measure_type_def_round_trips() {
1016        let source = r#"spec test
1017
1018data money: measure
1019  -> unit eur: 1.00
1020  -> unit usd: 0.91
1021  -> decimals 2
1022  -> minimum 0
1023
1024data price: money
1025
1026rule total: price
1027"#;
1028        let formatted =
1029            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1030        assert!(
1031            formatted.contains("unit eur: 1.00"),
1032            "measure unit should not be quoted, got: {}",
1033            formatted
1034        );
1035        // Round-trip
1036        let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
1037        assert!(
1038            reparsed.is_ok(),
1039            "formatted output should re-parse, got: {:?}",
1040            reparsed
1041        );
1042    }
1043
1044    #[test]
1045    fn format_deprecated_unit_space_emits_assignment_colon() {
1046        let source = r#"spec test
1047uses lemma units
1048
1049data money: measure
1050  -> unit eur: 1.00
1051
1052data rate: measure
1053  -> unit eur_per_hour: eur/hour
1054"#;
1055        let formatted =
1056            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1057        assert!(
1058            formatted.contains("unit eur: 1.00"),
1059            "formatter must emit assignment colon for unit, got: {}",
1060            formatted
1061        );
1062        assert!(
1063            formatted.contains("unit eur_per_hour: eur/hour"),
1064            "formatter must emit assignment colon for compound unit, got: {}",
1065            formatted
1066        );
1067        assert!(
1068            !formatted.contains("unit eur 1.00"),
1069            "formatter must not emit deprecated space unit syntax, got: {}",
1070            formatted
1071        );
1072    }
1073
1074    #[test]
1075    fn format_canonical_unit_colon_round_trips() {
1076        let source = r#"spec test
1077uses lemma units
1078
1079data money: measure
1080  -> unit eur: 1.00
1081
1082data rate: measure
1083  -> unit eur_per_hour: eur/hour
1084"#;
1085        let formatted =
1086            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1087        assert!(
1088            formatted.contains("unit eur: 1.00"),
1089            "canonical unit syntax must survive format, got: {}",
1090            formatted
1091        );
1092        let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
1093        assert!(
1094            reparsed.is_ok(),
1095            "formatted canonical unit syntax should re-parse, got: {:?}",
1096            reparsed
1097        );
1098    }
1099
1100    #[test]
1101    fn test_format_expression_display_stable_round_trip() {
1102        let source = r#"spec test
1103data a: 1.00
1104rule r: a + 2.00 * 3
1105"#;
1106        let formatted =
1107            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1108        let again =
1109            format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
1110        assert_eq!(
1111            formatted, again,
1112            "AST Display-based format must be idempotent under parse/format"
1113        );
1114    }
1115
1116    #[test]
1117    fn test_format_past_future_range_no_duplicate_in() {
1118        let source = r#"spec test
1119data start: date
1120data length: duration
1121rule valid: start in past length
1122rule window: past length
1123"#;
1124        let formatted =
1125            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1126        assert!(
1127            formatted.contains("rule valid:\n  start in past length"),
1128            "RangeContainment+PastFutureRange must not emit duplicate 'in', got:\n{formatted}"
1129        );
1130        assert!(
1131            formatted.contains("rule window:\n  past length"),
1132            "bare PastFutureRange must print 'past' not 'in past', got:\n{formatted}"
1133        );
1134        assert!(
1135            !formatted.contains("in in past"),
1136            "must not contain duplicate 'in', got:\n{formatted}"
1137        );
1138    }
1139
1140    #[test]
1141    fn test_format_rule_body_on_next_line() {
1142        let source = "spec test\nrule r: 1\n";
1143        let formatted =
1144            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1145        assert!(
1146            formatted.contains("rule r:\n  1\n"),
1147            "rule body must start on next line, got:\n{formatted}"
1148        );
1149    }
1150
1151    #[test]
1152    fn test_format_blank_line_between_data_and_rules() {
1153        let source = "spec test\ndata x: 1\nrule r: x\n";
1154        let formatted =
1155            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1156        assert!(
1157            formatted.contains("data x: 1\n\n\nrule r:\n"),
1158            "two blank lines required between data block and rules block, got:\n{formatted:?}"
1159        );
1160    }
1161
1162    #[test]
1163    fn test_format_rule_unless_single_line_when_short() {
1164        let source = r#"spec test
1165data a: number
1166data b: boolean
1167
1168rule r: no
1169  unless a < 1 then yes
1170  unless b then yes
1171"#;
1172        let formatted =
1173            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1174        assert!(
1175            formatted.contains("unless a < 1 then yes")
1176                && formatted.contains("unless b     then yes"),
1177            "unless stays on one line when under MAX_COLS, got:\n{formatted}"
1178        );
1179    }
1180
1181    #[test]
1182    fn test_format_rule_unless_child_premium_applies_inline_aligned() {
1183        let source = r#"spec child_premium_applies
1184data child_age_years: number
1185data is_male: boolean
1186data child_has_own_children: boolean
1187data child_is_oldest_insured: boolean
1188
1189rule child_premium_applies:
1190  yes
1191  unless child_age_years >= 25 and is_male then no
1192  unless child_has_own_children then no
1193  unless child_is_oldest_insured then no
1194"#;
1195        let formatted =
1196            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1197        assert!(
1198            formatted.contains("unless child_age_years >= 25 and is_male then no")
1199                && formatted.contains("unless child_has_own_children            then no")
1200                && formatted.contains("unless child_is_oldest_insured           then no"),
1201            "all-single unless clauses align then, got:\n{formatted}"
1202        );
1203        let twice =
1204            format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
1205        assert_eq!(formatted, twice);
1206    }
1207
1208    #[test]
1209    fn test_format_rule_unless_can_request_reinstatement_and_wrap() {
1210        let source = r#"spec can_request_reinstatement
1211data days_since_policy_stopped: number
1212data arrears_paid: boolean
1213data surrender_value_repaid: boolean
1214data all_insured_persons_alive: boolean
1215
1216rule can_request_reinstatement:
1217  no
1218  unless days_since_policy_stopped <= 365 and arrears_paid and surrender_value_repaid and all_insured_persons_alive then yes
1219"#;
1220        let formatted =
1221            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1222        assert!(
1223            formatted.contains(
1224                "unless days_since_policy_stopped <= 365\n    and arrears_paid\n    and surrender_value_repaid\n    and all_insured_persons_alive\n    then yes"
1225            ),
1226            "long and chain wraps one operand per line, got:\n{formatted}"
1227        );
1228        let twice =
1229            format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
1230        assert_eq!(formatted, twice);
1231    }
1232
1233    #[test]
1234    fn test_format_rule_unless_foreign_transport_uniform_split_then() {
1235        let source = r#"spec foreign_transport_covered
1236data death_location: text
1237data trip_duration_months: number
1238data negative_travel_advisory_at_departure: boolean
1239data left_area_asap_after_advisory: boolean
1240
1241rule foreign_transport_covered:
1242  yes
1243  unless death_location is "abroad" then no
1244  unless death_location is "abroad" and trip_duration_months <= 2 then yes
1245  unless death_location is "abroad" and trip_duration_months <= 2 and negative_travel_advisory_at_departure then no
1246  unless death_location is "abroad" and trip_duration_months <= 2 and negative_travel_advisory_at_departure and left_area_asap_after_advisory then yes
1247"#;
1248        let formatted =
1249            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1250        assert!(
1251            formatted.contains("unless death_location is \"abroad\"\n    then no"),
1252            "short sister uses split then when any clause needs it, got:\n{formatted}"
1253        );
1254        assert!(
1255            formatted.contains(
1256                "unless death_location is \"abroad\"\n    and trip_duration_months <= 2\n    then yes"
1257            ),
1258            "wrapped and chain with split then, got:\n{formatted}"
1259        );
1260        let twice =
1261            format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
1262        assert_eq!(formatted, twice);
1263    }
1264
1265    #[test]
1266    fn test_format_rule_unless_child_auto_covered_service_only() {
1267        let source = r#"spec child_auto_covered_service_only
1268data days_since_birth: number
1269data birth_reported_within_60_days: boolean
1270
1271rule child_auto_covered_service_only:
1272  yes
1273  unless days_since_birth >= 60 and not birth_reported_within_60_days then no
1274"#;
1275        let formatted =
1276            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1277        assert!(
1278            formatted.contains(
1279                "unless days_since_birth >= 60\n    and not birth_reported_within_60_days\n    then no"
1280            ),
1281            "multiline unless condition with split then, got:\n{formatted}"
1282        );
1283        let twice =
1284            format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
1285        assert_eq!(formatted, twice);
1286    }
1287}