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