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