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