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    expression_precedence, AsLemmaSource, Constraint, DataValue, Expression, ExpressionKind,
9    LemmaData, LemmaRule, LemmaSpec,
10};
11use crate::{parse, Error, ParseResult, ResourceLimits};
12
13/// Soft line length limit. Longer lines may be wrapped (unless clauses, expressions).
14/// Data and other constructs are not broken if they exceed this.
15/// 56 has been chosen to fit on an average mobile screen with an 11pt font.
16pub const MAX_COLS: usize = 56;
17
18// =============================================================================
19// Public entry points
20// =============================================================================
21
22/// Format a sequence of parsed specs into canonical Lemma source.
23///
24/// specs are separated by two blank lines.
25/// The result ends with a single newline.
26#[must_use]
27pub fn format_specs(specs: &[LemmaSpec]) -> String {
28    let refs: Vec<&LemmaSpec> = specs.iter().collect();
29    format_spec_refs(&refs)
30}
31
32/// Like [`format_specs`] for borrowed specs (e.g. from [`Arc<LemmaSpec>`](crate::parsing::ast::LemmaSpec)).
33#[must_use]
34pub fn format_spec_refs(specs: &[&LemmaSpec]) -> String {
35    let mut out = String::new();
36    for (index, spec) in specs.iter().enumerate() {
37        if index > 0 {
38            out.push_str("\n\n");
39        }
40        out.push_str(&format_spec(spec, MAX_COLS));
41    }
42    if !out.ends_with('\n') {
43        out.push('\n');
44    }
45    out
46}
47
48/// Format a [`ParseResult`] (repository groups + specs) into canonical Lemma source.
49#[must_use]
50pub fn format_parse_result(result: &ParseResult) -> String {
51    let mut blocks: Vec<String> = Vec::new();
52    for (repo, specs) in &result.repositories {
53        let mut prefix = String::new();
54        if let Some(name) = repo.name.as_deref() {
55            prefix.push_str("repo ");
56            prefix.push_str(name);
57            prefix.push_str("\n\n");
58        }
59        if specs.is_empty() {
60            if !prefix.is_empty() {
61                blocks.push(prefix);
62            }
63            continue;
64        }
65        let body = format_specs(specs.as_slice());
66        if prefix.is_empty() {
67            blocks.push(body);
68        } else {
69            prefix.push_str(&body);
70            blocks.push(prefix);
71        }
72    }
73    let mut out = blocks.join("\n\n");
74    if !out.ends_with('\n') {
75        out.push('\n');
76    }
77    out
78}
79
80/// Parse a source string and format it to canonical Lemma source.
81///
82/// Returns an error if the source does not parse.
83pub fn format_source(
84    source: &str,
85    source_type: crate::parsing::source::SourceType,
86) -> Result<String, Error> {
87    let limits = ResourceLimits::default();
88    let result = parse(source, source_type, &limits)?;
89    Ok(format_parse_result(&result))
90}
91
92// =============================================================================
93// Spec
94// =============================================================================
95
96pub(crate) fn format_spec(spec: &LemmaSpec, max_cols: usize) -> String {
97    let mut out = String::new();
98    out.push_str("spec ");
99    out.push_str(&spec.name);
100    if let crate::parsing::ast::EffectiveDate::DateTimeValue(ref af) = spec.effective_from {
101        out.push(' ');
102        out.push_str(&af.to_string());
103    }
104    out.push('\n');
105
106    if let Some(ref commentary) = spec.commentary {
107        out.push_str("\"\"\"\n");
108        out.push_str(commentary);
109        out.push_str("\n\"\"\"\n");
110    }
111
112    for meta in &spec.meta_fields {
113        out.push_str(&format!(
114            "meta {}: {}\n",
115            meta.key,
116            AsLemmaSource(&meta.value)
117        ));
118    }
119
120    if !spec.data.is_empty() {
121        format_sorted_data(&spec.data, &mut out, "");
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::With(_) => false,
156        _ => false,
157    }
158}
159
160fn data_value_rhs_for_spec_body(value: &DataValue, continuation_prefix: &str) -> String {
161    match value {
162        DataValue::Definition {
163            base,
164            constraints,
165            value,
166        } if data_constraints_nonempty(constraints) => {
167            let cs = constraints
168                .as_ref()
169                .expect("BUG: constraints checked above");
170            let head: String = if base.is_none() {
171                match value {
172                    Some(v) => format!("{}", AsLemmaSource(v)),
173                    None => String::new(),
174                }
175            } else {
176                match base.as_ref() {
177                    Some(b) => format!("{}", b),
178                    None => String::new(),
179                }
180            };
181            let mut out = head;
182            for (cmd, args) in cs {
183                out.push('\n');
184                out.push_str(continuation_prefix);
185                out.push_str("-> ");
186                out.push_str(&crate::parsing::ast::format_constraint_as_source(cmd, args));
187            }
188            out
189        }
190        DataValue::With(crate::parsing::ast::WithRhs::Reference { target }) => target.to_string(),
191        _ => format!("{}", AsLemmaSource(value)),
192    }
193}
194
195fn data_declaration_keyword(data: &LemmaData) -> &'static str {
196    match &data.value {
197        DataValue::Import(_) => unreachable!("BUG: format_data called on Import row"),
198        DataValue::With(_) => "with",
199        DataValue::Definition { .. } => "data",
200    }
201}
202
203fn format_data(data: &LemmaData, line_prefix: &str) -> String {
204    let kw = data_declaration_keyword(data);
205    let ref_str = format!("{}", data.reference);
206    let continuation = format!("{line_prefix}{DATA_CONSTRAINT_INDENT}");
207    let rhs = data_value_rhs_for_spec_body(&data.value, &continuation);
208    if let Some((first, rest)) = rhs.split_once('\n') {
209        format!("{kw} {}: {}\n{}", ref_str, first, rest)
210    } else {
211        format!("{kw} {}: {}", ref_str, rhs)
212    }
213}
214
215/// Byte length from start of `data ` or `with ` through the single space after `:` (same layout as [`format_data`]).
216fn data_line_prefix_len_before_rhs(keyword: &str, ref_str: &str) -> usize {
217    keyword.len() + 1 + ref_str.len() + 2
218}
219
220fn data_is_simple_single_line(data: &LemmaData, line_prefix: &str) -> bool {
221    if data_value_has_arrow_constraints(&data.value) {
222        return false;
223    }
224    let continuation = format!("{line_prefix}{DATA_CONSTRAINT_INDENT}");
225    let rhs = data_value_rhs_for_spec_body(&data.value, &continuation);
226    !rhs.contains('\n')
227}
228
229fn push_formatted_simple_data_line_padded(
230    out: &mut String,
231    data: &LemmaData,
232    line_prefix: &str,
233    target_prefix_len_before_rhs: usize,
234) {
235    let kw = data_declaration_keyword(data);
236    let ref_str = format!("{}", data.reference);
237    let continuation = format!("{line_prefix}{DATA_CONSTRAINT_INDENT}");
238    let rhs = data_value_rhs_for_spec_body(&data.value, &continuation);
239    let base = data_line_prefix_len_before_rhs(kw, &ref_str);
240    let gap = 1 + target_prefix_len_before_rhs.saturating_sub(base);
241    out.push_str(line_prefix);
242    out.push_str(kw);
243    out.push(' ');
244    out.push_str(&ref_str);
245    out.push(':');
246    out.push_str(&" ".repeat(gap));
247    out.push_str(&rhs);
248}
249
250fn emit_data_row_group(rows: &[&LemmaData], line_prefix: &str, out: &mut String) {
251    let mut i = 0;
252    while i < rows.len() {
253        if data_is_simple_single_line(rows[i], line_prefix) {
254            let run_start = i;
255            i += 1;
256            while i < rows.len() && data_is_simple_single_line(rows[i], line_prefix) {
257                i += 1;
258            }
259            let run_end = i;
260            let target = (run_start..run_end)
261                .map(|k| {
262                    let row = rows[k];
263                    let kw = data_declaration_keyword(row);
264                    let ref_str = format!("{}", row.reference);
265                    data_line_prefix_len_before_rhs(kw, &ref_str)
266                })
267                .max()
268                .expect("BUG: non-empty run");
269            for row in rows[run_start..run_end].iter().copied() {
270                push_formatted_simple_data_line_padded(out, row, line_prefix, target);
271                out.push('\n');
272            }
273        } else {
274            let row = rows[i];
275            out.push_str(line_prefix);
276            out.push_str(&format_data(row, line_prefix));
277            out.push('\n');
278            if data_value_has_arrow_constraints(&row.value) && i + 1 < rows.len() {
279                out.push('\n');
280            }
281            i += 1;
282        }
283    }
284}
285
286fn format_import_row(data: &LemmaData) -> String {
287    let alias = &data.reference.name;
288    if let DataValue::Import(spec_ref) = &data.value {
289        let spec_name = &spec_ref.name;
290        let last_segment = spec_name.rsplit('/').next().unwrap_or(spec_name);
291        if alias == last_segment {
292            format!("uses {}", spec_ref)
293        } else {
294            format!("uses {}: {}", alias, spec_ref)
295        }
296    } else {
297        unreachable!("BUG: format_import_row called on non-Import data")
298    }
299}
300
301/// Group data into sections separated by blank lines:
302///
303/// 1. Imports (`uses`), each followed by their literal bindings — original order within this block
304/// 2. Regular data (literals, type declarations, references) — original order
305/// 3. Qualified overrides that did not attach to any import — original order
306fn format_sorted_data(data: &[LemmaData], out: &mut String, line_prefix: &str) {
307    let mut regular: Vec<&LemmaData> = Vec::new();
308    let mut imports: Vec<&LemmaData> = Vec::new();
309    let mut overrides: Vec<&LemmaData> = Vec::new();
310
311    for data in data {
312        if !data.reference.is_local() {
313            overrides.push(data);
314        } else if matches!(&data.value, DataValue::Import(_)) {
315            imports.push(data);
316        } else {
317            regular.push(data);
318        }
319    }
320
321    let emit_group =
322        |rows: &[&LemmaData], out: &mut String| emit_data_row_group(rows, line_prefix, out);
323
324    if !imports.is_empty() {
325        out.push('\n');
326
327        for (i, row) in imports.iter().enumerate() {
328            if i > 0 {
329                out.push('\n');
330            }
331            out.push_str(line_prefix);
332            out.push_str(&format_import_row(row));
333            out.push('\n');
334            let ref_name = &row.reference.name;
335            let binding_overrides: Vec<&LemmaData> = overrides
336                .iter()
337                .filter(|o| {
338                    o.reference.segments.first().map(|s| s.as_str()) == Some(ref_name.as_str())
339                })
340                .copied()
341                .collect();
342            if !binding_overrides.is_empty() {
343                emit_data_row_group(&binding_overrides, line_prefix, out);
344            }
345        }
346    }
347
348    if !regular.is_empty() {
349        out.push('\n');
350        emit_group(&regular, out);
351    }
352
353    let matched_prefixes: Vec<&str> = imports.iter().map(|f| f.reference.name.as_str()).collect();
354    let unmatched: Vec<&LemmaData> = overrides
355        .iter()
356        .filter(|o| {
357            o.reference
358                .segments
359                .first()
360                .map(|s| !matched_prefixes.contains(&s.as_str()))
361                .unwrap_or(true)
362        })
363        .copied()
364        .collect();
365    if !unmatched.is_empty() {
366        out.push('\n');
367        emit_group(&unmatched, out);
368    }
369}
370
371// =============================================================================
372// Rules
373// =============================================================================
374
375const UNLESS_LINE_PREFIX: &str = "  unless ";
376
377/// Logical line length for `max_cols` checks (no extra spec-level indent).
378#[inline]
379fn spec_line_len(line: &str) -> usize {
380    line.len()
381}
382
383/// Default expression stays on the `rule name:` line when it fits under `max_cols`.
384///
385/// Single-line `unless … then …` clauses align `then` when every such line still fits under
386/// `max_cols` after alignment. Any clause that splits across lines (expression wraps, or one line
387/// would exceed `max_cols`) uses a fixed `then` indent — no column alignment with shorter sisters.
388fn format_rule(rule: &LemmaRule, max_cols: usize) -> String {
389    let expr_indent = "  ";
390    let body = format_expr_wrapped(&rule.expression, max_cols, expr_indent, 10);
391    let mut out = String::new();
392    out.push_str("rule ");
393    out.push_str(&rule.name);
394    let body_single_line = !body.contains('\n');
395    let header_fits_on_one_line =
396        body_single_line && spec_line_len(&format!("rule {}: {}", rule.name, body)) <= max_cols;
397    if header_fits_on_one_line {
398        out.push_str(": ");
399        out.push_str(&body);
400    } else {
401        out.push_str(":\n");
402        out.push_str(expr_indent);
403        out.push_str(&body);
404    }
405
406    let pl = UNLESS_LINE_PREFIX.len();
407    let naive_single_len = |cond: &str, res: &str| pl + cond.len() + 6 + res.len();
408    let aligned_single_len = |res: &str, max_end: usize| max_end + 6 + res.len();
409
410    let mut clauses: Vec<(String, String, bool)> = Vec::new();
411    for unless_clause in &rule.unless_clauses {
412        let condition = format_expr_wrapped(&unless_clause.condition, max_cols, "    ", 10);
413        let result = format_expr_wrapped(&unless_clause.result, max_cols, "    ", 10);
414        let multiline = condition.contains('\n') || result.contains('\n');
415        clauses.push((condition, result, multiline));
416    }
417
418    let mut singles: Vec<usize> = clauses
419        .iter()
420        .enumerate()
421        .filter(|(_, (c, r, m))| !*m && naive_single_len(c, r) <= max_cols)
422        .map(|(i, _)| i)
423        .collect();
424
425    loop {
426        if singles.is_empty() {
427            break;
428        }
429        let max_end = singles
430            .iter()
431            .map(|&i| pl + clauses[i].0.len())
432            .max()
433            .expect("BUG: singles non-empty");
434        let before = singles.len();
435        singles.retain(|&i| aligned_single_len(&clauses[i].1, max_end) <= max_cols);
436        if singles.len() == before {
437            break;
438        }
439    }
440
441    let align_max_end = singles.iter().map(|&i| pl + clauses[i].0.len()).max();
442    const SPLIT_THEN_INDENT_SPACES: usize = 4;
443
444    for (i, (condition, result, multiline)) in clauses.iter().enumerate() {
445        if *multiline {
446            out.push_str("\n  unless ");
447            out.push_str(condition);
448            out.push('\n');
449            out.push_str(&" ".repeat(SPLIT_THEN_INDENT_SPACES));
450            out.push_str("then ");
451            out.push_str(result);
452            continue;
453        }
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            continue;
464        }
465        out.push_str("\n  unless ");
466        out.push_str(condition);
467        out.push('\n');
468        out.push_str(&" ".repeat(SPLIT_THEN_INDENT_SPACES));
469        out.push_str("then ");
470        out.push_str(result);
471    }
472    out.push('\n');
473    out
474}
475
476// =============================================================================
477// Expression wrapping (soft line breaking at max_cols)
478// =============================================================================
479
480/// Indent every line after the first by `indent`.
481fn indent_after_first_line(s: &str, indent: &str) -> String {
482    let mut first = true;
483    let mut out = String::new();
484    for line in s.lines() {
485        if first {
486            first = false;
487            out.push_str(line);
488        } else {
489            out.push('\n');
490            out.push_str(indent);
491            out.push_str(line);
492        }
493    }
494    if s.ends_with('\n') {
495        out.push('\n');
496    }
497    out
498}
499
500/// Format an expression with optional wrapping at arithmetic operators when over max_cols.
501/// `parent_prec` is used to add parentheses when needed (pass 10 for top level).
502fn format_expr_wrapped(
503    expr: &Expression,
504    max_cols: usize,
505    indent: &str,
506    parent_prec: u8,
507) -> String {
508    let my_prec = expression_precedence(&expr.kind);
509
510    let wrap_in_parens = |s: String| {
511        if parent_prec < 10 && my_prec < parent_prec {
512            format!("({})", s)
513        } else {
514            s
515        }
516    };
517
518    match &expr.kind {
519        ExpressionKind::Arithmetic(left, op, right) => {
520            let left_str = format_expr_wrapped(left.as_ref(), max_cols, indent, my_prec);
521            let right_str = format_expr_wrapped(right.as_ref(), max_cols, indent, my_prec);
522            let single_line = format!("{} {} {}", left_str, op, right_str);
523            if single_line.len() <= max_cols && !single_line.contains('\n') {
524                return wrap_in_parens(single_line);
525            }
526            let continued_right = indent_after_first_line(&right_str, indent);
527            let continuation = format!("{}{} {}", indent, op, continued_right);
528            let multi_line = format!("{}\n{}", left_str, continuation);
529            wrap_in_parens(multi_line)
530        }
531        _ => {
532            let s = expr.to_string();
533            wrap_in_parens(s)
534        }
535    }
536}
537
538// =============================================================================
539// Tests
540// =============================================================================
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545    use crate::parsing::ast::{
546        AsLemmaSource, BooleanValue, DateTimeValue, TimeValue, TimezoneValue, Value,
547    };
548    use rust_decimal::prelude::FromStr;
549    use rust_decimal::Decimal;
550
551    /// Helper: format a Value as canonical Lemma source via AsLemmaSource.
552    fn fmt_value(v: &Value) -> String {
553        format!("{}", AsLemmaSource(v))
554    }
555
556    #[test]
557    fn test_format_value_text_is_quoted() {
558        let v = Value::Text("light".to_string());
559        assert_eq!(fmt_value(&v), "\"light\"");
560    }
561
562    #[test]
563    fn test_format_value_text_escapes_quotes() {
564        let v = Value::Text("say \"hello\"".to_string());
565        assert_eq!(fmt_value(&v), "\"say \\\"hello\\\"\"");
566    }
567
568    #[test]
569    fn test_format_value_number() {
570        let v = Value::Number(Decimal::from_str("42.50").unwrap());
571        assert_eq!(fmt_value(&v), "42.50");
572    }
573
574    #[test]
575    fn test_format_value_number_integer() {
576        let v = Value::Number(Decimal::from_str("100.00").unwrap());
577        assert_eq!(fmt_value(&v), "100");
578    }
579
580    #[test]
581    fn test_format_value_boolean() {
582        assert_eq!(fmt_value(&Value::Boolean(BooleanValue::True)), "true");
583        assert_eq!(fmt_value(&Value::Boolean(BooleanValue::Yes)), "yes");
584        assert_eq!(fmt_value(&Value::Boolean(BooleanValue::No)), "no");
585        assert_eq!(fmt_value(&Value::Boolean(BooleanValue::Accept)), "accept");
586        assert_eq!(fmt_value(&Value::Boolean(BooleanValue::Reject)), "reject");
587    }
588
589    #[test]
590    fn test_format_value_quantity() {
591        let v = Value::NumberWithUnit(Decimal::from_str("99.50").unwrap(), "eur".to_string());
592        assert_eq!(fmt_value(&v), "99.50 eur");
593    }
594
595    #[test]
596    fn test_format_value_duration_as_quantity() {
597        let v = Value::NumberWithUnit(Decimal::from(40), "hours".to_string());
598        assert_eq!(fmt_value(&v), "40 hours");
599    }
600
601    #[test]
602    fn test_format_value_calendar() {
603        let v = Value::NumberWithUnit(Decimal::from(6), "month".to_string());
604        assert_eq!(fmt_value(&v), "6 month");
605    }
606
607    #[test]
608    fn test_format_value_ratio_percent() {
609        let v = Value::NumberWithUnit(Decimal::from_str("10").unwrap(), "percent".to_string());
610        assert_eq!(fmt_value(&v), "10%");
611    }
612
613    #[test]
614    fn test_format_value_ratio_permille() {
615        let v = Value::NumberWithUnit(Decimal::from_str("5").unwrap(), "permille".to_string());
616        assert_eq!(fmt_value(&v), "5%%");
617    }
618
619    #[test]
620    fn test_format_value_number_with_unit_named() {
621        let v = Value::NumberWithUnit(
622            Decimal::from_str("500").unwrap(),
623            "basis_points".to_string(),
624        );
625        assert_eq!(fmt_value(&v), "500 basis_points");
626    }
627
628    #[test]
629    fn test_format_value_date_only() {
630        let v = Value::Date(DateTimeValue {
631            year: 2024,
632            month: 1,
633            day: 15,
634            hour: 0,
635            minute: 0,
636            second: 0,
637            microsecond: 0,
638            timezone: None,
639        });
640        assert_eq!(fmt_value(&v), "2024-01-15");
641    }
642
643    #[test]
644    fn test_format_value_datetime_with_tz() {
645        let v = Value::Date(DateTimeValue {
646            year: 2024,
647            month: 1,
648            day: 15,
649            hour: 14,
650            minute: 30,
651            second: 0,
652            microsecond: 0,
653            timezone: Some(TimezoneValue {
654                offset_hours: 0,
655                offset_minutes: 0,
656            }),
657        });
658        assert_eq!(fmt_value(&v), "2024-01-15T14:30:00Z");
659    }
660
661    #[test]
662    fn test_format_value_time() {
663        let v = Value::Time(TimeValue {
664            hour: 14,
665            minute: 30,
666            second: 45,
667            microsecond: 0,
668            timezone: None,
669        });
670        assert_eq!(fmt_value(&v), "14:30:45");
671    }
672
673    #[test]
674    fn test_format_source_lowercases_logical_identifiers() {
675        let source = r#"spec Test
676data Price: number -> default 1
677rule Total: price
678"#;
679        let formatted =
680            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
681        assert!(formatted.contains("spec test"), "got: {formatted}");
682        assert!(formatted.contains("data price"), "got: {formatted}");
683        assert!(formatted.contains("rule total"), "got: {formatted}");
684    }
685
686    #[test]
687    fn test_format_source_round_trips_text() {
688        let source = r#"spec test
689
690data name: "Alice"
691
692rule greeting: "hello"
693"#;
694        let formatted =
695            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
696        assert!(formatted.contains("\"Alice\""), "data text must be quoted");
697        assert!(formatted.contains("\"hello\""), "rule text must be quoted");
698    }
699
700    #[test]
701    fn test_format_source_preserves_percent() {
702        let source = r#"spec test
703
704data rate: 10 percent
705
706rule tax: rate * 21%
707"#;
708        let formatted =
709            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
710        assert!(
711            formatted.contains("10%"),
712            "data percent must use shorthand %, got: {}",
713            formatted
714        );
715    }
716
717    #[test]
718    fn test_format_groups_data_preserving_order() {
719        // Data are deliberately mixed: the formatter keeps all regular data together
720        // in original order, aligned
721        let source = r#"spec test
722
723data income: number -> minimum 0
724data filing_status: filing_status_type -> default "single"
725data country: "NL"
726data deductions: number -> minimum 0
727data name: text
728
729rule total: income
730"#;
731        let formatted =
732            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
733        let data_section = formatted
734            .split("rule total")
735            .next()
736            .unwrap()
737            .split("spec test\n")
738            .nth(1)
739            .unwrap();
740        let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
741        // Constrained rows: one blank line after each when more `data` follows.
742        assert_eq!(lines[0], "data income: number");
743        assert_eq!(lines[1], "  -> minimum 0");
744        assert_eq!(lines[2], "data filing_status: filing_status_type");
745        assert_eq!(lines[3], "  -> default \"single\"");
746        assert_eq!(lines[4], "data country: \"NL\"");
747        assert_eq!(lines[5], "data deductions: number");
748        assert_eq!(lines[6], "  -> minimum 0");
749        assert_eq!(lines[7], "data name: text");
750    }
751
752    #[test]
753    fn test_format_groups_spec_refs_with_overrides() {
754        let source = r#"spec test
755
756with retail.quantity: 5
757uses order wholesale
758uses order retail
759with wholesale.quantity: 100
760data base_price: 50
761
762rule total: base_price
763"#;
764        let formatted =
765            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
766        let data_section = formatted
767            .split("rule total")
768            .next()
769            .unwrap()
770            .split("spec test\n")
771            .nth(1)
772            .unwrap();
773        let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
774        assert_eq!(lines[0], "uses order wholesale");
775        assert_eq!(lines[1], "with wholesale.quantity: 100");
776        assert_eq!(lines[2], "uses order retail");
777        assert_eq!(lines[3], "with retail.quantity: 5");
778        assert_eq!(lines[4], "data base_price: 50");
779    }
780
781    #[test]
782    fn test_format_groups_with_literals_under_each_uses() {
783        let source = r#"spec test
784
785uses x
786uses y
787
788with x.name: "Ben"
789with y.age: 15
790
791rule r: 1
792"#;
793        let formatted =
794            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
795        let data_section = formatted
796            .split("rule r")
797            .next()
798            .unwrap()
799            .split("spec test\n")
800            .nth(1)
801            .unwrap();
802        let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
803        assert_eq!(lines[0], "uses x");
804        assert_eq!(lines[1], "with x.name: \"Ben\"");
805        assert_eq!(lines[2], "uses y");
806        assert_eq!(lines[3], "with y.age: 15");
807    }
808
809    #[test]
810    fn test_format_source_weather_clothing_text_quoted() {
811        let source = r#"spec weather_clothing
812
813data clothing_style: text
814  -> option "light"
815  -> option "warm"
816
817data temperature: number
818
819rule clothing_layer: "light"
820  unless temperature < 5 then "warm"
821"#;
822        let formatted =
823            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
824        assert!(
825            formatted.contains("\"light\""),
826            "text in rule must be quoted, got: {}",
827            formatted
828        );
829        assert!(
830            formatted.contains("\"warm\""),
831            "text in unless must be quoted, got: {}",
832            formatted
833        );
834    }
835
836    // NOTE: Default value type validation (e.g. rejecting "10 $$" as a number
837    // default) is tested at the planning level in engine.rs, not here. The
838    // formatter only parses — it does not validate types. Planning catches
839    // invalid defaults for both primitives and named types.
840
841    #[test]
842    fn test_format_text_option_round_trips() {
843        let source = r#"spec test
844
845data status: text
846  -> option "active"
847  -> option "inactive"
848
849data s: status
850
851rule out: s
852"#;
853        let formatted =
854            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
855        assert!(
856            formatted.contains("option \"active\""),
857            "text option must be quoted, got: {}",
858            formatted
859        );
860        assert!(
861            formatted.contains("option \"inactive\""),
862            "text option must be quoted, got: {}",
863            formatted
864        );
865        // Round-trip
866        let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
867        assert!(reparsed.is_ok(), "formatted output should re-parse");
868    }
869
870    #[test]
871    fn test_format_help_round_trips() {
872        let source = r#"spec test
873data quantity: number -> help "Number of items to order"
874rule total: quantity
875"#;
876        let formatted =
877            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
878        assert!(
879            formatted.contains("help \"Number of items to order\""),
880            "help must be quoted, got: {}",
881            formatted
882        );
883        // Round-trip
884        let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
885        assert!(reparsed.is_ok(), "formatted output should re-parse");
886    }
887
888    #[test]
889    fn test_format_quantity_type_def_round_trips() {
890        let source = r#"spec test
891
892data money: quantity
893  -> unit eur 1.00
894  -> unit usd 0.91
895  -> decimals 2
896  -> minimum 0
897
898data price: money
899
900rule total: price
901"#;
902        let formatted =
903            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
904        assert!(
905            formatted.contains("unit eur 1.00"),
906            "quantity unit should not be quoted, got: {}",
907            formatted
908        );
909        // Round-trip
910        let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
911        assert!(
912            reparsed.is_ok(),
913            "formatted output should re-parse, got: {:?}",
914            reparsed
915        );
916    }
917
918    #[test]
919    fn test_format_expression_display_stable_round_trip() {
920        let source = r#"spec test
921data a: 1.00
922rule r: a + 2.00 * 3
923"#;
924        let formatted =
925            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
926        let again =
927            format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
928        assert_eq!(
929            formatted, again,
930            "AST Display-based format must be idempotent under parse/format"
931        );
932    }
933
934    #[test]
935    fn test_format_rule_default_on_same_line_when_fits() {
936        let source = "spec test\nrule r: 1\n";
937        let formatted =
938            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
939        assert!(
940            formatted.contains("rule r: 1\n"),
941            "default expr should stay on rule line when under MAX_COLS, got:\n{formatted}"
942        );
943    }
944
945    #[test]
946    fn test_format_rule_unless_single_line_when_short() {
947        let source = r#"spec test
948data a: number
949data b: boolean
950
951rule r: no
952  unless a < 1 then yes
953  unless b then yes
954"#;
955        let formatted =
956            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
957        assert!(
958            formatted.contains("unless a < 1 then yes")
959                && formatted.contains("unless b     then yes"),
960            "unless stays on one line when under MAX_COLS, got:\n{formatted}"
961        );
962    }
963}