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    Constraint, DataValue, Expression, ExpressionKind, LemmaData, LemmaRule, LemmaSpec,
10    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        out.push('\n');
128        for (index, rule) in spec.rules.iter().enumerate() {
129            if index > 0 {
130                out.push('\n');
131            }
132            let rule_text = format_rule(rule, max_cols);
133            for line in rule_text.lines() {
134                out.push_str(line);
135                out.push('\n');
136            }
137        }
138    }
139
140    out
141}
142
143// =============================================================================
144// Data
145// =============================================================================
146
147/// Two spaces after `line_prefix` for each `-> ...` constraint line under `data ...: ...`.
148const DATA_CONSTRAINT_INDENT: &str = "  ";
149
150fn data_constraints_nonempty(constraints: &Option<Vec<Constraint>>) -> bool {
151    constraints.as_ref().is_some_and(|v| !v.is_empty())
152}
153
154fn data_value_has_arrow_constraints(value: &DataValue) -> bool {
155    match value {
156        DataValue::Definition { constraints, .. } => data_constraints_nonempty(constraints),
157        DataValue::With(_) => false,
158        _ => false,
159    }
160}
161
162fn data_value_rhs_for_spec_body(value: &DataValue, continuation_prefix: &str) -> String {
163    match value {
164        DataValue::Definition {
165            base,
166            constraints,
167            value,
168        } if data_constraints_nonempty(constraints) => {
169            let cs = constraints
170                .as_ref()
171                .expect("BUG: constraints checked above");
172            let head: String = if base.is_none() {
173                match value {
174                    Some(v) => format!("{}", AsLemmaSource(v)),
175                    None => String::new(),
176                }
177            } else {
178                match base.as_ref() {
179                    Some(b) => format!("{}", b),
180                    None => String::new(),
181                }
182            };
183            let mut out = head;
184            for (cmd, args) in cs {
185                out.push('\n');
186                out.push_str(continuation_prefix);
187                out.push_str("-> ");
188                out.push_str(&crate::parsing::ast::format_constraint_as_source(cmd, args));
189            }
190            out
191        }
192        DataValue::With(crate::parsing::ast::WithRhs::Reference { target }) => target.to_string(),
193        _ => format!("{}", AsLemmaSource(value)),
194    }
195}
196
197fn data_declaration_keyword(data: &LemmaData) -> &'static str {
198    match &data.value {
199        DataValue::Import(_) => unreachable!("BUG: format_data called on Import row"),
200        DataValue::With(_) => "with",
201        DataValue::Definition { .. } => "data",
202    }
203}
204
205fn format_data(data: &LemmaData, line_prefix: &str) -> String {
206    let kw = data_declaration_keyword(data);
207    let ref_str = format!("{}", data.reference);
208    let continuation = format!("{line_prefix}{DATA_CONSTRAINT_INDENT}");
209    let rhs = data_value_rhs_for_spec_body(&data.value, &continuation);
210    if let Some((first, rest)) = rhs.split_once('\n') {
211        format!("{kw} {}: {}\n{}", ref_str, first, rest)
212    } else {
213        format!("{kw} {}: {}", ref_str, rhs)
214    }
215}
216
217/// Byte length from start of `data ` or `with ` through the single space after `:` (same layout as [`format_data`]).
218fn data_line_prefix_len_before_rhs(keyword: &str, ref_str: &str) -> usize {
219    keyword.len() + 1 + ref_str.len() + 2
220}
221
222fn data_is_simple_single_line(data: &LemmaData, line_prefix: &str) -> bool {
223    if data_value_has_arrow_constraints(&data.value) {
224        return false;
225    }
226    let continuation = format!("{line_prefix}{DATA_CONSTRAINT_INDENT}");
227    let rhs = data_value_rhs_for_spec_body(&data.value, &continuation);
228    !rhs.contains('\n')
229}
230
231fn push_formatted_simple_data_line_padded(
232    out: &mut String,
233    data: &LemmaData,
234    line_prefix: &str,
235    target_prefix_len_before_rhs: usize,
236) {
237    let kw = data_declaration_keyword(data);
238    let ref_str = format!("{}", data.reference);
239    let continuation = format!("{line_prefix}{DATA_CONSTRAINT_INDENT}");
240    let rhs = data_value_rhs_for_spec_body(&data.value, &continuation);
241    let base = data_line_prefix_len_before_rhs(kw, &ref_str);
242    let gap = 1 + target_prefix_len_before_rhs.saturating_sub(base);
243    out.push_str(line_prefix);
244    out.push_str(kw);
245    out.push(' ');
246    out.push_str(&ref_str);
247    out.push(':');
248    out.push_str(&" ".repeat(gap));
249    out.push_str(&rhs);
250}
251
252fn emit_data_row_group(rows: &[&LemmaData], line_prefix: &str, out: &mut String) {
253    let mut i = 0;
254    while i < rows.len() {
255        if data_is_simple_single_line(rows[i], line_prefix) {
256            let run_start = i;
257            i += 1;
258            while i < rows.len() && data_is_simple_single_line(rows[i], line_prefix) {
259                i += 1;
260            }
261            let run_end = i;
262            let target = (run_start..run_end)
263                .map(|k| {
264                    let row = rows[k];
265                    let kw = data_declaration_keyword(row);
266                    let ref_str = format!("{}", row.reference);
267                    data_line_prefix_len_before_rhs(kw, &ref_str)
268                })
269                .max()
270                .expect("BUG: non-empty run");
271            for row in rows[run_start..run_end].iter().copied() {
272                push_formatted_simple_data_line_padded(out, row, line_prefix, target);
273                out.push('\n');
274            }
275        } else {
276            let row = rows[i];
277            out.push_str(line_prefix);
278            out.push_str(&format_data(row, line_prefix));
279            out.push('\n');
280            if data_value_has_arrow_constraints(&row.value) && i + 1 < rows.len() {
281                out.push('\n');
282            }
283            i += 1;
284        }
285    }
286}
287
288fn format_import_row(data: &LemmaData) -> String {
289    let alias = &data.reference.name;
290    if let DataValue::Import(spec_ref) = &data.value {
291        let spec_name = &spec_ref.name;
292        let last_segment = spec_name.rsplit('/').next().unwrap_or(spec_name);
293        if alias == last_segment {
294            format!("uses {}", spec_ref)
295        } else {
296            format!("uses {}: {}", alias, spec_ref)
297        }
298    } else {
299        unreachable!("BUG: format_import_row called on non-Import data")
300    }
301}
302
303/// Group data into sections separated by blank lines:
304///
305/// 1. Imports (`uses`), each followed by their literal bindings — original order within this block
306/// 2. Regular data (literals, type declarations, references) — original order
307/// 3. Qualified overrides that did not attach to any import — original order
308fn format_sorted_data(data: &[LemmaData], out: &mut String, line_prefix: &str) {
309    let mut regular: Vec<&LemmaData> = Vec::new();
310    let mut imports: Vec<&LemmaData> = Vec::new();
311    let mut overrides: Vec<&LemmaData> = Vec::new();
312
313    for data in data {
314        if !data.reference.is_local() {
315            overrides.push(data);
316        } else if matches!(&data.value, DataValue::Import(_)) {
317            imports.push(data);
318        } else {
319            regular.push(data);
320        }
321    }
322
323    let emit_group =
324        |rows: &[&LemmaData], out: &mut String| emit_data_row_group(rows, line_prefix, out);
325
326    if !imports.is_empty() {
327        out.push('\n');
328
329        for (i, row) in imports.iter().enumerate() {
330            if i > 0 {
331                out.push('\n');
332            }
333            out.push_str(line_prefix);
334            out.push_str(&format_import_row(row));
335            out.push('\n');
336            let ref_name = &row.reference.name;
337            let binding_overrides: Vec<&LemmaData> = overrides
338                .iter()
339                .filter(|o| {
340                    o.reference.segments.first().map(|s| s.as_str()) == Some(ref_name.as_str())
341                })
342                .copied()
343                .collect();
344            if !binding_overrides.is_empty() {
345                emit_data_row_group(&binding_overrides, line_prefix, out);
346            }
347        }
348    }
349
350    if !regular.is_empty() {
351        out.push('\n');
352        emit_group(&regular, out);
353    }
354
355    let matched_prefixes: Vec<&str> = imports.iter().map(|f| f.reference.name.as_str()).collect();
356    let unmatched: Vec<&LemmaData> = overrides
357        .iter()
358        .filter(|o| {
359            o.reference
360                .segments
361                .first()
362                .map(|s| !matched_prefixes.contains(&s.as_str()))
363                .unwrap_or(true)
364        })
365        .copied()
366        .collect();
367    if !unmatched.is_empty() {
368        out.push('\n');
369        emit_group(&unmatched, out);
370    }
371}
372
373// =============================================================================
374// Rules
375// =============================================================================
376
377const UNLESS_LINE_PREFIX: &str = "  unless ";
378
379/// Logical line length for `max_cols` checks (no extra spec-level indent).
380#[inline]
381fn spec_line_len(line: &str) -> usize {
382    line.len()
383}
384
385/// Default expression stays on the `rule name:` line when it fits under `max_cols`.
386///
387/// Single-line `unless … then …` clauses align `then` when every such line still fits under
388/// `max_cols` after alignment. Any clause that splits across lines (expression wraps, or one line
389/// would exceed `max_cols`) uses a fixed `then` indent — no column alignment with shorter sisters.
390fn format_rule(rule: &LemmaRule, max_cols: usize) -> String {
391    let expr_indent = "  ";
392    let body = format_expr_wrapped(&rule.expression, max_cols, expr_indent, 10);
393    let mut out = String::new();
394    out.push_str("rule ");
395    out.push_str(&rule.name);
396    let body_single_line = !body.contains('\n');
397    let header_fits_on_one_line =
398        body_single_line && spec_line_len(&format!("rule {}: {}", rule.name, body)) <= max_cols;
399    if header_fits_on_one_line {
400        out.push_str(": ");
401        out.push_str(&body);
402    } else {
403        out.push_str(":\n");
404        out.push_str(expr_indent);
405        out.push_str(&body);
406    }
407
408    let pl = UNLESS_LINE_PREFIX.len();
409    let naive_single_len = |cond: &str, res: &str| pl + cond.len() + 6 + res.len();
410    let aligned_single_len = |res: &str, max_end: usize| max_end + 6 + res.len();
411
412    let mut clauses: Vec<(String, String, bool)> = Vec::new();
413    for unless_clause in &rule.unless_clauses {
414        let condition = format_expr_wrapped(&unless_clause.condition, max_cols, "    ", 10);
415        let result = format_expr_wrapped(&unless_clause.result, max_cols, "    ", 10);
416        let multiline = condition.contains('\n') || result.contains('\n');
417        clauses.push((condition, result, multiline));
418    }
419
420    let mut singles: Vec<usize> = clauses
421        .iter()
422        .enumerate()
423        .filter(|(_, (c, r, m))| !*m && naive_single_len(c, r) <= max_cols)
424        .map(|(i, _)| i)
425        .collect();
426
427    loop {
428        if singles.is_empty() {
429            break;
430        }
431        let max_end = singles
432            .iter()
433            .map(|&i| pl + clauses[i].0.len())
434            .max()
435            .expect("BUG: singles non-empty");
436        let before = singles.len();
437        singles.retain(|&i| aligned_single_len(&clauses[i].1, max_end) <= max_cols);
438        if singles.len() == before {
439            break;
440        }
441    }
442
443    let align_max_end = singles.iter().map(|&i| pl + clauses[i].0.len()).max();
444    const SPLIT_THEN_INDENT_SPACES: usize = 4;
445
446    for (i, (condition, result, multiline)) in clauses.iter().enumerate() {
447        if *multiline {
448            out.push_str("\n  unless ");
449            out.push_str(condition);
450            out.push('\n');
451            out.push_str(&" ".repeat(SPLIT_THEN_INDENT_SPACES));
452            out.push_str("then ");
453            out.push_str(result);
454            continue;
455        }
456        if singles.contains(&i) {
457            let max_end = align_max_end.expect("BUG: singles.contains but align_max_end empty");
458            let gap = 1 + max_end.saturating_sub(pl + condition.len());
459            out.push('\n');
460            out.push_str(UNLESS_LINE_PREFIX);
461            out.push_str(condition);
462            out.push_str(&" ".repeat(gap));
463            out.push_str("then ");
464            out.push_str(result);
465            continue;
466        }
467        out.push_str("\n  unless ");
468        out.push_str(condition);
469        out.push('\n');
470        out.push_str(&" ".repeat(SPLIT_THEN_INDENT_SPACES));
471        out.push_str("then ");
472        out.push_str(result);
473    }
474    out.push('\n');
475    out
476}
477
478// =============================================================================
479// Expression wrapping (soft line breaking at max_cols)
480// =============================================================================
481
482/// Indent every line after the first by `indent`.
483fn indent_after_first_line(s: &str, indent: &str) -> String {
484    let mut first = true;
485    let mut out = String::new();
486    for line in s.lines() {
487        if first {
488            first = false;
489            out.push_str(line);
490        } else {
491            out.push('\n');
492            out.push_str(indent);
493            out.push_str(line);
494        }
495    }
496    if s.ends_with('\n') {
497        out.push('\n');
498    }
499    out
500}
501
502/// Format an expression with optional wrapping at arithmetic operators when over max_cols.
503///
504/// Arithmetic children use the same parenthesis policy as [`Expression`] display
505/// ([`operand_needs_parentheses`]). Pass `10` for top-level (no outer wrap).
506fn format_expr_wrapped(
507    expr: &Expression,
508    max_cols: usize,
509    indent: &str,
510    parent_prec: u8,
511) -> String {
512    let my_prec = expression_precedence(&expr.kind);
513
514    match &expr.kind {
515        ExpressionKind::Arithmetic(left, op, right) => {
516            let assoc = Some(arithmetic_associativity(op));
517            // Children formatted as top-level; this node applies paren policy.
518            let left_inner = format_expr_wrapped(left.as_ref(), max_cols, indent, 10);
519            let right_inner = format_expr_wrapped(right.as_ref(), max_cols, indent, 10);
520            let left_str = if operand_needs_parentheses(
521                expression_precedence(&left.kind),
522                my_prec,
523                OperandSide::Left,
524                assoc,
525            ) {
526                format!("({})", left_inner)
527            } else {
528                left_inner
529            };
530            let right_str = if operand_needs_parentheses(
531                expression_precedence(&right.kind),
532                my_prec,
533                OperandSide::Right,
534                assoc,
535            ) {
536                format!("({})", right_inner)
537            } else {
538                right_inner
539            };
540            let single_line = format!("{} {} {}", left_str, op, right_str);
541            let body = if single_line.len() <= max_cols && !single_line.contains('\n') {
542                single_line
543            } else {
544                let continued_right = indent_after_first_line(&right_str, indent);
545                let continuation = format!("{}{} {}", indent, op, continued_right);
546                format!("{}\n{}", left_str, continuation)
547            };
548            if parent_prec < 10
549                && operand_needs_parentheses(my_prec, parent_prec, OperandSide::Left, None)
550            {
551                format!("({})", body)
552            } else {
553                body
554            }
555        }
556        _ => {
557            let s = expr.to_string();
558            if parent_prec < 10
559                && operand_needs_parentheses(my_prec, parent_prec, OperandSide::Left, None)
560            {
561                format!("({})", s)
562            } else {
563                s
564            }
565        }
566    }
567}
568
569// =============================================================================
570// Tests
571// =============================================================================
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use crate::literals::DateGranularity;
577    use crate::parsing::ast::{
578        AsLemmaSource, BooleanValue, DateTimeValue, TimeValue, TimezoneValue, Value,
579    };
580    use rust_decimal::prelude::FromStr;
581    use rust_decimal::Decimal;
582
583    /// Helper: format a Value as canonical Lemma source via AsLemmaSource.
584    fn fmt_value(v: &Value) -> String {
585        format!("{}", AsLemmaSource(v))
586    }
587
588    #[test]
589    fn test_format_value_text_is_quoted() {
590        let v = Value::Text("light".to_string());
591        assert_eq!(fmt_value(&v), "\"light\"");
592    }
593
594    #[test]
595    fn test_format_value_text_escapes_quotes() {
596        let v = Value::Text("say \"hello\"".to_string());
597        assert_eq!(fmt_value(&v), "\"say \\\"hello\\\"\"");
598    }
599
600    #[test]
601    fn test_format_value_number() {
602        let v = Value::Number(Decimal::from_str("42.50").unwrap());
603        assert_eq!(fmt_value(&v), "42.50");
604    }
605
606    #[test]
607    fn test_format_value_number_integer() {
608        let v = Value::Number(Decimal::from_str("100.00").unwrap());
609        assert_eq!(fmt_value(&v), "100");
610    }
611
612    #[test]
613    fn test_format_value_boolean() {
614        assert_eq!(fmt_value(&Value::Boolean(BooleanValue::True)), "true");
615        assert_eq!(fmt_value(&Value::Boolean(BooleanValue::Yes)), "yes");
616        assert_eq!(fmt_value(&Value::Boolean(BooleanValue::No)), "no");
617    }
618
619    #[test]
620    fn test_format_value_measure() {
621        let v = Value::NumberWithUnit(Decimal::from_str("99.50").unwrap(), "eur".to_string());
622        assert_eq!(fmt_value(&v), "99.50 eur");
623    }
624
625    #[test]
626    fn test_format_value_duration_as_measure() {
627        let v = Value::NumberWithUnit(Decimal::from(40), "hour".to_string());
628        assert_eq!(fmt_value(&v), "40 hour");
629    }
630
631    #[test]
632    fn test_format_value_calendar() {
633        let v = Value::NumberWithUnit(Decimal::from(6), "month".to_string());
634        assert_eq!(fmt_value(&v), "6 month");
635    }
636
637    #[test]
638    fn test_format_value_ratio_percent() {
639        let v = Value::NumberWithUnit(Decimal::from_str("10").unwrap(), "percent".to_string());
640        assert_eq!(fmt_value(&v), "10%");
641    }
642
643    #[test]
644    fn test_format_value_ratio_permille() {
645        let v = Value::NumberWithUnit(Decimal::from_str("5").unwrap(), "permille".to_string());
646        assert_eq!(fmt_value(&v), "5%%");
647    }
648
649    #[test]
650    fn test_format_value_number_with_unit_named() {
651        let v = Value::NumberWithUnit(
652            Decimal::from_str("500").unwrap(),
653            "basis_points".to_string(),
654        );
655        assert_eq!(fmt_value(&v), "500 basis_points");
656    }
657
658    #[test]
659    fn test_format_value_date_only() {
660        let v = Value::Date(DateTimeValue {
661            year: 2024,
662            month: 1,
663            day: 15,
664            hour: 0,
665            minute: 0,
666            second: 0,
667            microsecond: 0,
668            timezone: None,
669
670            granularity: DateGranularity::Full,
671        });
672        assert_eq!(fmt_value(&v), "2024-01-15");
673    }
674
675    #[test]
676    fn test_format_value_datetime_with_tz() {
677        let v = Value::Date(DateTimeValue {
678            year: 2024,
679            month: 1,
680            day: 15,
681            hour: 14,
682            minute: 30,
683            second: 0,
684            microsecond: 0,
685            timezone: Some(TimezoneValue {
686                offset_hours: 0,
687                offset_minutes: 0,
688            }),
689
690            granularity: DateGranularity::DateTime,
691        });
692        assert_eq!(fmt_value(&v), "2024-01-15T14:30:00Z");
693    }
694
695    #[test]
696    fn test_format_value_time() {
697        let v = Value::Time(TimeValue {
698            hour: 14,
699            minute: 30,
700            second: 45,
701            microsecond: 0,
702            timezone: None,
703        });
704        assert_eq!(fmt_value(&v), "14:30:45");
705    }
706
707    #[test]
708    fn test_format_source_preserves_date_granularity() {
709        let formatted = format_source(
710            "spec x 2026\n",
711            crate::parsing::source::SourceType::Volatile,
712        )
713        .expect("spec x 2026 should format");
714        assert!(
715            formatted.contains("spec x 2026\n"),
716            "year-only effective date must round-trip, got: {formatted}"
717        );
718        assert!(
719            !formatted.contains("2026-01-01"),
720            "year-only effective date must not expand, got: {formatted}"
721        );
722        let reformatted = format_source(&formatted, crate::parsing::source::SourceType::Volatile)
723            .expect("reformat");
724        assert_eq!(formatted, reformatted, "spec x 2026 must be idempotent");
725
726        let formatted = format_source(
727            "spec x 2026-03\n",
728            crate::parsing::source::SourceType::Volatile,
729        )
730        .expect("spec x 2026-03 should format");
731        assert!(
732            formatted.contains("spec x 2026-03\n"),
733            "year-month effective date must round-trip, got: {formatted}"
734        );
735
736        let formatted = format_source(
737            "spec x 2026-W34\n",
738            crate::parsing::source::SourceType::Volatile,
739        )
740        .expect("spec x 2026-W34 should format");
741        assert!(
742            formatted.contains("spec x 2026-W34\n"),
743            "iso week effective date must round-trip, got: {formatted}"
744        );
745
746        let source = "spec consumer\nuses finance 2026\n";
747        let formatted = format_source(source, crate::parsing::source::SourceType::Volatile)
748            .expect("uses with year should format");
749        assert!(
750            formatted.contains("uses finance 2026"),
751            "uses effective pin must preserve year-only date, got: {formatted}"
752        );
753        assert!(
754            !formatted.contains("2026-01-01"),
755            "uses effective pin must not expand year-only date, got: {formatted}"
756        );
757    }
758
759    #[test]
760    fn test_format_source_lowercases_logical_identifiers() {
761        let source = r#"spec Test
762data Price: number -> suggest 1
763rule Total: price
764"#;
765        let formatted =
766            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
767        assert!(formatted.contains("spec test"), "got: {formatted}");
768        assert!(formatted.contains("data price"), "got: {formatted}");
769        assert!(formatted.contains("rule total"), "got: {formatted}");
770    }
771
772    #[test]
773    fn test_format_source_round_trips_text() {
774        let source = r#"spec test
775
776data name: "Alice"
777
778rule greeting: "hello"
779"#;
780        let formatted =
781            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
782        assert!(formatted.contains("\"Alice\""), "data text must be quoted");
783        assert!(formatted.contains("\"hello\""), "rule text must be quoted");
784    }
785
786    #[test]
787    fn test_format_source_preserves_percent() {
788        let source = r#"spec test
789
790data rate: 10 percent
791
792rule tax: rate * 21%
793"#;
794        let formatted =
795            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
796        assert!(
797            formatted.contains("10%"),
798            "data percent must use shorthand %, got: {}",
799            formatted
800        );
801    }
802
803    #[test]
804    fn test_format_groups_data_preserving_order() {
805        // Data are deliberately mixed: the formatter keeps all regular data together
806        // in original order, aligned
807        let source = r#"spec test
808
809data income: number -> minimum 0
810data filing_status: filing_status_type -> suggest "single"
811data country: "NL"
812data deductions: number -> minimum 0
813data name: text
814
815rule total: income
816"#;
817        let formatted =
818            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
819        let data_section = formatted
820            .split("rule total")
821            .next()
822            .unwrap()
823            .split("spec test\n")
824            .nth(1)
825            .unwrap();
826        let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
827        // Constrained rows: one blank line after each when more `data` follows.
828        assert_eq!(lines[0], "data income: number");
829        assert_eq!(lines[1], "  -> minimum 0");
830        assert_eq!(lines[2], "data filing_status: filing_status_type");
831        assert_eq!(lines[3], "  -> suggest \"single\"");
832        assert_eq!(lines[4], "data country: \"NL\"");
833        assert_eq!(lines[5], "data deductions: number");
834        assert_eq!(lines[6], "  -> minimum 0");
835        assert_eq!(lines[7], "data name: text");
836    }
837
838    #[test]
839    fn test_format_groups_spec_refs_with_overrides() {
840        let source = r#"spec test
841
842with retail.quantity: 5
843uses order wholesale
844uses order retail
845with wholesale.quantity: 100
846data base_price: 50
847
848rule total: base_price
849"#;
850        let formatted =
851            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
852        let data_section = formatted
853            .split("rule total")
854            .next()
855            .unwrap()
856            .split("spec test\n")
857            .nth(1)
858            .unwrap();
859        let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
860        assert_eq!(lines[0], "uses order wholesale");
861        assert_eq!(lines[1], "with wholesale.quantity: 100");
862        assert_eq!(lines[2], "uses order retail");
863        assert_eq!(lines[3], "with retail.quantity: 5");
864        assert_eq!(lines[4], "data base_price: 50");
865    }
866
867    #[test]
868    fn test_format_groups_with_literals_under_each_uses() {
869        let source = r#"spec test
870
871uses x
872uses y
873
874with x.name: "Ben"
875with y.age: 15
876
877rule r: 1
878"#;
879        let formatted =
880            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
881        let data_section = formatted
882            .split("rule r")
883            .next()
884            .unwrap()
885            .split("spec test\n")
886            .nth(1)
887            .unwrap();
888        let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
889        assert_eq!(lines[0], "uses x");
890        assert_eq!(lines[1], "with x.name: \"Ben\"");
891        assert_eq!(lines[2], "uses y");
892        assert_eq!(lines[3], "with y.age: 15");
893    }
894
895    #[test]
896    fn test_format_source_weather_clothing_text_quoted() {
897        let source = r#"spec weather_clothing
898
899data clothing_style: text
900  -> option "light"
901  -> option "warm"
902
903data temperature: number
904
905rule clothing_layer: "light"
906  unless temperature < 5 then "warm"
907"#;
908        let formatted =
909            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
910        assert!(
911            formatted.contains("\"light\""),
912            "text in rule must be quoted, got: {}",
913            formatted
914        );
915        assert!(
916            formatted.contains("\"warm\""),
917            "text in unless must be quoted, got: {}",
918            formatted
919        );
920    }
921
922    // NOTE: Default value type validation (e.g. rejecting "10 $$" as a number
923    // default) is tested at the planning level in engine.rs, not here. The
924    // formatter only parses — it does not validate types. Planning catches
925    // invalid defaults for both primitives and named types.
926
927    #[test]
928    fn test_format_text_option_round_trips() {
929        let source = r#"spec test
930
931data status: text
932  -> option "active"
933  -> option "inactive"
934
935data s: status
936
937rule out: s
938"#;
939        let formatted =
940            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
941        assert!(
942            formatted.contains("option \"active\""),
943            "text option must be quoted, got: {}",
944            formatted
945        );
946        assert!(
947            formatted.contains("option \"inactive\""),
948            "text option must be quoted, got: {}",
949            formatted
950        );
951        // Round-trip
952        let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
953        assert!(reparsed.is_ok(), "formatted output should re-parse");
954    }
955
956    #[test]
957    fn test_format_help_round_trips() {
958        let source = r#"spec test
959data quantity: number -> help "Number of items to order"
960rule total: quantity
961"#;
962        let formatted =
963            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
964        assert!(
965            formatted.contains("help \"Number of items to order\""),
966            "help must be quoted, got: {}",
967            formatted
968        );
969        // Round-trip
970        let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
971        assert!(reparsed.is_ok(), "formatted output should re-parse");
972    }
973
974    #[test]
975    fn test_format_measure_type_def_round_trips() {
976        let source = r#"spec test
977
978data money: measure
979  -> unit eur 1.00
980  -> unit usd 0.91
981  -> decimals 2
982  -> minimum 0
983
984data price: money
985
986rule total: price
987"#;
988        let formatted =
989            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
990        assert!(
991            formatted.contains("unit eur 1.00"),
992            "measure unit should not be quoted, got: {}",
993            formatted
994        );
995        // Round-trip
996        let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
997        assert!(
998            reparsed.is_ok(),
999            "formatted output should re-parse, got: {:?}",
1000            reparsed
1001        );
1002    }
1003
1004    #[test]
1005    fn test_format_expression_display_stable_round_trip() {
1006        let source = r#"spec test
1007data a: 1.00
1008rule r: a + 2.00 * 3
1009"#;
1010        let formatted =
1011            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1012        let again =
1013            format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
1014        assert_eq!(
1015            formatted, again,
1016            "AST Display-based format must be idempotent under parse/format"
1017        );
1018    }
1019
1020    #[test]
1021    fn test_format_past_future_range_no_duplicate_in() {
1022        let source = r#"spec test
1023data start: date
1024data length: duration
1025rule valid: start in past length
1026rule window: past length
1027"#;
1028        let formatted =
1029            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1030        assert!(
1031            formatted.contains("rule valid: start in past length"),
1032            "RangeContainment+PastFutureRange must not emit duplicate 'in', got:\n{formatted}"
1033        );
1034        assert!(
1035            formatted.contains("rule window: past length"),
1036            "bare PastFutureRange must print 'past' not 'in past', got:\n{formatted}"
1037        );
1038        assert!(
1039            !formatted.contains("in in past"),
1040            "must not contain duplicate 'in', got:\n{formatted}"
1041        );
1042    }
1043
1044    #[test]
1045    fn test_format_rule_default_on_same_line_when_fits() {
1046        let source = "spec test\nrule r: 1\n";
1047        let formatted =
1048            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1049        assert!(
1050            formatted.contains("rule r: 1\n"),
1051            "default expr should stay on rule line when under MAX_COLS, got:\n{formatted}"
1052        );
1053    }
1054
1055    #[test]
1056    fn test_format_rule_unless_single_line_when_short() {
1057        let source = r#"spec test
1058data a: number
1059data b: boolean
1060
1061rule r: no
1062  unless a < 1 then yes
1063  unless b then yes
1064"#;
1065        let formatted =
1066            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1067        assert!(
1068            formatted.contains("unless a < 1 then yes")
1069                && formatted.contains("unless b     then yes"),
1070            "unless stays on one line when under MAX_COLS, got:\n{formatted}"
1071        );
1072    }
1073}