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