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