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