1pub(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
17pub const MAX_COLS: usize = 56;
21
22#[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#[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#[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
84pub 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
96pub(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
141const 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
260fn 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
315fn 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
326fn 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
384fn 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
392fn 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
419fn 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
462fn 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
590fn 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(®ular, out);
626 }
627}
628
629const UNLESS_LINE_PREFIX: &str = " unless ";
634
635fn 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
715fn 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
735fn 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#[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 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 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 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 #[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 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 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 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}