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
7pub(crate) mod expression;
8
9use crate::parsing::ast::{
10    AsLemmaSource, Constraint, ContinuationShape, DataValue, Expression, ExpressionKind, LemmaData,
11    LemmaRule, LemmaSpec, ListCommand, ListWithRows, TypeConstraintCommand,
12};
13use crate::parsing::{parse, ParseResult};
14use crate::{Error, ResourceLimits};
15use expression::{display_broken, Syntax};
16
17/// Soft line length limit for prose (`unless`, expression soft-breaks, comma-value lists).
18/// Aligned instance-row tables may exceed this so each `-> with` stays on one padded line.
19/// 56 has been chosen to fit on an average mobile screen with an 11pt font.
20pub const MAX_COLS: usize = 56;
21
22// =============================================================================
23// Public entry points
24// =============================================================================
25
26/// Format a sequence of parsed specs into canonical Lemma source.
27///
28/// specs are separated by two blank lines.
29/// The result ends with a single newline.
30#[must_use]
31pub fn format_specs(specs: &[LemmaSpec]) -> String {
32    let refs: Vec<&LemmaSpec> = specs.iter().collect();
33    format_spec_refs(&refs)
34}
35
36/// Like [`format_specs`] for borrowed specs (e.g. from Context storage).
37#[must_use]
38pub fn format_spec_refs(specs: &[&LemmaSpec]) -> String {
39    let mut out = String::new();
40    for (index, spec) in specs.iter().enumerate() {
41        if index > 0 {
42            out.push_str("\n\n");
43        }
44        out.push_str(&format_spec(spec, MAX_COLS));
45    }
46    if !out.ends_with('\n') {
47        out.push('\n');
48    }
49    out
50}
51
52/// Format a [`ParseResult`] (repository groups + specs) into canonical Lemma source.
53#[must_use]
54pub fn format_parse_result(result: &ParseResult) -> String {
55    let mut blocks: Vec<String> = Vec::new();
56    for (repo, specs) in &result.repositories {
57        let mut prefix = String::new();
58        if let Some(name) = repo.name.as_deref() {
59            prefix.push_str("repo ");
60            prefix.push_str(name);
61            prefix.push_str("\n\n");
62        }
63        if specs.is_empty() {
64            if !prefix.is_empty() {
65                blocks.push(prefix);
66            }
67            continue;
68        }
69        let body = format_specs(specs.as_slice());
70        if prefix.is_empty() {
71            blocks.push(body);
72        } else {
73            prefix.push_str(&body);
74            blocks.push(prefix);
75        }
76    }
77    let mut out = blocks.join("\n\n");
78    if !out.ends_with('\n') {
79        out.push('\n');
80    }
81    out
82}
83
84/// Parse a source string and format it to canonical Lemma source.
85///
86/// Returns an error if the source does not parse.
87pub fn format_source(
88    source: &str,
89    source_type: crate::parsing::source::SourceType,
90) -> Result<String, Error> {
91    let limits = ResourceLimits::default();
92    let result = parse(source, source_type, &limits)?;
93    Ok(format_parse_result(&result))
94}
95
96// =============================================================================
97// Spec
98// =============================================================================
99
100pub(crate) fn format_spec(spec: &LemmaSpec, max_cols: usize) -> String {
101    let mut out = String::new();
102    out.push_str("spec ");
103    out.push_str(&spec.name);
104    if let crate::parsing::ast::EffectiveDate::DateTimeValue(ref af) = spec.effective_from {
105        out.push(' ');
106        out.push_str(&af.to_string());
107    }
108    out.push('\n');
109
110    if let Some(ref commentary) = spec.commentary {
111        out.push_str("\"\"\"\n");
112        out.push_str(commentary);
113        out.push_str("\n\"\"\"\n");
114    }
115
116    for meta in &spec.meta_fields {
117        out.push_str(&format!("{meta}\n"));
118    }
119
120    if !spec.data.is_empty() {
121        format_sorted_data(&spec.data, &mut out, "", max_cols);
122    }
123
124    if !spec.rules.is_empty() {
125        out.push('\n');
126        for (index, rule) in spec.rules.iter().enumerate() {
127            if index > 0 {
128                out.push('\n');
129            }
130            let rule_text = format_rule(rule, max_cols);
131            for line in rule_text.lines() {
132                out.push_str(line);
133                out.push('\n');
134            }
135        }
136    }
137
138    out
139}
140
141// =============================================================================
142// Data
143// =============================================================================
144
145/// Two spaces after `line_prefix` for each `-> ...` constraint line under `data ...: ...`.
146const DATA_CONSTRAINT_INDENT: &str = "  ";
147
148fn data_constraints_nonempty(constraints: &Option<Vec<Constraint>>) -> bool {
149    constraints.as_ref().is_some_and(|v| !v.is_empty())
150}
151
152fn data_value_has_arrow_constraints(value: &DataValue) -> bool {
153    match value {
154        DataValue::Definition {
155            constraints,
156            list: Some(list),
157            ..
158        } => {
159            data_constraints_nonempty(constraints)
160                || list.key.is_some()
161                || !list.rows.is_empty()
162                || !list.option_rows.is_empty()
163        }
164        DataValue::Definition { constraints, .. } => data_constraints_nonempty(constraints),
165        DataValue::Import { .. } | DataValue::ImportList { .. } => false,
166    }
167}
168
169fn format_with_rhs(rhs: &crate::parsing::ast::WithRhs) -> String {
170    match rhs {
171        crate::parsing::ast::WithRhs::Literal(v) => format!("{}", AsLemmaSource(v)),
172        crate::parsing::ast::WithRhs::Reference { target } => target.to_string(),
173    }
174}
175
176fn format_uses_binding_rhs(rhs: &crate::parsing::ast::UsesBindingRhs) -> String {
177    match rhs {
178        crate::parsing::ast::UsesBindingRhs::Literal(value) => {
179            format!("{}", AsLemmaSource(value))
180        }
181        crate::parsing::ast::UsesBindingRhs::References { targets } => targets
182            .iter()
183            .map(ToString::to_string)
184            .collect::<Vec<_>>()
185            .join(", "),
186    }
187}
188
189fn data_value_rhs_for_spec_body(
190    value: &DataValue,
191    continuation_prefix: &str,
192    max_cols: usize,
193) -> String {
194    match value {
195        DataValue::Definition {
196            base,
197            constraints,
198            value,
199            list: Some(list),
200        } => {
201            let head: String = match base.as_ref() {
202                Some(b) => format!("{}", b),
203                None => match value {
204                    Some(v) => format!("{}", AsLemmaSource(v)),
205                    None => String::new(),
206                },
207            };
208            let mut out = head;
209            if data_constraints_nonempty(constraints) {
210                let cs = constraints
211                    .as_ref()
212                    .expect("BUG: constraints checked above");
213                for row in cs {
214                    out.push('\n');
215                    out.push_str(&format_constraint_block(
216                        &row.command,
217                        &row.args,
218                        continuation_prefix,
219                        max_cols,
220                    ));
221                }
222            }
223            out.push_str(&format_uses_list_commands(
224                list,
225                continuation_prefix,
226                max_cols,
227            ));
228            out
229        }
230        DataValue::Definition {
231            base,
232            constraints,
233            value,
234            list: None,
235        } if data_constraints_nonempty(constraints) => {
236            let cs = constraints
237                .as_ref()
238                .expect("BUG: constraints checked above");
239            let head: String = if base.is_none() {
240                match value {
241                    Some(v) => format!("{}", AsLemmaSource(v)),
242                    None => String::new(),
243                }
244            } else {
245                match base.as_ref() {
246                    Some(b) => format!("{}", b),
247                    None => String::new(),
248                }
249            };
250            let mut out = head;
251            for row in cs {
252                out.push('\n');
253                out.push_str(&format_constraint_block(
254                    &row.command,
255                    &row.args,
256                    continuation_prefix,
257                    max_cols,
258                ));
259            }
260            out
261        }
262        DataValue::Definition { .. } => format!("{}", AsLemmaSource(value)),
263        DataValue::Import { .. } | DataValue::ImportList { .. } => {
264            unreachable!("BUG: data row right-hand side requested for an import")
265        }
266    }
267}
268
269fn format_constraint_block(
270    command: &TypeConstraintCommand,
271    args: &[crate::parsing::ast::CommandArg],
272    continuation_prefix: &str,
273    max_cols: usize,
274) -> String {
275    if command.continuation_shape() != ContinuationShape::CommaSeparated || args.len() < 2 {
276        return format!(
277            "{}-> {}",
278            continuation_prefix,
279            crate::parsing::ast::format_constraint_as_source(command, args)
280        );
281    }
282    let items: Vec<String> = args
283        .iter()
284        .map(|arg| format!("{}", AsLemmaSource(arg)))
285        .collect();
286    let wrap_indent = format!("{continuation_prefix}    ");
287    let mut lines: Vec<String> = Vec::new();
288    let mut current = format!("{continuation_prefix}-> {command} {}", items[0]);
289    for item in items.iter().skip(1) {
290        let candidate = format!("{current}, {item}");
291        if candidate.len() <= max_cols {
292            current = candidate;
293        } else {
294            current.push(',');
295            lines.push(current);
296            current = format!("{wrap_indent}{item}");
297        }
298    }
299    lines.push(current);
300    lines.join("\n")
301}
302
303/// `data reference:` then `gap` spaces, then `rhs`.
304fn push_data_line(out: &mut String, line_prefix: &str, reference: &str, gap: usize, rhs: &str) {
305    out.push_str(line_prefix);
306    out.push_str("data ");
307    out.push_str(reference);
308    out.push(':');
309    out.push_str(&" ".repeat(gap));
310    out.push_str(rhs);
311    out.push('\n');
312}
313
314/// A run of single-line rows aligns its right-hand sides; a multi-line row stands alone.
315fn emit_data_row_group(rows: &[&LemmaData], line_prefix: &str, max_cols: usize, out: &mut String) {
316    let continuation = format!("{line_prefix}{DATA_CONSTRAINT_INDENT}");
317    let formatted: Vec<(String, String)> = rows
318        .iter()
319        .map(|row| {
320            (
321                row.reference.to_string(),
322                data_value_rhs_for_spec_body(&row.value, &continuation, max_cols),
323            )
324        })
325        .collect();
326    let mut i = 0;
327    while i < rows.len() {
328        let mut run_end = i;
329        while run_end < rows.len() && !formatted[run_end].1.contains('\n') {
330            run_end += 1;
331        }
332        if run_end == i {
333            let (reference, rhs) = &formatted[i];
334            push_data_line(out, line_prefix, reference, 1, rhs);
335            if data_value_has_arrow_constraints(&rows[i].value) && i + 1 < rows.len() {
336                out.push('\n');
337            }
338            i += 1;
339            continue;
340        }
341        let width = formatted[i..run_end]
342            .iter()
343            .map(|(reference, _)| reference.len())
344            .max()
345            .expect("BUG: non-empty run");
346        for (reference, rhs) in &formatted[i..run_end] {
347            push_data_line(
348                out,
349                line_prefix,
350                reference,
351                1 + width - reference.len(),
352                rhs,
353            );
354        }
355        i = run_end;
356    }
357}
358
359fn format_import_header(data: &LemmaData) -> String {
360    let alias = &data.reference.name;
361    let DataValue::Import { spec_ref, .. } = &data.value else {
362        unreachable!("BUG: format_import_header called on non-Import data")
363    };
364    let spec_name = &spec_ref.name;
365    if alias == spec_name {
366        format!("uses {spec_ref}")
367    } else {
368        format!("uses {alias}: {spec_ref}")
369    }
370}
371
372/// `name: value` strings of one inline row, before alignment.
373fn uses_row_fields(bindings: &[crate::parsing::ast::RowBinding]) -> Vec<(String, String)> {
374    bindings
375        .iter()
376        .map(|binding| (binding.path.to_string(), format_with_rhs(&binding.rhs)))
377        .collect()
378}
379
380/// Per-column width when every row names the same fields in the same order; otherwise empty.
381fn aligned_column_widths(field_rows: &[Vec<(String, String)>]) -> Vec<usize> {
382    let Some(first) = field_rows.first() else {
383        return Vec::new();
384    };
385    let names: Vec<&str> = first.iter().map(|(name, _)| name.as_str()).collect();
386    let aligned = field_rows.iter().all(|row| {
387        row.len() == names.len()
388            && row
389                .iter()
390                .zip(names.iter())
391                .all(|((name, _), shared)| name == shared)
392    });
393    if !aligned {
394        return Vec::new();
395    }
396    (0..names.len())
397        .map(|column| {
398            field_rows
399                .iter()
400                .map(|row| row[column].0.len() + 2 + row[column].1.len() + 1)
401                .max()
402                .expect("BUG: aligned rows are non-empty")
403        })
404        .collect()
405}
406
407/// One `-> with` / `-> option` Fields line. When `column_widths` is non-empty, pad to those
408/// widths and never verticalize. When empty, keep one line if it fits `max_cols`, else one
409/// field per line.
410fn format_fields_row_line(
411    command: &str,
412    fields: &[(String, String)],
413    command_prefix: &str,
414    vertical_indent: &str,
415    column_widths: &[usize],
416    max_cols: usize,
417) -> String {
418    let mut inline = String::new();
419    for (index, (name, value)) in fields.iter().enumerate() {
420        let last = index + 1 == fields.len();
421        let cell = if last {
422            format!("{name}: {value}")
423        } else {
424            format!("{name}: {value},")
425        };
426        inline.push_str(&cell);
427        if !last {
428            let width = column_widths.get(index).copied().unwrap_or(cell.len());
429            inline.push_str(&" ".repeat(width.saturating_sub(cell.len()) + 1));
430        }
431    }
432    let line = format!("{command_prefix}{command} {inline}");
433    if !column_widths.is_empty() || line.len() <= max_cols {
434        return line;
435    }
436    let mut out = String::new();
437    out.push_str(command_prefix);
438    out.push_str(command);
439    for (index, (name, value)) in fields.iter().enumerate() {
440        out.push('\n');
441        out.push_str(vertical_indent);
442        out.push_str(&format!("{name}: {value}"));
443        if index + 1 < fields.len() {
444            out.push(',');
445        }
446    }
447    out
448}
449
450/// `-> key` first, then rows in source order. Rows with the same field order pad to equal
451/// column widths on one line each; misaligned rows that exceed `max_cols` go vertical.
452fn format_uses_list_commands(
453    list: &crate::parsing::ast::InstanceListBody,
454    continuation_prefix: &str,
455    max_cols: usize,
456) -> String {
457    use crate::parsing::ast::InstanceListRow;
458    let mut out = String::new();
459    let command_prefix = format!("{continuation_prefix}-> ");
460    if let Some(key) = &list.key {
461        let fields: Vec<String> = key.fields.iter().map(ToString::to_string).collect();
462        out.push('\n');
463        out.push_str(&command_prefix);
464        out.push_str("key ");
465        out.push_str(&fields.join(", "));
466    }
467
468    let field_rows: Vec<Vec<(String, String)>> = list
469        .rows
470        .iter()
471        .filter_map(|row| match row {
472            InstanceListRow::Fields { bindings, .. } => Some(uses_row_fields(bindings)),
473            InstanceListRow::Values { .. } => None,
474        })
475        .collect();
476    let column_widths = aligned_column_widths(&field_rows);
477
478    let vertical_indent = format!("{continuation_prefix}     ");
479    for row in &list.option_rows {
480        out.push('\n');
481        out.push_str(&format_instance_row_line(
482            "option",
483            row,
484            &command_prefix,
485            &vertical_indent,
486            &column_widths,
487            max_cols,
488        ));
489    }
490    for row in &list.rows {
491        out.push('\n');
492        out.push_str(&format_instance_row_line(
493            "with",
494            row,
495            &command_prefix,
496            &vertical_indent,
497            &column_widths,
498            max_cols,
499        ));
500    }
501    out
502}
503
504fn format_instance_row_line(
505    command: &str,
506    row: &crate::parsing::ast::InstanceListRow,
507    command_prefix: &str,
508    vertical_indent: &str,
509    column_widths: &[usize],
510    max_cols: usize,
511) -> String {
512    use crate::parsing::ast::InstanceListRow;
513    match row {
514        InstanceListRow::Fields { bindings, .. } => format_fields_row_line(
515            command,
516            &uses_row_fields(bindings),
517            command_prefix,
518            vertical_indent,
519            column_widths,
520            max_cols,
521        ),
522        InstanceListRow::Values { values, .. } => {
523            let items: Vec<String> = values.iter().map(format_with_rhs).collect();
524            let mut out = String::new();
525            out.push_str(command_prefix);
526            out.push_str(command);
527            out.push(' ');
528            out.push_str(&items.join(", "));
529            out
530        }
531    }
532}
533
534fn format_uses_block(data: &LemmaData, line_prefix: &str) -> String {
535    let mut out = format_import_header(data);
536    let DataValue::Import {
537        bindings,
538        effective_ref,
539        ..
540    } = &data.value
541    else {
542        unreachable!("BUG: format_uses_block called on non-Import data");
543    };
544    if let Some(reference) = effective_ref {
545        out.push('\n');
546        out.push_str(line_prefix);
547        out.push_str(DATA_CONSTRAINT_INDENT);
548        out.push_str("-> effective ");
549        out.push_str(&reference.to_string());
550    }
551    for binding in bindings {
552        out.push('\n');
553        out.push_str(line_prefix);
554        out.push_str(DATA_CONSTRAINT_INDENT);
555        out.push_str("-> ");
556        out.push_str(&crate::parsing::ast::format_assignment_continuation(
557            "with",
558            &format!("{}", binding.path),
559            &format_uses_binding_rhs(&binding.rhs),
560        ));
561    }
562    out
563}
564
565/// Group data into sections separated by blank lines:
566///
567/// 1. Imports (`uses`) with `-> with` bindings — declaration order
568/// 2. Regular local `data` — declaration order
569fn format_sorted_data(data: &[LemmaData], out: &mut String, line_prefix: &str, max_cols: usize) {
570    let mut regular: Vec<&LemmaData> = Vec::new();
571    let mut imports: Vec<&LemmaData> = Vec::new();
572
573    for data in data {
574        if data.value.uses_spec_ref().is_some() {
575            imports.push(data);
576        } else {
577            regular.push(data);
578        }
579    }
580
581    let emit_group = |rows: &[&LemmaData], out: &mut String| {
582        emit_data_row_group(rows, line_prefix, max_cols, out)
583    };
584
585    if !imports.is_empty() {
586        out.push('\n');
587
588        for (i, row) in imports.iter().enumerate() {
589            if i > 0 {
590                out.push('\n');
591            }
592            out.push_str(line_prefix);
593            out.push_str(&format_uses_block(row, line_prefix));
594            out.push('\n');
595        }
596    }
597
598    if !regular.is_empty() {
599        out.push('\n');
600        emit_group(&regular, out);
601    }
602}
603
604// =============================================================================
605// Rules
606// =============================================================================
607
608const UNLESS_LINE_PREFIX: &str = "  unless ";
609
610/// Rule body always starts on the line after `rule name:`.
611///
612/// When every `unless` clause fits on one line under `max_cols`, `then` columns align across
613/// sisters. If any clause needs split `then` (wrapped condition, wrapped result, or oversize
614/// flat line), every clause on the rule uses split `then` at a fixed 4-space indent.
615fn format_rule(rule: &LemmaRule, max_cols: usize) -> String {
616    let expr_indent = "  ";
617    let body = format_expr_wrapped(&rule.expression, max_cols, expr_indent);
618    let mut out = String::new();
619    out.push_str("rule ");
620    out.push_str(&rule.name);
621    out.push_str(":\n");
622    out.push_str(expr_indent);
623    out.push_str(&body);
624
625    let pl = UNLESS_LINE_PREFIX.len();
626    let naive_single_len = |cond: &str, res: &str| pl + cond.len() + 6 + res.len();
627    let aligned_single_len = |res: &str, max_end: usize| max_end + 6 + res.len();
628    let unless_condition_budget = max_cols.saturating_sub(pl);
629
630    let mut clauses: Vec<(String, String)> = Vec::new();
631    for unless_clause in &rule.unless_clauses {
632        let condition =
633            format_expr_wrapped(&unless_clause.condition, unless_condition_budget, "    ");
634        let result = format_expr_wrapped(&unless_clause.result, max_cols, "    ");
635        clauses.push((condition, result));
636    }
637
638    let clause_needs_split_then = |condition: &str, result: &str| {
639        condition.contains('\n')
640            || result.contains('\n')
641            || naive_single_len(condition, result) > max_cols
642    };
643
644    let any_split = clauses.iter().any(|(c, r)| clause_needs_split_then(c, r));
645
646    const SPLIT_THEN_INDENT_SPACES: usize = 4;
647
648    let mut singles: Vec<usize> = if any_split {
649        Vec::new()
650    } else {
651        (0..clauses.len()).collect()
652    };
653
654    loop {
655        if singles.is_empty() {
656            break;
657        }
658        let max_end = singles
659            .iter()
660            .map(|&i| pl + clauses[i].0.len())
661            .max()
662            .expect("BUG: singles non-empty");
663        let before = singles.len();
664        singles.retain(|&i| aligned_single_len(&clauses[i].1, max_end) <= max_cols);
665        if singles.len() == before {
666            break;
667        }
668    }
669
670    let align_max_end = singles.iter().map(|&i| pl + clauses[i].0.len()).max();
671
672    for (i, (condition, result)) in clauses.iter().enumerate() {
673        out.push('\n');
674        out.push_str(UNLESS_LINE_PREFIX);
675        out.push_str(condition);
676        if singles.contains(&i) {
677            let max_end = align_max_end.expect("BUG: singles.contains but align_max_end empty");
678            out.push_str(&" ".repeat(1 + max_end.saturating_sub(pl + condition.len())));
679        } else {
680            out.push('\n');
681            out.push_str(&" ".repeat(SPLIT_THEN_INDENT_SPACES));
682        }
683        out.push_str("then ");
684        out.push_str(result);
685    }
686    out.push('\n');
687    out
688}
689
690// =============================================================================
691// Expression wrapping (soft line breaking at max_cols)
692// =============================================================================
693
694/// The expression on one line when it fits `max_cols`; otherwise every top-level `and` /
695/// arithmetic operator starts a new line at `indent`. Operands always print on one line.
696/// A sealed-catalog spine (`base`, optional `-> key`, then aligned `-> with` field rows)
697/// prints as a multi-line padded table instead.
698fn format_expr_wrapped(expr: &Expression, max_cols: usize, indent: &str) -> String {
699    if let Some(catalog) = format_list_catalog_table(expr, indent) {
700        return catalog;
701    }
702    let flat = Syntax(expr.root()).to_string();
703    if flat.len() <= max_cols {
704        flat
705    } else {
706        display_broken(expr.root(), indent)
707    }
708}
709
710/// `base -> key? -> with field: … -> with …` when every `-> with` shares field order:
711/// one padded row per line. First line is the base (caller supplies indent for that line);
712/// continuation `->` lines use `indent`.
713fn format_list_catalog_table(expr: &Expression, indent: &str) -> Option<String> {
714    let mut commands = Vec::new();
715    let mut cur = expr.root;
716    while let ExpressionKind::ListCommand { list, command } = expr.table.kind(cur) {
717        commands.push(command.clone());
718        cur = *list;
719    }
720    if commands.is_empty() {
721        return None;
722    }
723    commands.reverse();
724
725    let mut with_start = 0;
726    let key_fields = match commands.first() {
727        Some(ListCommand::Key { fields }) => {
728            with_start = 1;
729            Some(fields)
730        }
731        _ => None,
732    };
733    let with_commands = &commands[with_start..];
734    if with_commands.is_empty() {
735        return None;
736    }
737
738    let mut field_rows: Vec<Vec<(String, String)>> = Vec::with_capacity(with_commands.len());
739    for command in with_commands {
740        let ListCommand::With {
741            rows: ListWithRows::FieldPairs(pairs),
742        } = command
743        else {
744            return None;
745        };
746        field_rows.push(
747            pairs
748                .iter()
749                .map(|(name, value)| (name.to_string(), Syntax(expr.at(*value)).to_string()))
750                .collect(),
751        );
752    }
753
754    let column_widths = aligned_column_widths(&field_rows);
755    if column_widths.is_empty() {
756        return None;
757    }
758
759    let command_prefix = format!("{indent}-> ");
760    let vertical_indent = format!("{indent}     ");
761    let mut out = Syntax(expr.at(cur)).to_string();
762    if let Some(fields) = key_fields {
763        let names: Vec<String> = fields.iter().map(ToString::to_string).collect();
764        out.push('\n');
765        out.push_str(&command_prefix);
766        out.push_str("key ");
767        out.push_str(&names.join(", "));
768    }
769    for fields in &field_rows {
770        out.push('\n');
771        out.push_str(&format_fields_row_line(
772            "with",
773            fields,
774            &command_prefix,
775            &vertical_indent,
776            &column_widths,
777            MAX_COLS,
778        ));
779    }
780    Some(out)
781}
782
783// =============================================================================
784// Tests
785// =============================================================================
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790    use crate::literals::DateGranularity;
791    use crate::parsing::ast::{
792        AsLemmaSource, BooleanValue, DateTimeValue, TimeValue, TimezoneValue, Value,
793    };
794    use rust_decimal::prelude::FromStr;
795    use rust_decimal::Decimal;
796
797    /// Helper: format a Value as canonical Lemma source via AsLemmaSource.
798    fn fmt_value(v: &Value) -> String {
799        format!("{}", AsLemmaSource(v))
800    }
801
802    #[test]
803    fn test_format_value_text_is_quoted() {
804        let v = Value::Text("light".to_string());
805        assert_eq!(fmt_value(&v), "\"light\"");
806    }
807
808    #[test]
809    fn test_format_value_text_escapes_quotes() {
810        let v = Value::Text("say \"hello\"".to_string());
811        assert_eq!(fmt_value(&v), "\"say \\\"hello\\\"\"");
812    }
813
814    #[test]
815    fn test_format_value_number() {
816        let v = Value::Number(Decimal::from_str("42.50").unwrap());
817        assert_eq!(fmt_value(&v), "42.50");
818    }
819
820    #[test]
821    fn test_format_value_number_integer() {
822        let v = Value::Number(Decimal::from_str("100.00").unwrap());
823        assert_eq!(fmt_value(&v), "100");
824    }
825
826    #[test]
827    fn test_format_value_boolean() {
828        assert_eq!(fmt_value(&Value::Boolean(BooleanValue::True)), "true");
829        assert_eq!(fmt_value(&Value::Boolean(BooleanValue::Yes)), "yes");
830        assert_eq!(fmt_value(&Value::Boolean(BooleanValue::No)), "no");
831    }
832
833    #[test]
834    fn test_format_value_measure() {
835        let v = Value::NumberWithUnit(Decimal::from_str("99.50").unwrap(), "eur".to_string());
836        assert_eq!(fmt_value(&v), "99.50 eur");
837    }
838
839    #[test]
840    fn test_format_value_duration_as_measure() {
841        let v = Value::NumberWithUnit(Decimal::from(40), "hour".to_string());
842        assert_eq!(fmt_value(&v), "40 hour");
843    }
844
845    #[test]
846    fn test_format_value_calendar() {
847        let v = Value::NumberWithUnit(Decimal::from(6), "month".to_string());
848        assert_eq!(fmt_value(&v), "6 month");
849    }
850
851    #[test]
852    fn test_format_value_ratio_percent() {
853        let v = Value::NumberWithUnit(Decimal::from_str("10").unwrap(), "percent".to_string());
854        assert_eq!(fmt_value(&v), "10%");
855    }
856
857    #[test]
858    fn test_format_value_ratio_permille() {
859        let v = Value::NumberWithUnit(Decimal::from_str("5").unwrap(), "permille".to_string());
860        assert_eq!(fmt_value(&v), "5%%");
861    }
862
863    #[test]
864    fn test_format_value_number_with_unit_named() {
865        let v = Value::NumberWithUnit(
866            Decimal::from_str("500").unwrap(),
867            "basis_points".to_string(),
868        );
869        assert_eq!(fmt_value(&v), "500 basis_points");
870    }
871
872    #[test]
873    fn test_format_value_date_only() {
874        let v = Value::Date(DateTimeValue {
875            year: 2024,
876            month: 1,
877            day: 15,
878            hour: 0,
879            minute: 0,
880            second: 0,
881            microsecond: 0,
882            timezone: None,
883
884            granularity: DateGranularity::Full,
885        });
886        assert_eq!(fmt_value(&v), "2024-01-15");
887    }
888
889    #[test]
890    fn test_format_value_datetime_with_tz() {
891        let v = Value::Date(DateTimeValue {
892            year: 2024,
893            month: 1,
894            day: 15,
895            hour: 14,
896            minute: 30,
897            second: 0,
898            microsecond: 0,
899            timezone: Some(TimezoneValue { offset_minutes: 0 }),
900
901            granularity: DateGranularity::DateTime,
902        });
903        assert_eq!(fmt_value(&v), "2024-01-15T14:30:00Z");
904    }
905
906    #[test]
907    fn test_format_value_time() {
908        let v = Value::Time(TimeValue {
909            hour: 14,
910            minute: 30,
911            second: 45,
912            microsecond: 0,
913            timezone: None,
914        });
915        assert_eq!(fmt_value(&v), "14:30:45");
916    }
917
918    #[test]
919    fn test_format_source_preserves_date_granularity() {
920        let formatted = format_source(
921            "spec x 2026\n",
922            crate::parsing::source::SourceType::Volatile,
923        )
924        .expect("spec x 2026 should format");
925        assert!(
926            formatted.contains("spec x 2026\n"),
927            "year-only effective date must round-trip, got: {formatted}"
928        );
929        assert!(
930            !formatted.contains("2026-01-01"),
931            "year-only effective date must not expand, got: {formatted}"
932        );
933        let reformatted = format_source(&formatted, crate::parsing::source::SourceType::Volatile)
934            .expect("reformat");
935        assert_eq!(formatted, reformatted, "spec x 2026 must be idempotent");
936
937        let formatted = format_source(
938            "spec x 2026-03\n",
939            crate::parsing::source::SourceType::Volatile,
940        )
941        .expect("spec x 2026-03 should format");
942        assert!(
943            formatted.contains("spec x 2026-03\n"),
944            "year-month effective date must round-trip, got: {formatted}"
945        );
946
947        let formatted = format_source(
948            "spec x 2026-W34\n",
949            crate::parsing::source::SourceType::Volatile,
950        )
951        .expect("spec x 2026-W34 should format");
952        assert!(
953            formatted.contains("spec x 2026-W34\n"),
954            "iso week effective date must round-trip, got: {formatted}"
955        );
956
957        let source = "spec consumer\nuses finance 2026\n";
958        let formatted = format_source(source, crate::parsing::source::SourceType::Volatile)
959            .expect("uses with year should format");
960        assert!(
961            formatted.contains("uses finance 2026"),
962            "uses effective pin must preserve year-only date, got: {formatted}"
963        );
964        assert!(
965            !formatted.contains("2026-01-01"),
966            "uses effective pin must not expand year-only date, got: {formatted}"
967        );
968    }
969
970    #[test]
971    fn format_uses_dynamic_effective_command() {
972        let source = r#"
973spec alpha2 2020-01-01
974data code: text
975
976spec intake
977data birth_date: date
978uses iso: alpha2
979  -> effective birth_date
980  -> with code: "NL"
981"#;
982        let formatted = format_source(source, crate::parsing::source::SourceType::Volatile)
983            .expect("dynamic effective uses should format");
984        assert!(
985            formatted.contains("-> effective birth_date"),
986            "formatter must emit -> effective, got: {formatted}"
987        );
988        assert!(
989            formatted
990                .find("-> effective birth_date")
991                .expect("effective")
992                < formatted.find("-> with code").expect("with"),
993            "-> effective must precede -> with, got: {formatted}"
994        );
995    }
996
997    #[test]
998    fn test_format_source_lowercases_logical_identifiers() {
999        let source = r#"spec Test
1000data Price: number -> suggest 1
1001rule Total: price
1002"#;
1003        let formatted =
1004            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1005        assert!(formatted.contains("spec test"), "got: {formatted}");
1006        assert!(formatted.contains("data price"), "got: {formatted}");
1007        assert!(formatted.contains("rule total"), "got: {formatted}");
1008    }
1009
1010    #[test]
1011    fn test_format_source_round_trips_text() {
1012        let source = r#"spec test
1013
1014data name: "Alice"
1015
1016rule greeting: "hello"
1017"#;
1018        let formatted =
1019            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1020        assert!(formatted.contains("\"Alice\""), "data text must be quoted");
1021        assert!(formatted.contains("\"hello\""), "rule text must be quoted");
1022    }
1023
1024    #[test]
1025    fn test_format_source_preserves_percent() {
1026        let source = r#"spec test
1027
1028data rate: 10 percent
1029
1030rule tax: rate * 21%
1031"#;
1032        let formatted =
1033            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1034        assert!(
1035            formatted.contains("10%"),
1036            "data percent must use shorthand %, got: {}",
1037            formatted
1038        );
1039    }
1040
1041    #[test]
1042    fn test_format_groups_data_preserving_order() {
1043        // Data are deliberately mixed: the formatter keeps all regular data together
1044        // in original order, aligned
1045        let source = r#"spec test
1046
1047data income: number -> minimum 0
1048data filing_status: filing_status_type -> suggest "single"
1049data country: "NL"
1050data deductions: number -> minimum 0
1051data name: text
1052
1053rule total: income
1054"#;
1055        let formatted =
1056            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1057        let data_section = formatted
1058            .split("rule total")
1059            .next()
1060            .unwrap()
1061            .split("spec test\n")
1062            .nth(1)
1063            .unwrap();
1064        let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
1065        // Constrained rows: one blank line after each when more `data` follows.
1066        assert_eq!(lines[0], "data income: number");
1067        assert_eq!(lines[1], "  -> minimum 0");
1068        assert_eq!(lines[2], "data filing_status: filing_status_type");
1069        assert_eq!(lines[3], "  -> suggest \"single\"");
1070        assert_eq!(lines[4], "data country: \"NL\"");
1071        assert_eq!(lines[5], "data deductions: number");
1072        assert_eq!(lines[6], "  -> minimum 0");
1073        assert_eq!(lines[7], "data name: text");
1074    }
1075
1076    #[test]
1077    fn test_format_groups_spec_refs_with_overrides() {
1078        let source = r#"spec test
1079
1080uses order wholesale
1081  -> with quantity: 100
1082uses order retail
1083  -> with quantity: 5
1084data base_price: 50
1085
1086rule total: base_price
1087"#;
1088        let formatted =
1089            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1090        let data_section = formatted
1091            .split("rule total")
1092            .next()
1093            .unwrap()
1094            .split("spec test\n")
1095            .nth(1)
1096            .unwrap();
1097        let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
1098        assert_eq!(lines[0], "uses order wholesale");
1099        assert_eq!(lines[1], "  -> with quantity: 100");
1100        assert_eq!(lines[2], "uses order retail");
1101        assert_eq!(lines[3], "  -> with quantity: 5");
1102        assert_eq!(lines[4], "data base_price: 50");
1103    }
1104
1105    #[test]
1106    fn test_format_groups_with_literals_under_each_uses() {
1107        let source = r#"spec test
1108
1109uses x
1110  -> with name: "Ben"
1111uses y
1112  -> with age: 15
1113
1114rule r: 1
1115"#;
1116        let formatted =
1117            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1118        let data_section = formatted
1119            .split("rule r")
1120            .next()
1121            .unwrap()
1122            .split("spec test\n")
1123            .nth(1)
1124            .unwrap();
1125        let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
1126        assert_eq!(lines[0], "uses x");
1127        assert_eq!(lines[1], "  -> with name: \"Ben\"");
1128        assert_eq!(lines[2], "uses y");
1129        assert_eq!(lines[3], "  -> with age: 15");
1130    }
1131
1132    #[test]
1133    fn test_format_source_weather_clothing_text_quoted() {
1134        let source = r#"spec weather_clothing
1135
1136data clothing_style: text
1137  -> option "light"
1138  -> option "warm"
1139
1140data temperature: number
1141
1142rule clothing_layer: "light"
1143  unless temperature < 5 then "warm"
1144"#;
1145        let formatted =
1146            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1147        assert!(
1148            formatted.contains("\"light\""),
1149            "text in rule must be quoted, got: {}",
1150            formatted
1151        );
1152        assert!(
1153            formatted.contains("\"warm\""),
1154            "text in unless must be quoted, got: {}",
1155            formatted
1156        );
1157    }
1158
1159    // NOTE: Default value type validation (e.g. rejecting "10 $$" as a number
1160    // default) is tested at the planning level in engine.rs, not here. The
1161    // formatter only parses — it does not validate types. Planning catches
1162    // invalid defaults for both primitives and named types.
1163
1164    #[test]
1165    fn test_format_text_option_round_trips() {
1166        let source = r#"spec test
1167
1168data status: text
1169  -> option "active"
1170  -> option "inactive"
1171
1172data s: status
1173
1174rule out: s
1175"#;
1176        let formatted =
1177            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1178        assert!(
1179            formatted.contains("option \"active\""),
1180            "text option must be quoted, got: {}",
1181            formatted
1182        );
1183        assert!(
1184            formatted.contains("option \"inactive\""),
1185            "text option must be quoted, got: {}",
1186            formatted
1187        );
1188        // Round-trip
1189        let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
1190        assert!(reparsed.is_ok(), "formatted output should re-parse");
1191    }
1192
1193    #[test]
1194    fn test_format_help_round_trips() {
1195        let source = r#"spec test
1196data quantity: number -> help "Number of items to order"
1197rule total: quantity
1198"#;
1199        let formatted =
1200            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1201        assert!(
1202            formatted.contains("help \"Number of items to order\""),
1203            "help must be quoted, got: {}",
1204            formatted
1205        );
1206        // Round-trip
1207        let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
1208        assert!(reparsed.is_ok(), "formatted output should re-parse");
1209    }
1210
1211    #[test]
1212    fn test_format_measure_type_def_round_trips() {
1213        let source = r#"spec test
1214
1215data money: measure
1216  -> unit eur: 1.00
1217  -> unit usd: 0.91
1218  -> minimum 0
1219
1220data price: money
1221
1222rule total: price
1223"#;
1224        let formatted =
1225            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1226        assert!(
1227            formatted.contains("unit eur: 1.00"),
1228            "measure unit should not be quoted, got: {}",
1229            formatted
1230        );
1231        // Round-trip
1232        let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
1233        assert!(
1234            reparsed.is_ok(),
1235            "formatted output should re-parse, got: {:?}",
1236            reparsed
1237        );
1238    }
1239
1240    #[test]
1241    fn format_deprecated_unit_space_emits_assignment_colon() {
1242        let source = r#"spec test
1243uses lemma units
1244
1245data money: measure
1246  -> unit eur: 1.00
1247
1248data rate: measure
1249  -> unit eur_per_hour: eur/hour
1250"#;
1251        let formatted =
1252            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1253        assert!(
1254            formatted.contains("unit eur: 1.00"),
1255            "formatter must emit assignment colon for unit, got: {}",
1256            formatted
1257        );
1258        assert!(
1259            formatted.contains("unit eur_per_hour: eur/hour"),
1260            "formatter must emit assignment colon for compound unit, got: {}",
1261            formatted
1262        );
1263        assert!(
1264            !formatted.contains("unit eur 1.00"),
1265            "formatter must not emit deprecated space unit syntax, got: {}",
1266            formatted
1267        );
1268    }
1269
1270    #[test]
1271    fn format_canonical_unit_colon_round_trips() {
1272        let source = r#"spec test
1273uses lemma units
1274
1275data money: measure
1276  -> unit eur: 1.00
1277
1278data rate: measure
1279  -> unit eur_per_hour: eur/hour
1280"#;
1281        let formatted =
1282            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1283        assert!(
1284            formatted.contains("unit eur: 1.00"),
1285            "canonical unit syntax must survive format, got: {}",
1286            formatted
1287        );
1288        let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
1289        assert!(
1290            reparsed.is_ok(),
1291            "formatted canonical unit syntax should re-parse, got: {:?}",
1292            reparsed
1293        );
1294    }
1295
1296    #[test]
1297    fn test_format_expression_display_stable_round_trip() {
1298        let source = r#"spec test
1299data a: 1.00
1300rule r: a + 2.00 * 3
1301"#;
1302        let formatted =
1303            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1304        let again =
1305            format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
1306        assert_eq!(
1307            formatted, again,
1308            "AST Display-based format must be idempotent under parse/format"
1309        );
1310    }
1311
1312    #[test]
1313    fn test_format_past_future_range_no_duplicate_in() {
1314        let source = r#"spec test
1315data start: date
1316data length: duration
1317rule valid: start in past length
1318rule window: past length
1319"#;
1320        let formatted =
1321            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1322        assert!(
1323            formatted.contains("rule valid:\n  start in past length"),
1324            "RangeContainment+PastFutureRange must not emit duplicate 'in', got:\n{formatted}"
1325        );
1326        assert!(
1327            formatted.contains("rule window:\n  past length"),
1328            "bare PastFutureRange must print 'past' not 'in past', got:\n{formatted}"
1329        );
1330        assert!(
1331            !formatted.contains("in in past"),
1332            "must not contain duplicate 'in', got:\n{formatted}"
1333        );
1334    }
1335
1336    #[test]
1337    fn test_format_rule_body_on_next_line() {
1338        let source = "spec test\nrule r: 1\n";
1339        let formatted =
1340            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1341        assert!(
1342            formatted.contains("rule r:\n  1\n"),
1343            "rule body must start on next line, got:\n{formatted}"
1344        );
1345    }
1346
1347    #[test]
1348    fn test_format_blank_line_between_data_and_rules() {
1349        let source = "spec test\ndata x: 1\nrule r: x\n";
1350        let formatted =
1351            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1352        assert!(
1353            formatted.contains("data x: 1\n\nrule r:\n")
1354                && !formatted.contains("data x: 1\n\n\nrule r:\n"),
1355            "one blank line required between data block and rules block, got:\n{formatted:?}"
1356        );
1357    }
1358
1359    #[test]
1360    fn test_format_rule_unless_single_line_when_short() {
1361        let source = r#"spec test
1362data a: number
1363data b: boolean
1364
1365rule r: no
1366  unless a < 1 then yes
1367  unless b then yes
1368"#;
1369        let formatted =
1370            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1371        assert!(
1372            formatted.contains("unless a < 1 then yes")
1373                && formatted.contains("unless b     then yes"),
1374            "unless stays on one line when under MAX_COLS, got:\n{formatted}"
1375        );
1376    }
1377
1378    #[test]
1379    fn test_format_rule_unless_child_premium_applies_inline_aligned() {
1380        let source = r#"spec child_premium_applies
1381data child_age_years: number
1382data is_male: boolean
1383data child_has_own_children: boolean
1384data child_is_oldest_insured: boolean
1385
1386rule child_premium_applies:
1387  yes
1388  unless child_age_years >= 25 and is_male then no
1389  unless child_has_own_children then no
1390  unless child_is_oldest_insured then no
1391"#;
1392        let formatted =
1393            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1394        assert!(
1395            formatted.contains("unless child_age_years >= 25 and is_male then no")
1396                && formatted.contains("unless child_has_own_children            then no")
1397                && formatted.contains("unless child_is_oldest_insured           then no"),
1398            "all-single unless clauses align then, got:\n{formatted}"
1399        );
1400        let twice =
1401            format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
1402        assert_eq!(formatted, twice);
1403    }
1404
1405    #[test]
1406    fn test_format_rule_unless_can_request_reinstatement_and_wrap() {
1407        let source = r#"spec can_request_reinstatement
1408data days_since_policy_stopped: number
1409data arrears_paid: boolean
1410data surrender_value_repaid: boolean
1411data all_insured_persons_alive: boolean
1412
1413rule can_request_reinstatement:
1414  no
1415  unless days_since_policy_stopped <= 365 and arrears_paid and surrender_value_repaid and all_insured_persons_alive then yes
1416"#;
1417        let formatted =
1418            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1419        assert!(
1420            formatted.contains(
1421                "unless days_since_policy_stopped <= 365\n    and arrears_paid\n    and surrender_value_repaid\n    and all_insured_persons_alive\n    then yes"
1422            ),
1423            "long and chain wraps one operand per line, got:\n{formatted}"
1424        );
1425        let twice =
1426            format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
1427        assert_eq!(formatted, twice);
1428    }
1429
1430    #[test]
1431    fn test_format_rule_unless_foreign_transport_uniform_split_then() {
1432        let source = r#"spec foreign_transport_covered
1433data death_location: text
1434data trip_duration_months: number
1435data negative_travel_advisory_at_departure: boolean
1436data left_area_asap_after_advisory: boolean
1437
1438rule foreign_transport_covered:
1439  yes
1440  unless death_location is "abroad" then no
1441  unless death_location is "abroad" and trip_duration_months <= 2 then yes
1442  unless death_location is "abroad" and trip_duration_months <= 2 and negative_travel_advisory_at_departure then no
1443  unless death_location is "abroad" and trip_duration_months <= 2 and negative_travel_advisory_at_departure and left_area_asap_after_advisory then yes
1444"#;
1445        let formatted =
1446            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1447        assert!(
1448            formatted.contains("unless death_location is \"abroad\"\n    then no"),
1449            "short sister uses split then when any clause needs it, got:\n{formatted}"
1450        );
1451        assert!(
1452            formatted.contains(
1453                "unless death_location is \"abroad\"\n    and trip_duration_months <= 2\n    then yes"
1454            ),
1455            "wrapped and chain with split then, got:\n{formatted}"
1456        );
1457        let twice =
1458            format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
1459        assert_eq!(formatted, twice);
1460    }
1461
1462    #[test]
1463    fn test_format_rule_unless_child_auto_covered_service_only() {
1464        let source = r#"spec child_auto_covered_service_only
1465data days_since_birth: number
1466data birth_reported_within_60_days: boolean
1467
1468rule child_auto_covered_service_only:
1469  yes
1470  unless days_since_birth >= 60 and not birth_reported_within_60_days then no
1471"#;
1472        let formatted =
1473            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1474        assert!(
1475            formatted.contains(
1476                "unless days_since_birth >= 60\n    and not birth_reported_within_60_days\n    then no"
1477            ),
1478            "multiline unless condition with split then, got:\n{formatted}"
1479        );
1480        let twice =
1481            format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
1482        assert_eq!(formatted, twice);
1483    }
1484
1485    #[test]
1486    fn test_format_long_arithmetic_chain_breaks_before_every_operator() {
1487        let source = r#"spec test
1488data first_amount: number
1489data second_amount: number
1490data third_amount: number
1491data fourth_amount: number
1492data fifth_amount: number
1493data sixth_amount: number
1494
1495rule total: first_amount + second_amount + third_amount + fourth_amount + fifth_amount + sixth_amount
1496"#;
1497        let formatted =
1498            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1499        assert!(
1500            formatted.contains(
1501                "rule total:\n  first_amount\n  + second_amount\n  + third_amount\n  + fourth_amount\n  + fifth_amount\n  + sixth_amount\n"
1502            ),
1503            "overlong chain breaks before every operator, got:\n{formatted}"
1504        );
1505        let twice =
1506            format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
1507        assert_eq!(formatted, twice);
1508    }
1509
1510    #[test]
1511    fn test_format_broken_chain_keeps_nested_chain_operand_flat() {
1512        let source = r#"spec test
1513data base_amount_value: number
1514data first_amount: number
1515data second_amount: number
1516data third_amount: number
1517data other_amount_value: number
1518
1519rule total: base_amount_value * (first_amount + second_amount + third_amount) + other_amount_value
1520"#;
1521        let formatted =
1522            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1523        assert!(
1524            formatted.contains(
1525                "rule total:\n  base_amount_value * (first_amount + second_amount + third_amount)\n  + other_amount_value\n"
1526            ),
1527            "only the root chain breaks, got:\n{formatted}"
1528        );
1529        let twice =
1530            format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
1531        assert_eq!(formatted, twice);
1532    }
1533
1534    #[test]
1535    fn test_format_constrained_data_row_stands_alone_between_aligned_runs() {
1536        let source = r#"spec test
1537data a: 1
1538data longer_name: 2
1539data limit: number -> minimum 0
1540data b: 3
1541"#;
1542        let formatted =
1543            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1544        assert!(
1545            formatted.contains(
1546                "data a:           1\ndata longer_name: 2\ndata limit: number\n  -> minimum 0\n\ndata b: 3\n"
1547            ),
1548            "got:\n{formatted}"
1549        );
1550    }
1551}