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