1use crate::error::Error;
2use crate::limits::ResourceLimits;
3
4pub mod arena;
5pub mod ast;
6pub mod lexer;
7pub mod parser;
8pub mod source;
9
10pub use ast::*;
11pub use parser::{parse_repository_qualifier_str, ParseResult};
12
13pub fn parse(
14 content: &str,
15 source_type: source::SourceType,
16 limits: &ResourceLimits,
17) -> Result<ParseResult, Error> {
18 parser::parse(content, source_type, limits)
19}
20
21#[cfg(test)]
26mod tests {
27 use super::{
28 parse, ArithmeticComputation, CommandArg, Constraint, DataValue, Expression,
29 ExpressionKind, LemmaSpec, MathematicalComputation, RangeBound, TypeConstraintCommand,
30 UnitArg, UnitFactor,
31 };
32 use crate::formatting::format_parse_result;
33 use crate::parsing::arena::ExprRef;
34 use crate::Error;
35 use crate::ResourceLimits;
36
37 fn binary_arithmetic(
39 node: ExprRef<'_, ExpressionKind>,
40 ) -> Option<(
41 ExprRef<'_, ExpressionKind>,
42 ArithmeticComputation,
43 ExprRef<'_, ExpressionKind>,
44 )> {
45 match node.kind() {
46 ExpressionKind::Arithmetic { first, steps } if steps.len() == 1 => Some((
47 node.child(*first),
48 steps[0].op.clone(),
49 node.child(steps[0].operand),
50 )),
51 _ => None,
52 }
53 }
54
55 #[test]
56 fn parse_empty_input_returns_no_specs() {
57 let result = parse(
58 "",
59 crate::parsing::source::SourceType::Volatile,
60 &ResourceLimits::default(),
61 )
62 .unwrap()
63 .into_flattened_specs();
64 assert_eq!(result.len(), 0);
65 }
66
67 #[test]
68 fn parse_workspace_file_yields_expected_spec_datas_and_rules() {
69 let input = r#"spec person
70data name: "John Doe"
71rule adult: true"#;
72 let result = parse(
73 input,
74 crate::parsing::source::SourceType::Volatile,
75 &ResourceLimits::default(),
76 )
77 .unwrap()
78 .into_flattened_specs();
79 assert_eq!(result.len(), 1);
80 assert_eq!(result[0].name, "person");
81 assert_eq!(result[0].data.len(), 1);
82 assert_eq!(result[0].rules.len(), 1);
83 assert_eq!(result[0].rules[0].name, "adult");
84 }
85
86 #[test]
87 fn mixing_data_and_rules_is_collected_into_spec() {
88 let input = r#"spec test
89data name: "John"
90rule is_adult: age >= 18
91data age: 25
92rule can_drink: age >= 21
93data status: "active"
94rule is_eligible: is_adult and status is "active""#;
95
96 let result = parse(
97 input,
98 crate::parsing::source::SourceType::Volatile,
99 &ResourceLimits::default(),
100 )
101 .unwrap()
102 .into_flattened_specs();
103 assert_eq!(result.len(), 1);
104 assert_eq!(result[0].data.len(), 3);
105 assert_eq!(result[0].rules.len(), 3);
106 }
107
108 #[test]
109 fn parse_simple_spec_collects_data() {
110 let input = r#"spec person
111data name: "John"
112data age: 25"#;
113 let result = parse(
114 input,
115 crate::parsing::source::SourceType::Volatile,
116 &ResourceLimits::default(),
117 )
118 .unwrap()
119 .into_flattened_specs();
120 assert_eq!(result.len(), 1);
121 assert_eq!(result[0].name, "person");
122 assert_eq!(result[0].data.len(), 2);
123 }
124
125 #[test]
126 fn parse_dotted_spec_name() {
127 let input = r#"spec contracts.employment.jack
128data name: "Jack""#;
129 let result = parse(
130 input,
131 crate::parsing::source::SourceType::Volatile,
132 &ResourceLimits::default(),
133 )
134 .unwrap()
135 .into_flattened_specs();
136 assert_eq!(result.len(), 1);
137 assert_eq!(result[0].name, "contracts.employment.jack");
138 }
139
140 #[test]
141 fn parse_slashed_spec_name() {
142 let input = "spec contracts/employment/jack\ndata x: 1";
143 let result = parse(
144 input,
145 crate::parsing::source::SourceType::Volatile,
146 &ResourceLimits::default(),
147 )
148 .unwrap()
149 .into_flattened_specs();
150 assert_eq!(result.len(), 1);
151 assert_eq!(result[0].name, "contracts/employment/jack");
152 }
153
154 #[test]
155 fn parse_spec_name_no_version_tag() {
156 let input = "spec myspec\nrule x: 1";
157 let result = parse(
158 input,
159 crate::parsing::source::SourceType::Volatile,
160 &ResourceLimits::default(),
161 )
162 .unwrap()
163 .into_flattened_specs();
164 assert_eq!(result.len(), 1);
165 assert_eq!(result[0].name, "myspec");
166 assert_eq!(result[0].effective_from(), None);
167 }
168
169 #[test]
170 fn parse_commentary_block_is_attached_to_spec() {
171 let input = r#"spec person
172"""
173This is a markdown comment
174uses **bold** text
175"""
176data name: "John""#;
177 let result = parse(
178 input,
179 crate::parsing::source::SourceType::Volatile,
180 &ResourceLimits::default(),
181 )
182 .unwrap()
183 .into_flattened_specs();
184 assert_eq!(result.len(), 1);
185 assert!(result[0].commentary.is_some());
186 assert!(result[0].commentary.as_ref().unwrap().contains("**bold**"));
187 }
188
189 #[test]
190 fn parse_spec_with_rule_collects_rule() {
191 let input = r#"spec person
192rule is_adult: age >= 18"#;
193 let result = parse(
194 input,
195 crate::parsing::source::SourceType::Volatile,
196 &ResourceLimits::default(),
197 )
198 .unwrap()
199 .into_flattened_specs();
200 assert_eq!(result.len(), 1);
201 assert_eq!(result[0].rules.len(), 1);
202 assert_eq!(result[0].rules[0].name, "is_adult");
203 }
204
205 #[test]
206 fn parse_multiple_specs_returns_all_specs() {
207 let input = r#"spec person
208data name: "John"
209
210spec company
211data name: "Acme Corp""#;
212 let result = parse(
213 input,
214 crate::parsing::source::SourceType::Volatile,
215 &ResourceLimits::default(),
216 )
217 .unwrap()
218 .into_flattened_specs();
219 assert_eq!(result.len(), 2);
220 assert_eq!(result[0].name, "person");
221 assert_eq!(result[1].name, "company");
222 }
223
224 #[test]
225 fn parse_allows_duplicate_data_names() {
226 let input = r#"spec person
227data name: "John"
228data name: "Jane""#;
229 let result = parse(
230 input,
231 crate::parsing::source::SourceType::Volatile,
232 &ResourceLimits::default(),
233 );
234 assert!(
235 result.is_ok(),
236 "Parser should succeed even with duplicate data"
237 );
238 }
239
240 #[test]
241 fn parse_allows_duplicate_rule_names() {
242 let input = r#"spec person
243rule is_adult: age >= 18
244rule is_adult: age >= 21"#;
245 let result = parse(
246 input,
247 crate::parsing::source::SourceType::Volatile,
248 &ResourceLimits::default(),
249 );
250 assert!(
251 result.is_ok(),
252 "Parser should succeed even with duplicate rules"
253 );
254 }
255
256 #[test]
257 fn parse_rejects_malformed_input() {
258 let input = "invalid syntax here";
259 let result = parse(
260 input,
261 crate::parsing::source::SourceType::Volatile,
262 &ResourceLimits::default(),
263 );
264 assert!(result.is_err());
265 }
266
267 #[test]
268 fn parse_handles_whitespace_variants_in_expressions() {
269 let test_cases = vec![
270 ("spec test\nrule test: 2+3", "no spaces in arithmetic"),
271 ("spec test\nrule test: age>=18", "no spaces in comparison"),
272 (
273 "spec test\nrule test: age >= 18 and salary>50000",
274 "spaces around and keyword",
275 ),
276 (
277 "spec test\nrule test: age >= 18 and salary > 50000",
278 "extra spaces",
279 ),
280 (
281 "spec test\nrule test: \n age >= 18 \n and \n salary > 50000",
282 "newlines in expression",
283 ),
284 ];
285
286 for (input, description) in test_cases {
287 let result = parse(
288 input,
289 crate::parsing::source::SourceType::Volatile,
290 &ResourceLimits::default(),
291 );
292 assert!(
293 result.is_ok(),
294 "Failed to parse {} ({}): {:?}",
295 input,
296 description,
297 result.err()
298 );
299 }
300 }
301
302 #[test]
303 fn parse_error_cases_are_rejected() {
304 let error_cases = vec![
305 (
306 "spec test\ndata name: \"unclosed string",
307 "unclosed string literal",
308 ),
309 ("spec test\nrule test: (2 + 3", "unclosed parenthesis"),
310 ("spec test\nrule test: 2 + 3)", "extra closing paren"),
311 ("spec test\ndata spec: 123", "reserved keyword as data name"),
312 (
313 "spec test\nrule rule: true",
314 "reserved keyword as rule name",
315 ),
316 ];
317
318 for (input, description) in error_cases {
319 let result = parse(
320 input,
321 crate::parsing::source::SourceType::Volatile,
322 &ResourceLimits::default(),
323 );
324 assert!(
325 result.is_err(),
326 "Expected error for {} but got success",
327 description
328 );
329 }
330 }
331
332 #[test]
333 fn parse_duration_literals_in_rules() {
334 let test_cases = vec![
335 ("2 year", "year"),
336 ("6 month", "month"),
337 ("52 week", "week"),
338 ("365 day", "day"),
339 ("24 hour", "hour"),
340 ("60 minute", "minute"),
341 ("3600 second", "second"),
342 ("1000 millisecond", "millisecond"),
343 ("500000 microsecond", "microsecond"),
344 ("50 percent", "percent"),
345 ];
346
347 for (expr, description) in test_cases {
348 let input = format!("spec test\nrule test: {}", expr);
349 let result = parse(
350 &input,
351 crate::parsing::source::SourceType::Volatile,
352 &ResourceLimits::default(),
353 );
354 assert!(
355 result.is_ok(),
356 "Failed to parse literal {} ({}): {:?}",
357 expr,
358 description,
359 result.err()
360 );
361 }
362 }
363
364 #[test]
365 fn parse_comparisons_with_duration_unit_conversions() {
366 let test_cases = vec![
367 (
368 "(duration as hour) > 2",
369 "duration conversion in comparison with parens",
370 ),
371 (
372 "(meeting_time as minute) >= 30",
373 "duration conversion with gte",
374 ),
375 (
376 "(project_length as day) < 100",
377 "duration conversion with lt",
378 ),
379 (
380 "(delay as second) is 60",
381 "duration conversion with equality",
382 ),
383 (
384 "(1 hour) > (30 minute)",
385 "duration conversions on both sides",
386 ),
387 ("duration as hour > 2", "duration conversion without parens"),
388 (
389 "meeting_time as second > 3600",
390 "variable duration conversion in comparison",
391 ),
392 (
393 "project_length as day > deadline_days",
394 "two variables with duration conversion",
395 ),
396 (
397 "duration as hour >= 1 and duration as hour <= 8",
398 "multiple duration comparisons",
399 ),
400 (
401 "(2024-06-01...2024-06-15) as day as number >= 7",
402 "chained as conversion before comparison",
403 ),
404 ("duration as hour as number > 2", "chained as on duration"),
405 ];
406
407 for (expr, description) in test_cases {
408 let input = format!("spec test\nrule test: {}", expr);
409 let result = parse(
410 &input,
411 crate::parsing::source::SourceType::Volatile,
412 &ResourceLimits::default(),
413 );
414 assert!(
415 result.is_ok(),
416 "Failed to parse {} ({}): {:?}",
417 expr,
418 description,
419 result.err()
420 );
421 }
422 }
423
424 #[test]
425 fn parse_rejects_token_after_unit_conversion() {
426 let result = parse(
427 "spec test\nuses lemma units\nrule ok: (2024-06-01...2024-06-15) as day foo",
428 crate::parsing::source::SourceType::Volatile,
429 &ResourceLimits::default(),
430 );
431 let err = result.expect_err("expected parse error");
432 let msg = err.to_string();
433 assert!(
434 msg.contains("Unexpected token") && msg.contains("foo"),
435 "expected error at 'foo', got: {}",
436 msg
437 );
438 assert!(
439 !msg.contains("Expected 'data'"),
440 "should not defer to spec-level error, got: {}",
441 msg
442 );
443 }
444
445 #[test]
446 fn parse_unit_conversion_before_next_spec() {
447 let result = parse(
448 r#"spec pricing
449rule hourly_rate: 150 eur
450 unless loyalty is "silver" then 140 eur
451 unless loyalty is "gold" then 125 usd as eur
452
453spec other
454rule x: 1"#,
455 crate::parsing::source::SourceType::Volatile,
456 &ResourceLimits::default(),
457 );
458 assert!(
459 result.is_ok(),
460 "unless branch ending with 'as' must parse before next spec: {:?}",
461 result.err()
462 );
463 }
464
465 #[test]
466 fn parse_unit_conversion_before_sibling_rule() {
467 let result = parse(
468 r#"spec s
469rule a: 100 usd as eur
470rule b: 1"#,
471 crate::parsing::source::SourceType::Volatile,
472 &ResourceLimits::default(),
473 );
474 assert!(
475 result.is_ok(),
476 "rule ending with 'as' must parse before sibling rule: {:?}",
477 result.err()
478 );
479 }
480
481 #[test]
482 fn parse_unit_conversion_before_uses() {
483 let result = parse(
484 r#"spec s
485rule rate: 10 usd as eur
486uses lemma units
487rule hour: 1 hour"#,
488 crate::parsing::source::SourceType::Volatile,
489 &ResourceLimits::default(),
490 );
491 assert!(
492 result.is_ok(),
493 "rule ending with 'as' must parse before uses: {:?}",
494 result.err()
495 );
496 }
497
498 #[test]
500 fn parse_unit_conversion_before_expression_boundaries() {
501 let cases: &[(&str, &str)] = &[
502 (
503 "sibling data",
504 r#"spec s
505rule rate: 10 usd as eur
506data price: 100 eur"#,
507 ),
508 (
509 "sibling uses",
510 r#"spec s
511rule rate: 10 usd as eur
512uses other"#,
513 ),
514 (
515 "sibling meta",
516 r#"spec s
517rule rate: 10 usd as eur
518meta version: 1"#,
519 ),
520 (
521 "another unless",
522 r#"spec s
523rule rate: 10 usd
524 unless active then 5 usd as eur
525 unless premium then 3 usd as eur"#,
526 ),
527 (
528 "eof",
529 r#"spec s
530rule rate: 10 usd as eur"#,
531 ),
532 (
533 "next repo",
534 r#"spec s
535rule rate: 10 usd as eur
536
537repo other
538spec t
539rule x: 1"#,
540 ),
541 (
542 "unless then before next unless",
543 r#"spec s
544rule rate: 10 usd
545 unless a then 1 usd as eur
546 unless b then 2"#,
547 ),
548 (
549 "chained as before sibling rule",
550 r#"spec s
551rule rate: (2024-01-01...2024-01-02) as day as number
552rule other: 1"#,
553 ),
554 ];
555
556 for (label, source) in cases {
557 let result = parse(
558 source,
559 crate::parsing::source::SourceType::Volatile,
560 &ResourceLimits::default(),
561 );
562 assert!(
563 result.is_ok(),
564 "unit conversion before {label} must parse: {:?}",
565 result.err()
566 );
567 }
568 }
569
570 #[test]
571 fn parse_rejects_plain_number_plus_converted_operand() {
572 let result = parse(
573 r#"spec test
574data c: measure
575 -> unit eur: 1
576 -> unit usd: 0.84
577rule z: 5 + c as usd"#,
578 crate::parsing::source::SourceType::Volatile,
579 &ResourceLimits::default(),
580 );
581 let err = result.expect_err("expected parse error for 5 + c as usd");
582 let msg = err.to_string();
583 assert!(
584 msg.contains("plain number") || msg.contains("each operand"),
585 "expected conversion-before-+ error, got: {msg}"
586 );
587 }
588
589 #[test]
590 fn parse_accepts_conversion_on_each_additive_operand() {
591 let cases: &[(&str, &str)] = &[
592 (
593 "money",
594 r#"spec test
595data c: measure
596 -> unit eur: 1
597 -> unit usd: 0.84
598rule z: 5 as usd + c as usd"#,
599 ),
600 (
601 "duration + literal",
602 r#"spec test
603uses lemma units
604rule z: duration as hour + 1"#,
605 ),
606 (
607 "duration + comparison",
608 r#"spec test
609uses lemma units
610data duration: units.duration
611 -> suggest 1 hour
612rule z: duration as hour + 1 > 0"#,
613 ),
614 (
615 "date range + ref",
616 r#"spec test
617uses lemma units
618data age: date range
619data c: measure
620 -> unit eur: 1
621rule z: age as day + c"#,
622 ),
623 ];
624 for (label, source) in cases {
625 let result = parse(
626 source,
627 crate::parsing::source::SourceType::Volatile,
628 &ResourceLimits::default(),
629 );
630 assert!(
631 result.is_ok(),
632 "expected {label} to parse, got: {:?}",
633 result.err()
634 );
635 }
636 }
637
638 fn decimal(value: &str) -> rust_decimal::Decimal {
639 value.parse().expect("decimal literal in test")
640 }
641
642 fn parse_single_spec(source: &str) -> LemmaSpec {
643 let specs = parse(
644 source,
645 crate::parsing::source::SourceType::Volatile,
646 &ResourceLimits::default(),
647 )
648 .expect("spec must parse")
649 .into_flattened_specs();
650 assert_eq!(specs.len(), 1, "expected one spec, got {}", specs.len());
651 specs[0].clone()
652 }
653
654 fn measure_unit_constraints(spec: &LemmaSpec) -> Vec<Constraint> {
655 let mut unit_rows = Vec::new();
656 for data in &spec.data {
657 let DataValue::Definition { constraints, .. } = &data.value else {
658 continue;
659 };
660 if let Some(rows) = constraints {
661 for row in rows {
662 if row.command == TypeConstraintCommand::Unit {
663 unit_rows.push(row.clone());
664 }
665 }
666 }
667 }
668 unit_rows
669 }
670
671 fn unit_name_and_arg(row: &Constraint) -> (String, UnitArg) {
672 let args = &row.args;
673 let name = match args.first() {
674 Some(CommandArg::Label(label)) => label.clone(),
675 other => panic!("unit constraint needs label name, got: {:?}", other),
676 };
677 let unit_arg = match args.get(1) {
678 Some(CommandArg::UnitExpr(unit_arg)) => unit_arg.clone(),
679 other => panic!("unit constraint needs UnitExpr payload, got: {:?}", other),
680 };
681 (name, unit_arg)
682 }
683
684 #[test]
687 fn parse_unit_colon_factor_payload() {
688 let spec = parse_single_spec(
689 r#"spec money_spec
690data money: measure
691 -> unit eur: 1.00
692"#,
693 );
694 let rows = measure_unit_constraints(&spec);
695 assert_eq!(rows.len(), 1);
696 let (name, unit_arg) = unit_name_and_arg(&rows[0]);
697 assert_eq!(name, "eur");
698 assert_eq!(unit_arg, UnitArg::Factor(decimal("1.00")));
699 assert!(!rows[0].deprecated_without_colon);
700 }
701
702 #[test]
703 fn parse_unit_colon_compound_only_payload() {
704 let spec = parse_single_spec(
705 r#"spec rates
706uses lemma units
707
708data money: measure
709 -> unit eur: 1.00
710
711data rate: measure
712 -> unit eur_per_hour: eur/hour
713"#,
714 );
715 let rows = measure_unit_constraints(&spec);
716 assert_eq!(rows.len(), 2);
717 let (name, unit_arg) = unit_name_and_arg(&rows[1]);
718 assert_eq!(name, "eur_per_hour");
719 assert_eq!(
720 unit_arg,
721 UnitArg::Expr(
722 rust_decimal::Decimal::ONE,
723 vec![
724 UnitFactor {
725 measure_ref: "eur".to_string(),
726 exp: 1,
727 },
728 UnitFactor {
729 measure_ref: "hour".to_string(),
730 exp: -1,
731 }
732 ],
733 )
734 );
735 assert!(!rows[1].deprecated_without_colon);
736 }
737
738 #[test]
739 fn parse_unit_colon_numeric_prefix_and_compound_payload() {
740 let spec = parse_single_spec(
741 r#"spec speed
742uses lemma units
743
744data velocity: measure
745 -> unit kmh: 3.6 meter/second
746"#,
747 );
748 let rows = measure_unit_constraints(&spec);
749 assert_eq!(rows.len(), 1);
750 let (name, unit_arg) = unit_name_and_arg(&rows[0]);
751 assert_eq!(name, "kmh");
752 assert_eq!(
753 unit_arg,
754 UnitArg::Expr(
755 decimal("3.6"),
756 vec![
757 UnitFactor {
758 measure_ref: "meter".to_string(),
759 exp: 1,
760 },
761 UnitFactor {
762 measure_ref: "second".to_string(),
763 exp: -1,
764 }
765 ],
766 )
767 );
768 assert!(!rows[0].deprecated_without_colon);
769 }
770
771 #[test]
774 fn parse_unit_deprecated_space_factor_payload() {
775 let spec = parse_single_spec(
776 r#"spec money_spec
777data money: measure
778 -> unit eur 1.00
779"#,
780 );
781 let rows = measure_unit_constraints(&spec);
782 assert_eq!(rows.len(), 1);
783 let (name, unit_arg) = unit_name_and_arg(&rows[0]);
784 assert_eq!(name, "eur");
785 assert_eq!(unit_arg, UnitArg::Factor(decimal("1.00")));
786 assert!(rows[0].deprecated_without_colon);
787 }
788
789 #[test]
790 fn parse_unit_deprecated_space_compound_only_payload() {
791 let spec = parse_single_spec(
792 r#"spec rates
793uses lemma units
794
795data money: measure
796 -> unit eur 1.00
797
798data rate: measure
799 -> unit eur_per_hour eur/hour
800"#,
801 );
802 let rows = measure_unit_constraints(&spec);
803 assert_eq!(rows.len(), 2);
804 let (name, unit_arg) = unit_name_and_arg(&rows[1]);
805 assert_eq!(name, "eur_per_hour");
806 assert_eq!(
807 unit_arg,
808 UnitArg::Expr(
809 rust_decimal::Decimal::ONE,
810 vec![
811 UnitFactor {
812 measure_ref: "eur".to_string(),
813 exp: 1,
814 },
815 UnitFactor {
816 measure_ref: "hour".to_string(),
817 exp: -1,
818 }
819 ],
820 )
821 );
822 assert!(rows[1].deprecated_without_colon);
823 }
824
825 #[test]
826 fn parse_unit_deprecated_space_numeric_prefix_and_compound_payload() {
827 let spec = parse_single_spec(
828 r#"spec speed
829uses lemma units
830
831data velocity: measure
832 -> unit kmh 3.6 meter/second
833"#,
834 );
835 let rows = measure_unit_constraints(&spec);
836 assert_eq!(rows.len(), 1);
837 let (name, unit_arg) = unit_name_and_arg(&rows[0]);
838 assert_eq!(name, "kmh");
839 assert_eq!(
840 unit_arg,
841 UnitArg::Expr(
842 decimal("3.6"),
843 vec![
844 UnitFactor {
845 measure_ref: "meter".to_string(),
846 exp: 1,
847 },
848 UnitFactor {
849 measure_ref: "second".to_string(),
850 exp: -1,
851 }
852 ],
853 )
854 );
855 assert!(rows[0].deprecated_without_colon);
856 }
857
858 #[test]
859 fn parse_unit_deprecated_space_matches_colon_payload() {
860 let canonical = parse_single_spec(
861 r#"spec rates
862uses lemma units
863
864data money: measure
865 -> unit eur: 1.00
866
867data rate: measure
868 -> unit eur_per_hour: eur/hour
869"#,
870 );
871 let deprecated = parse_single_spec(
872 r#"spec rates
873uses lemma units
874
875data money: measure
876 -> unit eur 1.00
877
878data rate: measure
879 -> unit eur_per_hour eur/hour
880"#,
881 );
882 let canonical_args: Vec<UnitArg> = measure_unit_constraints(&canonical)
883 .iter()
884 .map(|row| unit_name_and_arg(row).1)
885 .collect();
886 let deprecated_args: Vec<UnitArg> = measure_unit_constraints(&deprecated)
887 .iter()
888 .map(|row| unit_name_and_arg(row).1)
889 .collect();
890 assert_eq!(canonical_args, deprecated_args);
891 }
892
893 #[test]
894 fn parse_unit_colon_without_payload_errors() {
895 let result = parse(
896 r#"spec s
897data money: measure
898 -> unit eur:
899"#,
900 crate::parsing::source::SourceType::Volatile,
901 &ResourceLimits::default(),
902 );
903 assert!(
904 result.is_err(),
905 "empty unit payload after colon must not parse"
906 );
907 }
908
909 fn rule_expression(source: &str, rule_name: &str) -> Expression {
910 let parsed = parse(
911 source,
912 crate::parsing::source::SourceType::Volatile,
913 &ResourceLimits::default(),
914 )
915 .expect("expected parse");
916 let spec = parsed
917 .flatten_specs()
918 .into_iter()
919 .next()
920 .expect("expected one spec");
921 spec.rules
922 .iter()
923 .find(|rule| rule.name == rule_name)
924 .unwrap_or_else(|| panic!("rule '{rule_name}' not found"))
925 .expression
926 .clone()
927 }
928
929 fn assert_multiply_range_side(
930 root: ExprRef<'_, ExpressionKind>,
931 range_on_left: bool,
932 label: &str,
933 ) {
934 let Some((left, ArithmeticComputation::Multiply, right)) = binary_arithmetic(root) else {
935 panic!("{label}: expected Multiply, got {:?}", root.kind());
936 };
937 let (range, other) = if range_on_left {
938 (left, right)
939 } else {
940 (right, left)
941 };
942 assert!(
943 matches!(range.kind(), ExpressionKind::RangeLiteral(..)),
944 "{label}: expected RangeLiteral on {} of *, got {:?}",
945 if range_on_left { "left" } else { "right" },
946 range.kind()
947 );
948 assert!(
949 !matches!(other.kind(), ExpressionKind::RangeLiteral(..)),
950 "{label}: expected non-range on other side of *"
951 );
952 }
953
954 #[test]
955 fn parse_range_binds_tighter_than_multiply() {
956 let base = r#"spec test
957uses lemma units
958data rate: measure -> unit eur: 1
959data period_start: 2026-01-01
960data period_end: 2026-01-02
961"#;
962 let rhs = rule_expression(
963 &format!("{base}rule rhs: rate * period_start...period_end"),
964 "rhs",
965 );
966 assert_multiply_range_side(rhs.root(), false, "rate * period_start...period_end");
967 let lhs = rule_expression(
968 &format!("{base}rule lhs: period_start...period_end * rate"),
969 "lhs",
970 );
971 assert_multiply_range_side(lhs.root(), true, "period_start...period_end * rate");
972 }
973
974 #[test]
975 fn parse_range_additive_right_endpoint_binds_inside_literal() {
976 let expression = rule_expression(
977 r#"spec test
978uses lemma units
979data start: date
980data length: units.duration
981rule valid: now in start...start + length"#,
982 "valid",
983 );
984 let root = expression.root();
985 let ExpressionKind::RangeContainment(value, range) = root.kind() else {
986 panic!("expected RangeContainment, got {:?}", root.kind());
987 };
988 let value = root.child(*value);
989 assert!(
990 matches!(value.kind(), ExpressionKind::Now),
991 "expected now as containment value, got {:?}",
992 value.kind()
993 );
994 let range = root.child(*range);
995 let ExpressionKind::RangeLiteral(left, right) = range.kind() else {
996 panic!(
997 "expected RangeLiteral as containment range, got {:?}",
998 range.kind()
999 );
1000 };
1001 let left = range.child(*left);
1002 assert!(
1003 matches!(left.kind(), ExpressionKind::Reference(..)),
1004 "expected start reference as left endpoint, got {:?}",
1005 left.kind()
1006 );
1007 let right = range.child(*right);
1008 let Some((add_left, ArithmeticComputation::Add, add_right)) = binary_arithmetic(right)
1009 else {
1010 panic!(
1011 "expected start + length as right endpoint, got {:?}",
1012 right.kind()
1013 );
1014 };
1015 assert!(
1016 matches!(add_left.kind(), ExpressionKind::Reference(..)),
1017 "expected start reference in right endpoint add, got {:?}",
1018 add_left.kind()
1019 );
1020 assert!(
1021 matches!(add_right.kind(), ExpressionKind::Reference(..)),
1022 "expected length reference in right endpoint add, got {:?}",
1023 add_right.kind()
1024 );
1025 }
1026
1027 #[test]
1028 fn parse_range_multiply_with_conversion_without_inner_parens() {
1029 let expression = rule_expression(
1030 r#"spec test
1031uses lemma units
1032data money: measure -> unit eur: 1
1033data rate: measure -> unit eur_per_hour: eur/hour
1034data hourly_rate: 50 eur_per_hour
1035data period_start: 2026-01-01
1036data period_end: 2026-01-02
1037rule pay: (hourly_rate * period_start...period_end) as eur"#,
1038 "pay",
1039 );
1040 let ExpressionKind::UnitConversion(inner, _) = expression.root_kind() else {
1041 panic!("expected UnitConversion, got {:?}", expression.root_kind());
1042 };
1043 assert_multiply_range_side(expression.at(*inner), false, "pay");
1044 }
1045
1046 #[test]
1047 fn parse_error_includes_attribute_and_parse_error_spec_name() {
1048 let result = parse(
1049 r#"
1050spec test
1051data name: "Unclosed string
1052data age: 25
1053"#,
1054 crate::parsing::source::SourceType::Volatile,
1055 &ResourceLimits::default(),
1056 );
1057
1058 match result {
1059 Err(Error::Parsing(details)) => {
1060 let src = details
1061 .source
1062 .as_ref()
1063 .expect("BUG: parsing errors always have source");
1064 assert_eq!(
1065 src.source_type,
1066 crate::parsing::source::SourceType::Volatile
1067 );
1068 }
1069 Err(e) => panic!("Expected Parse error, got: {e:?}"),
1070 Ok(_) => panic!("Expected parse error for unclosed string"),
1071 }
1072 }
1073
1074 #[test]
1075 fn parse_single_spec_file() {
1076 let input = r#"spec somespec
1077data name: "Alice""#;
1078 let parsed = parse(
1079 input,
1080 crate::parsing::source::SourceType::Volatile,
1081 &ResourceLimits::default(),
1082 )
1083 .unwrap();
1084 let specs = parsed.flatten_specs();
1085 assert_eq!(specs.len(), 1);
1086 assert_eq!(specs[0].name, "somespec");
1087 }
1088
1089 #[test]
1090 fn parse_uses_registry_spec_explicit_alias() {
1091 let input = r#"spec example
1092uses external: @user/workspace somespec"#;
1093 let specs = parse(
1094 input,
1095 crate::parsing::source::SourceType::Volatile,
1096 &ResourceLimits::default(),
1097 )
1098 .unwrap()
1099 .into_flattened_specs();
1100 assert_eq!(specs.len(), 1);
1101 assert_eq!(specs[0].data.len(), 1);
1102 match &specs[0].data[0].value {
1103 crate::parsing::ast::DataValue::Import {
1104 spec_ref,
1105 bindings: _,
1106 } => {
1107 assert_eq!(spec_ref.name, "somespec");
1108 let repository_hdr = spec_ref
1109 .repository
1110 .as_ref()
1111 .expect("expected repository qualifier");
1112 assert_eq!(repository_hdr.name, "@user/workspace");
1113 }
1114 other => panic!("Expected Import, got: {:?}", other),
1115 }
1116 }
1117
1118 #[test]
1119 fn parse_multiple_specs_cross_reference_in_file() {
1120 let input = r#"spec spec_a
1121data x: 10
1122
1123spec spec_b
1124data y: 20
1125uses a: spec_a"#;
1126 let parsed = parse(
1127 input,
1128 crate::parsing::source::SourceType::Volatile,
1129 &ResourceLimits::default(),
1130 )
1131 .unwrap();
1132 let specs = parsed.flatten_specs();
1133 assert_eq!(specs.len(), 2);
1134 assert_eq!(specs[0].name, "spec_a");
1135 assert_eq!(specs[1].name, "spec_b");
1136 }
1137
1138 #[test]
1139 fn parse_uses_registry_spec_default_alias() {
1140 let input = "spec example\nuses @owner/repo somespec";
1141 let specs = parse(
1142 input,
1143 crate::parsing::source::SourceType::Volatile,
1144 &ResourceLimits::default(),
1145 )
1146 .unwrap()
1147 .into_flattened_specs();
1148 match &specs[0].data[0].value {
1149 crate::parsing::ast::DataValue::Import {
1150 spec_ref,
1151 bindings: _,
1152 } => {
1153 assert_eq!(spec_ref.name, "somespec");
1154 let repository_hdr = spec_ref
1155 .repository
1156 .as_ref()
1157 .expect("expected repository qualifier");
1158 assert_eq!(repository_hdr.name, "@owner/repo");
1159 }
1160 other => panic!("Expected Import, got: {:?}", other),
1161 }
1162 }
1163
1164 #[test]
1165 fn parse_uses_local_spec_default_alias() {
1166 let input = "spec example\nuses myspec";
1167 let specs = parse(
1168 input,
1169 crate::parsing::source::SourceType::Volatile,
1170 &ResourceLimits::default(),
1171 )
1172 .unwrap()
1173 .into_flattened_specs();
1174 match &specs[0].data[0].value {
1175 crate::parsing::ast::DataValue::Import {
1176 spec_ref,
1177 bindings: _,
1178 } => {
1179 assert_eq!(spec_ref.name, "myspec");
1180 assert!(
1181 spec_ref.repository.is_none(),
1182 "same-repository reference must omit repository qualifier"
1183 );
1184 }
1185 other => panic!("Expected Import, got: {:?}", other),
1186 }
1187 }
1188
1189 #[test]
1190 fn parse_spec_name_with_trailing_dot_is_opaque_name() {
1191 let input = "spec myspec.\ndata x: 1";
1192 let result = parse(
1193 input,
1194 crate::parsing::source::SourceType::Volatile,
1195 &ResourceLimits::default(),
1196 )
1197 .unwrap()
1198 .into_flattened_specs();
1199 assert_eq!(result[0].name, "myspec.");
1200 }
1201
1202 #[test]
1203 fn parse_multiple_specs_in_same_file() {
1204 let input = "spec myspec_a\nrule x: 1\n\nspec myspec_b\nrule x: 2";
1205 let result = parse(
1206 input,
1207 crate::parsing::source::SourceType::Volatile,
1208 &ResourceLimits::default(),
1209 )
1210 .unwrap()
1211 .into_flattened_specs();
1212 assert_eq!(result.len(), 2);
1213 assert_eq!(result[0].name, "myspec_a");
1214 assert_eq!(result[1].name, "myspec_b");
1215 }
1216
1217 #[test]
1218 fn parse_uses_accepts_name_only() {
1219 let input = "spec consumer\nuses other";
1220 let result = parse(
1221 input,
1222 crate::parsing::source::SourceType::Volatile,
1223 &ResourceLimits::default(),
1224 );
1225 assert!(result.is_ok(), "uses name should parse");
1226 let specs = result.unwrap().into_flattened_specs();
1227 let spec_ref = match &specs[0].data[0].value {
1228 crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1229 _ => panic!("expected Import"),
1230 };
1231 assert_eq!(spec_ref.name, "other");
1232 }
1233
1234 #[test]
1235 fn parse_uses_bare_year_effective() {
1236 let input = "spec consumer\nuses other 2026";
1237 let result = parse(
1238 input,
1239 crate::parsing::source::SourceType::Volatile,
1240 &ResourceLimits::default(),
1241 )
1242 .unwrap();
1243 let specs = result.into_flattened_specs();
1244 let spec_ref = match &specs[0].data[0].value {
1245 crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1246 _ => panic!("expected Import"),
1247 };
1248 assert_eq!(spec_ref.name, "other");
1249 let eff = spec_ref.effective.as_ref().expect("effective");
1250 assert_eq!(eff.year, 2026);
1251 assert_eq!(eff.month, 1);
1252 assert_eq!(eff.day, 1);
1253 }
1254
1255 #[test]
1256 fn parse_uses_registry_spec_ref_records_repository_and_target_spans() {
1257 let input = "spec consumer\nuses @iso/countries alpha2 2026";
1258 let result = parse(
1259 input,
1260 crate::parsing::source::SourceType::Volatile,
1261 &ResourceLimits::default(),
1262 )
1263 .unwrap();
1264 let spec = &result.flatten_specs()[0];
1265 let sr = match &spec.data[0].value {
1266 crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1267 _ => panic!("expected Import"),
1268 };
1269 let rs = sr
1270 .repository_span
1271 .as_ref()
1272 .expect("repository_span should be set for @-qualified uses");
1273 let ts = sr
1274 .target_span
1275 .as_ref()
1276 .expect("target_span should cover spec name and effective");
1277 assert_eq!(&input[rs.start..rs.end], "@iso/countries");
1278 assert_eq!(&input[ts.start..ts.end], "alpha2 2026");
1279 }
1280
1281 #[test]
1282 fn parse_uses_alias_no_comma_continuation() {
1283 let input = "spec consumer\nuses alias: pricing retail\ndata x: 1";
1284 let result = parse(
1285 input,
1286 crate::parsing::source::SourceType::Volatile,
1287 &ResourceLimits::default(),
1288 )
1289 .unwrap();
1290 let data = &result.flatten_specs()[0].data;
1291 assert_eq!(data.len(), 2);
1292 assert_eq!(data[0].reference.name, "alias");
1293 let sr = match &data[0].value {
1294 crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1295 _ => panic!("expected Import"),
1296 };
1297 assert_eq!(sr.name, "retail");
1298 let repository_hdr = sr
1299 .repository
1300 .as_ref()
1301 .expect("expected repository qualifier");
1302 assert_eq!(repository_hdr.name, "pricing");
1303 }
1304
1305 #[test]
1306 fn parse_data_qualified_type_with_effective_and_repository_on_uses() {
1307 let input = "spec consumer\nuses @iso/countries alpha2 2026-06-01\ndata country: alpha2.code -> option \"NL\"";
1308 let result = parse(
1309 input,
1310 crate::parsing::source::SourceType::Volatile,
1311 &ResourceLimits::default(),
1312 )
1313 .unwrap()
1314 .into_flattened_specs();
1315 let spec_ref = match &result[0].data[0].value {
1316 crate::parsing::ast::DataValue::Import { spec_ref: sr, .. } => sr,
1317 other => panic!("expected Import on uses row, got: {:?}", other),
1318 };
1319 assert_eq!(spec_ref.name, "alpha2");
1320
1321 let eff = spec_ref
1322 .effective
1323 .as_ref()
1324 .expect("expected effective datetime");
1325 assert_eq!(eff.year, 2026);
1326 assert_eq!(eff.month, 6);
1327
1328 let qualifier = spec_ref
1329 .repository
1330 .as_ref()
1331 .expect("expected repository qualifier");
1332 assert_eq!(qualifier.name, "@iso/countries");
1333
1334 match &result[0].data[1].value {
1335 crate::parsing::ast::DataValue::Definition {
1336 base,
1337 constraints,
1338 value,
1339 } => {
1340 assert!(value.is_none());
1341 assert_eq!(
1342 base.as_ref().expect("expected base"),
1343 &crate::parsing::ast::ParentType::Qualified {
1344 spec_alias: "alpha2".into(),
1345 inner: Box::new(crate::parsing::ast::ParentType::Custom {
1346 name: "code".into(),
1347 }),
1348 }
1349 );
1350
1351 let cs = constraints
1352 .as_ref()
1353 .expect("expected trailing constraint chain");
1354 assert_eq!(cs.len(), 1);
1355 }
1356 other => panic!("expected Definition, got: {:?}", other),
1357 }
1358 }
1359
1360 #[test]
1361 fn parse_qualified_type_with_slashed_opaque_spec_name() {
1362 let input = r#"
1363spec logistics/terms
1364data incoterm_type: text
1365
1366spec logistics/shipping
1367uses logistics/terms
1368data incoterm: logistics/terms.incoterm_type
1369"#;
1370 let result = parse(
1371 input,
1372 crate::parsing::source::SourceType::Volatile,
1373 &ResourceLimits::default(),
1374 )
1375 .unwrap()
1376 .into_flattened_specs();
1377 assert_eq!(result.len(), 2);
1378 assert_eq!(result[0].name, "logistics/terms");
1379 assert_eq!(result[1].name, "logistics/shipping");
1380 assert_eq!(result[1].data[0].reference.name, "logistics/terms");
1381 let import = match &result[1].data[0].value {
1382 crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1383 other => panic!("expected Import, got: {other:?}"),
1384 };
1385 assert_eq!(import.name, "logistics/terms");
1386 assert!(import.repository.is_none());
1387 match &result[1].data[1].value {
1388 crate::parsing::ast::DataValue::Definition {
1389 base: Some(base), ..
1390 } => {
1391 assert_eq!(
1392 base,
1393 &crate::parsing::ast::ParentType::Qualified {
1394 spec_alias: "logistics/terms".into(),
1395 inner: Box::new(crate::parsing::ast::ParentType::Custom {
1396 name: "incoterm_type".into(),
1397 }),
1398 }
1399 );
1400 }
1401 other => panic!("expected Definition with qualified base, got: {other:?}"),
1402 }
1403 }
1404
1405 #[test]
1406 fn parse_uses_slashed_name_alias_is_full_opaque_name() {
1407 let input = "spec consumer\nuses finance/units\ndata x: 1";
1408 let result = parse(
1409 input,
1410 crate::parsing::source::SourceType::Volatile,
1411 &ResourceLimits::default(),
1412 )
1413 .unwrap()
1414 .into_flattened_specs();
1415 assert_eq!(result[0].data[0].reference.name, "finance/units");
1416 let sr = match &result[0].data[0].value {
1417 crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1418 other => panic!("expected Import, got: {other:?}"),
1419 };
1420 assert_eq!(sr.name, "finance/units");
1421 assert!(sr.repository.is_none());
1422 }
1423
1424 #[test]
1425 fn parse_uses_terms_is_spec_named_terms_not_sibling_of_logistics() {
1426 let input = "spec logistics/shipping\nuses terms\ndata x: 1";
1427 let result = parse(
1428 input,
1429 crate::parsing::source::SourceType::Volatile,
1430 &ResourceLimits::default(),
1431 )
1432 .unwrap()
1433 .into_flattened_specs();
1434 assert_eq!(result[0].data[0].reference.name, "terms");
1435 let sr = match &result[0].data[0].value {
1436 crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1437 other => panic!("expected Import, got: {other:?}"),
1438 };
1439 assert_eq!(sr.name, "terms");
1440 assert!(sr.repository.is_none());
1441 }
1442
1443 #[test]
1444 fn parse_error_is_returned_for_garbage_input() {
1445 let result = parse(
1446 r#"
1447spec test
1448this is not valid lemma syntax @#$%
1449"#,
1450 crate::parsing::source::SourceType::Volatile,
1451 &ResourceLimits::default(),
1452 );
1453
1454 assert!(result.is_err(), "Should fail on malformed input");
1455 match result {
1456 Err(Error::Parsing { .. }) => {
1457 }
1459 Err(e) => panic!("Expected Parse error, got: {e:?}"),
1460 Ok(_) => panic!("Expected parse error"),
1461 }
1462 }
1463
1464 #[test]
1467 fn parse_local_with_literal_rejected() {
1468 let err = parse(
1469 r#"spec s
1470with x: 42"#,
1471 crate::parsing::source::SourceType::Volatile,
1472 &ResourceLimits::default(),
1473 )
1474 .unwrap_err();
1475 let msg = err.to_string();
1476 assert!(
1477 msg.contains("Standalone") || msg.contains("uses"),
1478 "expected standalone with rejection, got: {msg}"
1479 );
1480 }
1481
1482 #[test]
1483 fn parse_local_with_import_reference_rejected() {
1484 let err = parse(
1485 r#"spec s
1486uses i: inner
1487with copy: i.v"#,
1488 crate::parsing::source::SourceType::Volatile,
1489 &ResourceLimits::default(),
1490 )
1491 .unwrap_err();
1492 let msg = err.to_string();
1493 assert!(
1494 msg.contains("Standalone") || msg.contains("uses"),
1495 "expected standalone with rejection, got: {msg}"
1496 );
1497 }
1498
1499 #[test]
1500 fn parse_local_with_dotted_rhs_rejected() {
1501 let err = parse(
1502 r#"spec s
1503with x: a.something"#,
1504 crate::parsing::source::SourceType::Volatile,
1505 &ResourceLimits::default(),
1506 )
1507 .unwrap_err();
1508 let msg = err.to_string();
1509 assert!(
1510 msg.contains("Standalone") || msg.contains("uses"),
1511 "expected standalone with rejection, got: {msg}"
1512 );
1513 }
1514
1515 #[test]
1516 fn parse_local_with_multi_segment_rhs_rejected() {
1517 let err = parse(
1518 r#"spec s
1519with x: alpha.beta.gamma.delta"#,
1520 crate::parsing::source::SourceType::Volatile,
1521 &ResourceLimits::default(),
1522 )
1523 .unwrap_err();
1524 let msg = err.to_string();
1525 assert!(
1526 msg.contains("Standalone") || msg.contains("uses"),
1527 "expected standalone with rejection, got: {msg}"
1528 );
1529 }
1530
1531 #[test]
1534 fn parse_local_non_dotted_rhs_stays_definition_with_custom_base() {
1535 let input = r#"spec s
1536data x: myothertype"#;
1537 let result = parse(
1538 input,
1539 crate::parsing::source::SourceType::Volatile,
1540 &ResourceLimits::default(),
1541 )
1542 .unwrap()
1543 .into_flattened_specs();
1544 let value = &result[0].data[0].value;
1545 assert!(
1546 matches!(
1547 value,
1548 crate::parsing::ast::DataValue::Definition {
1549 base: Some(crate::parsing::ast::ParentType::Custom { .. }),
1550 ..
1551 }
1552 ),
1553 "non-dotted local RHS must stay Definition with custom base, got: {:?}",
1554 value
1555 );
1556 }
1557
1558 #[test]
1560 fn parse_uses_binding_non_dotted_rhs_is_reference() {
1561 let input = r#"spec s
1562uses child: other
1563 -> with slot: somename"#;
1564 let result = parse(
1565 input,
1566 crate::parsing::source::SourceType::Volatile,
1567 &ResourceLimits::default(),
1568 )
1569 .unwrap()
1570 .into_flattened_specs();
1571 let bindings = match &result[0].data[0].value {
1572 crate::parsing::ast::DataValue::Import { bindings, .. } => bindings,
1573 other => panic!("expected Import, got: {:?}", other),
1574 };
1575 assert_eq!(bindings.len(), 1);
1576 assert!(
1577 matches!(
1578 &bindings[0].rhs,
1579 crate::parsing::ast::WithRhs::Reference { .. }
1580 ),
1581 "non-dotted RHS must yield Reference; got: {:?}",
1582 bindings[0].rhs
1583 );
1584 }
1585
1586 #[test]
1588 fn parse_data_colon_spec_rhs_is_rejected() {
1589 let result = parse(
1590 r#"
1591spec s
1592data x: spec other
1593"#,
1594 crate::parsing::source::SourceType::Volatile,
1595 &ResourceLimits::default(),
1596 );
1597 match result {
1598 Ok(_) => panic!("`data x: spec other` must fail to parse"),
1599 Err(err) => {
1600 let msg = err.to_string();
1601 assert!(
1602 msg.contains("uses") && msg.contains("spec"),
1603 "error must direct to `uses` for spec import, got: {msg}"
1604 );
1605 }
1606 }
1607 }
1608
1609 #[test]
1611 fn parse_uses_binding_with_dotted_rhs_preserves_both_sides() {
1612 let input = r#"spec s
1613uses outer: other
1614 -> with inner: target.field"#;
1615 let result = parse(
1616 input,
1617 crate::parsing::source::SourceType::Volatile,
1618 &ResourceLimits::default(),
1619 )
1620 .unwrap()
1621 .into_flattened_specs();
1622 let bindings = match &result[0].data[0].value {
1623 crate::parsing::ast::DataValue::Import { bindings, .. } => bindings,
1624 other => panic!("expected Import, got: {:?}", other),
1625 };
1626 assert_eq!(bindings.len(), 1);
1627 assert_eq!(bindings[0].path.segments, vec![] as Vec<String>);
1628 assert_eq!(bindings[0].path.name, "inner");
1629 match &bindings[0].rhs {
1630 crate::parsing::ast::WithRhs::Reference { target } => {
1631 assert_eq!(target.segments, vec!["target"]);
1632 assert_eq!(target.name, "field");
1633 }
1634 other => panic!("expected Reference rhs, got: {:?}", other),
1635 }
1636 }
1637
1638 #[test]
1639 fn parse_deprecated_standalone_with_is_error() {
1640 let input = r#"spec inner
1641data x: number
1642
1643spec outer
1644uses i: inner
1645with i.x: 42
1646rule r: i.x"#;
1647 let result = parse(
1648 input,
1649 crate::parsing::source::SourceType::Volatile,
1650 &ResourceLimits::default(),
1651 );
1652 match result {
1653 Ok(_) => panic!("standalone with after uses must not parse"),
1654 Err(err) => {
1655 let msg = err.to_string();
1656 assert!(
1657 msg.contains("with"),
1658 "error should mention with; got: {msg}"
1659 );
1660 }
1661 }
1662 }
1663
1664 #[test]
1665 fn parse_deprecated_standalone_with_before_uses_is_error() {
1666 let input = r#"spec inner
1667data slot: number
1668
1669spec outer
1670with i.slot: 99
1671uses i: inner
1672rule r: i.slot"#;
1673 let result = parse(
1674 input,
1675 crate::parsing::source::SourceType::Volatile,
1676 &ResourceLimits::default(),
1677 );
1678 match result {
1679 Ok(_) => panic!("standalone with before uses must not parse"),
1680 Err(err) => {
1681 let msg = err.to_string();
1682 assert!(
1683 msg.contains("with"),
1684 "error should mention with; got: {msg}"
1685 );
1686 }
1687 }
1688 }
1689
1690 #[test]
1691 fn parse_standalone_with_without_matching_uses_errors() {
1692 let result = parse(
1693 r#"spec s
1694with outer.inner: 1"#,
1695 crate::parsing::source::SourceType::Volatile,
1696 &ResourceLimits::default(),
1697 );
1698 match result {
1699 Ok(_) => panic!("standalone with without uses must not parse"),
1700 Err(err) => {
1701 let msg = err.to_string();
1702 assert!(
1703 msg.contains("with"),
1704 "error should mention with; got: {msg}"
1705 );
1706 }
1707 }
1708 }
1709
1710 #[test]
1711 fn parse_data_on_binding_path_is_rejected_with_with_hint() {
1712 let result = parse(
1713 r#"spec s
1714data outer.inner: 1"#,
1715 crate::parsing::source::SourceType::Volatile,
1716 &ResourceLimits::default(),
1717 );
1718 match result {
1719 Ok(_) => panic!("data with binding path must not parse"),
1720 Err(err) => {
1721 let msg = err.to_string();
1722 assert!(
1723 msg.contains("with"),
1724 "error should steer authors toward with; got: {msg}"
1725 );
1726 }
1727 }
1728 }
1729
1730 #[test]
1731 fn parse_bare_file_yields_single_anonymous_repository_group() {
1732 let input = "spec a\ndata x: 1\nspec b\ndata y: 2";
1733 let parsed = parse(
1734 input,
1735 crate::parsing::source::SourceType::Volatile,
1736 &ResourceLimits::default(),
1737 )
1738 .unwrap();
1739 assert_eq!(parsed.repositories.len(), 1);
1740 let (repo, specs) = parsed.repositories.iter().next().unwrap();
1741 assert!(repo.name.is_none());
1742 assert_eq!(specs.len(), 2);
1743 assert_eq!(specs[0].name, "a");
1744 assert_eq!(specs[1].name, "b");
1745 }
1746
1747 #[test]
1748 fn parse_repo_sections_preserve_order_and_names() {
1749 let input = r#"repo r1
1750
1751spec a
1752data x: 1
1753
1754repo r2
1755
1756spec b
1757data y: 2"#;
1758 let parsed = parse(
1759 input,
1760 crate::parsing::source::SourceType::Volatile,
1761 &ResourceLimits::default(),
1762 )
1763 .unwrap();
1764 assert_eq!(parsed.repositories.len(), 2);
1765 let keys: Vec<_> = parsed.repositories.keys().collect();
1766 assert_eq!(keys[0].name.as_deref(), Some("r1"));
1767 assert_eq!(keys[1].name.as_deref(), Some("r2"));
1768 }
1769
1770 #[test]
1771 fn parse_duplicate_repo_name_merges_spec_lists() {
1772 let input = r#"repo dup
1773
1774spec a
1775data x: 1
1776
1777repo dup
1778
1779spec b
1780data y: 2"#;
1781 let parsed = parse(
1782 input,
1783 crate::parsing::source::SourceType::Volatile,
1784 &ResourceLimits::default(),
1785 )
1786 .unwrap();
1787 assert_eq!(parsed.repositories.len(), 1);
1788 assert_eq!(parsed.flatten_specs().len(), 2);
1789 }
1790
1791 #[test]
1792 fn parse_repo_with_no_specs_then_eof_yields_empty_spec_vec_for_that_repo() {
1793 let input = "repo empty";
1794 let parsed = parse(
1795 input,
1796 crate::parsing::source::SourceType::Volatile,
1797 &ResourceLimits::default(),
1798 )
1799 .unwrap();
1800 assert_eq!(parsed.repositories.len(), 1);
1801 let (_, specs) = parsed.repositories.iter().next().unwrap();
1802 assert_eq!(specs.len(), 0);
1803 }
1804
1805 #[test]
1806 fn parse_repo_followed_by_repo_without_specs_first_repo_empty_second_has_spec() {
1807 let input = "repo a\n\nrepo b\n\nspec s\ndata x: 1";
1808 let parsed = parse(
1809 input,
1810 crate::parsing::source::SourceType::Volatile,
1811 &ResourceLimits::default(),
1812 )
1813 .unwrap();
1814 assert_eq!(parsed.repositories.len(), 2);
1815 let names: Vec<_> = parsed
1816 .repositories
1817 .keys()
1818 .map(|r| r.name.as_deref())
1819 .collect();
1820 assert_eq!(names, vec![Some("a"), Some("b")]);
1821 assert!(parsed.repositories.values().next().unwrap().is_empty());
1822 assert_eq!(parsed.repositories.values().nth(1).unwrap().len(), 1);
1823 }
1824
1825 #[test]
1826 fn parse_spec_named_repo_keyword_should_be_rejected() {
1827 assert!(
1828 parse(
1829 "spec repo\ndata x: 1",
1830 crate::parsing::source::SourceType::Volatile,
1831 &ResourceLimits::default(),
1832 )
1833 .is_err(),
1834 "spec must not be allowed to use reserved keyword `repo` as its name"
1835 );
1836 }
1837
1838 #[test]
1839 fn parse_repo_declaration_cannot_use_spec_keyword_as_repository_name() {
1840 assert!(
1841 parse(
1842 "repo spec\n\nspec z\ndata q: 1\nrule r: q",
1843 crate::parsing::source::SourceType::Volatile,
1844 &ResourceLimits::default(),
1845 )
1846 .is_err(),
1847 "repository name cannot be the token `spec`"
1848 );
1849 }
1850
1851 #[test]
1852 fn parse_repo_declaration_cannot_use_data_keyword_as_repository_name() {
1853 assert!(
1854 parse(
1855 "repo data\n\nspec z\ndata q: 1\nrule r: q",
1856 crate::parsing::source::SourceType::Volatile,
1857 &ResourceLimits::default(),
1858 )
1859 .is_err(),
1860 "repository name cannot be the token `data`"
1861 );
1862 }
1863
1864 #[test]
1865 fn parse_repo_declaration_cannot_use_rule_keyword_as_repository_name() {
1866 assert!(
1867 parse(
1868 "repo rule\n\nspec z\ndata q: 1\nrule r: q",
1869 crate::parsing::source::SourceType::Volatile,
1870 &ResourceLimits::default(),
1871 )
1872 .is_err(),
1873 "repository name cannot be the token `rule`"
1874 );
1875 }
1876
1877 #[test]
1878 fn parse_data_named_repo_keyword_is_rejected() {
1879 let err = parse(
1880 "spec s\ndata repo: 1",
1881 crate::parsing::source::SourceType::Volatile,
1882 &ResourceLimits::default(),
1883 )
1884 .unwrap_err();
1885 assert!(
1886 err.to_string().contains("repo"),
1887 "data named repo should not parse: {}",
1888 err
1889 );
1890 }
1891
1892 #[test]
1893 fn parse_rule_named_repo_keyword_is_rejected() {
1894 let err = parse(
1895 "spec s\ndata x: 1\nrule repo: x",
1896 crate::parsing::source::SourceType::Volatile,
1897 &ResourceLimits::default(),
1898 )
1899 .unwrap_err();
1900 let msg = err.to_string();
1901 assert!(
1902 msg.contains("repo") || msg.contains("reserved"),
1903 "rule named repo should not parse: {msg}"
1904 );
1905 }
1906
1907 #[test]
1908 fn parse_repo_declaration_accepts_non_keyword_repository_identifier() {
1909 let parsed = parse(
1910 "repo warehouse\n\nspec z\ndata q: 1\nrule r: q",
1911 crate::parsing::source::SourceType::Volatile,
1912 &ResourceLimits::default(),
1913 )
1914 .unwrap();
1915 assert_eq!(parsed.repositories.len(), 1);
1916 assert_eq!(
1917 parsed.repositories.keys().next().unwrap().name.as_deref(),
1918 Some("warehouse")
1919 );
1920 }
1921
1922 #[test]
1923 fn parse_repo_name_case_insensitive_same_repository_merged() {
1924 let input = "repo Foo\n\nspec a\ndata x: 1\n\nrepo foo\n\nspec b\ndata y: 2";
1925 let parsed = parse(
1926 input,
1927 crate::parsing::source::SourceType::Volatile,
1928 &ResourceLimits::default(),
1929 )
1930 .unwrap();
1931 assert_eq!(
1932 parsed.repositories.len(),
1933 1,
1934 "Foo and foo are the same repository after canonicalization"
1935 );
1936 let specs: Vec<_> = parsed.repositories.values().next().unwrap().clone();
1937 assert_eq!(specs.len(), 2);
1938 assert_eq!(specs[0].name, "a");
1939 assert_eq!(specs[1].name, "b");
1940 }
1941
1942 #[test]
1943 fn parse_repo_empty_name_errors() {
1944 let err = parse(
1945 "repo \nspec a\ndata x: 1",
1946 crate::parsing::source::SourceType::Volatile,
1947 &ResourceLimits::default(),
1948 )
1949 .unwrap_err();
1950 assert!(
1951 !err.to_string().is_empty(),
1952 "empty repo name should not parse quietly: {err}"
1953 );
1954 }
1955
1956 #[test]
1957 fn parse_repo_numeric_name_behavior() {
1958 let input = "repo 123\n\nspec a\ndata x: 1";
1959 let result = parse(
1960 input,
1961 crate::parsing::source::SourceType::Volatile,
1962 &ResourceLimits::default(),
1963 );
1964 match result {
1965 Ok(parsed) => {
1966 assert_eq!(
1967 parsed.repositories.keys().next().unwrap().name.as_deref(),
1968 Some("123"),
1969 "if numeric repo names parse, identity must be stable"
1970 );
1971 }
1972 Err(e) => {
1973 assert!(
1974 !e.to_string().is_empty(),
1975 "rejecting numeric repo name is ok if explicit: {e}"
1976 );
1977 }
1978 }
1979 }
1980
1981 #[test]
1982 fn parse_duplicate_repo_three_sections_preserves_spec_order_abc() {
1983 let input = r#"repo dup
1984
1985spec a
1986data x: 1
1987
1988repo dup
1989
1990spec b
1991data y: 2
1992
1993repo dup
1994
1995spec c
1996data z: 3"#;
1997 let parsed = parse(
1998 input,
1999 crate::parsing::source::SourceType::Volatile,
2000 &ResourceLimits::default(),
2001 )
2002 .unwrap();
2003 assert_eq!(parsed.repositories.len(), 1);
2004 let specs = parsed.repositories.values().next().unwrap();
2005 assert_eq!(
2006 specs.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
2007 vec!["a", "b", "c"]
2008 );
2009 }
2010
2011 #[test]
2012 fn parse_repo_single_section_roundtrips_through_formatter() {
2013 let input = "repo r\n\nspec a\ndata x: 1";
2014 let parsed = parse(
2015 input,
2016 crate::parsing::source::SourceType::Volatile,
2017 &ResourceLimits::default(),
2018 )
2019 .unwrap();
2020 let formatted = format_parse_result(&parsed);
2021 let again = parse(
2022 &formatted,
2023 crate::parsing::source::SourceType::Volatile,
2024 &ResourceLimits::default(),
2025 )
2026 .unwrap();
2027 assert_eq!(again.repositories.len(), parsed.repositories.len());
2028 assert_eq!(again.flatten_specs().len(), parsed.flatten_specs().len());
2029 assert_eq!(
2030 again.flatten_specs()[0].name,
2031 parsed.flatten_specs()[0].name
2032 );
2033 }
2034
2035 #[test]
2036 fn parse_repo_two_sections_roundtrips_through_formatter() {
2037 let input = "repo r1\n\nspec a\ndata x: 1\n\nrepo r2\n\nspec b\ndata y: 2";
2038 let parsed = parse(
2039 input,
2040 crate::parsing::source::SourceType::Volatile,
2041 &ResourceLimits::default(),
2042 )
2043 .unwrap();
2044 let formatted = format_parse_result(&parsed);
2045 let again = parse(
2046 &formatted,
2047 crate::parsing::source::SourceType::Volatile,
2048 &ResourceLimits::default(),
2049 )
2050 .unwrap();
2051 assert_eq!(again.repositories.len(), 2);
2052 assert_eq!(again.flatten_specs().len(), 2);
2053 }
2054
2055 #[test]
2056 fn parse_repo_duplicate_merge_formatter_emits_single_repo_block_or_equivalent_parse() {
2057 let input = r#"repo dup
2058
2059spec a
2060data x: 1
2061
2062repo dup
2063
2064spec b
2065data y: 2"#;
2066 let parsed = parse(
2067 input,
2068 crate::parsing::source::SourceType::Volatile,
2069 &ResourceLimits::default(),
2070 )
2071 .unwrap();
2072 let formatted = format_parse_result(&parsed);
2073 let again = parse(
2074 &formatted,
2075 crate::parsing::source::SourceType::Volatile,
2076 &ResourceLimits::default(),
2077 )
2078 .unwrap();
2079 assert_eq!(
2080 again.repositories.len(),
2081 1,
2082 "formatted duplicate-repo file must still merge to one logical repo"
2083 );
2084 assert_eq!(again.flatten_specs().len(), 2);
2085 }
2086
2087 #[test]
2088 fn parse_rejects_data_named_measure() {
2089 let result = parse(
2090 "spec s\ndata measure: 1",
2091 crate::parsing::source::SourceType::Volatile,
2092 &ResourceLimits::default(),
2093 );
2094 assert!(
2095 result.is_err(),
2096 "data named measure (type keyword) must be rejected"
2097 );
2098 }
2099
2100 #[test]
2101 fn parse_rejects_data_named_number() {
2102 let result = parse(
2103 "spec s\ndata number: 42",
2104 crate::parsing::source::SourceType::Volatile,
2105 &ResourceLimits::default(),
2106 );
2107 assert!(
2108 result.is_err(),
2109 "data named number (type keyword) must be rejected"
2110 );
2111 }
2112
2113 #[test]
2114 fn parse_rejects_data_named_text() {
2115 let result = parse(
2116 "spec s\ndata text: \"hello\"",
2117 crate::parsing::source::SourceType::Volatile,
2118 &ResourceLimits::default(),
2119 );
2120 assert!(
2121 result.is_err(),
2122 "data named text (type keyword) must be rejected"
2123 );
2124 }
2125
2126 #[test]
2127 fn parse_rejects_data_named_date() {
2128 let result = parse(
2129 "spec s\ndata date: 2024-01-01",
2130 crate::parsing::source::SourceType::Volatile,
2131 &ResourceLimits::default(),
2132 );
2133 assert!(
2134 result.is_err(),
2135 "data named date (type keyword) must be rejected"
2136 );
2137 }
2138
2139 #[test]
2140 fn parse_rejects_data_named_boolean() {
2141 let result = parse(
2142 "spec s\ndata boolean: true",
2143 crate::parsing::source::SourceType::Volatile,
2144 &ResourceLimits::default(),
2145 );
2146 assert!(
2147 result.is_err(),
2148 "data named boolean (type keyword) must be rejected"
2149 );
2150 }
2151
2152 #[test]
2153 fn parse_rejects_data_named_ratio() {
2154 let result = parse(
2155 "spec s\ndata ratio: 5%",
2156 crate::parsing::source::SourceType::Volatile,
2157 &ResourceLimits::default(),
2158 );
2159 assert!(
2160 result.is_err(),
2161 "data named ratio (type keyword) must be rejected"
2162 );
2163 }
2164
2165 #[test]
2166 fn parse_rejects_rule_named_measure() {
2167 let result = parse(
2168 "spec s\ndata x: 1\nrule measure: x",
2169 crate::parsing::source::SourceType::Volatile,
2170 &ResourceLimits::default(),
2171 );
2172 assert!(
2173 result.is_err(),
2174 "rule named measure (type keyword) must be rejected"
2175 );
2176 }
2177
2178 #[test]
2179 fn parse_rejects_rule_named_number() {
2180 let result = parse(
2181 "spec s\ndata x: 1\nrule number: x",
2182 crate::parsing::source::SourceType::Volatile,
2183 &ResourceLimits::default(),
2184 );
2185 assert!(
2186 result.is_err(),
2187 "rule named number (type keyword) must be rejected"
2188 );
2189 }
2190
2191 #[test]
2192 fn parse_rejects_rule_named_text() {
2193 let result = parse(
2194 "spec s\ndata x: 1\nrule text: x",
2195 crate::parsing::source::SourceType::Volatile,
2196 &ResourceLimits::default(),
2197 );
2198 assert!(
2199 result.is_err(),
2200 "rule named text (type keyword) must be rejected"
2201 );
2202 }
2203
2204 #[test]
2205 fn parse_rejects_rule_named_date() {
2206 let result = parse(
2207 "spec s\ndata x: 1\nrule date: x",
2208 crate::parsing::source::SourceType::Volatile,
2209 &ResourceLimits::default(),
2210 );
2211 assert!(
2212 result.is_err(),
2213 "rule named date (type keyword) must be rejected"
2214 );
2215 }
2216
2217 #[test]
2218 fn parse_rejects_rule_named_boolean() {
2219 let result = parse(
2220 "spec s\ndata x: 1\nrule boolean: x",
2221 crate::parsing::source::SourceType::Volatile,
2222 &ResourceLimits::default(),
2223 );
2224 assert!(
2225 result.is_err(),
2226 "rule named boolean (type keyword) must be rejected"
2227 );
2228 }
2229
2230 #[test]
2231 fn parse_rejects_rule_named_ratio() {
2232 let result = parse(
2233 "spec s\ndata x: 1\nrule ratio: x",
2234 crate::parsing::source::SourceType::Volatile,
2235 &ResourceLimits::default(),
2236 );
2237 assert!(
2238 result.is_err(),
2239 "rule named ratio (type keyword) must be rejected"
2240 );
2241 }
2242
2243 fn assert_reserved_name(source: &str) {
2244 let result = parse(
2245 source,
2246 crate::parsing::source::SourceType::Volatile,
2247 &ResourceLimits::default(),
2248 );
2249 let message = result
2250 .expect_err("reserved name must not parse")
2251 .to_string();
2252 assert!(
2253 message.contains("reserved keyword"),
2254 "expected reserved-keyword error, got: {message}"
2255 );
2256 }
2257
2258 #[test]
2259 fn parse_rejects_lower_and_upper_as_names() {
2260 assert_reserved_name("spec s\ndata lower: 1");
2261 assert_reserved_name("spec s\ndata upper: 1");
2262 assert_reserved_name("spec s\nrule lower: 1");
2263 assert_reserved_name("spec s\nrule upper: 1");
2264 }
2265
2266 #[test]
2267 fn parse_constraint_lower_and_upper_still_parse() {
2268 let parsed = parse(
2269 r#"spec window
2270data period: date range
2271 -> lower 2020-01-01
2272 -> upper 2030-12-31
2273"#,
2274 crate::parsing::source::SourceType::Volatile,
2275 &ResourceLimits::default(),
2276 )
2277 .expect("-> lower / -> upper are constraint commands");
2278 let spec = parsed.flatten_specs().into_iter().next().expect("one spec");
2279 let period = spec
2280 .data
2281 .iter()
2282 .find(|data| data.reference.name == "period")
2283 .expect("period");
2284 let DataValue::Definition {
2285 constraints: Some(rows),
2286 ..
2287 } = &period.value
2288 else {
2289 panic!("expected constraints, got {:?}", period.value);
2290 };
2291 let commands: Vec<_> = rows.iter().map(|row| row.command).collect();
2292 assert_eq!(
2293 commands,
2294 vec![TypeConstraintCommand::Lower, TypeConstraintCommand::Upper]
2295 );
2296 }
2297
2298 fn prefix_expression(source: &str) -> Expression {
2299 rule_expression(source, "out")
2300 }
2301
2302 fn prefix_parse_error(source: &str) -> String {
2303 match parse(
2304 source,
2305 crate::parsing::source::SourceType::Volatile,
2306 &ResourceLimits::default(),
2307 ) {
2308 Err(Error::Parsing(details)) => details.message,
2309 other => panic!("expected parse Error, got {other:?}"),
2310 }
2311 }
2312
2313 #[test]
2314 fn parse_prefix_operand_is_call_or_one_operand() {
2315 for (body, operator) in [
2316 ("floor x + 1", "+"),
2317 ("floor x as gram", "as"),
2318 ("sqrt x ^ 2", "^"),
2319 ("lower p + 1", "+"),
2320 ("not ok and ok", "and"),
2321 ("not x > 1", ">"),
2322 ] {
2323 let message = prefix_parse_error(&format!(
2324 "spec test\nuses lemma units\ndata x: 1\ndata p: number range\ndata ok: boolean\nrule out: {body}"
2325 ));
2326 assert!(
2327 message.contains(&format!("found '{operator}' after it")),
2328 "{body}: {message}"
2329 );
2330 }
2331 let message = prefix_parse_error("spec test\nrule out: abs -1");
2332 assert!(message.contains("`abs(...)`"), "{message}");
2333
2334 let floor_plus = prefix_expression(
2335 r#"spec test
2336data x: 1
2337rule out: floor(x) + 1"#,
2338 );
2339 let Some((left, ArithmeticComputation::Add, _)) = binary_arithmetic(floor_plus.root())
2340 else {
2341 panic!("floor(x) + 1 must be add, got {floor_plus}");
2342 };
2343 assert!(matches!(
2344 left.kind(),
2345 ExpressionKind::MathematicalComputation(MathematicalComputation::Floor, _)
2346 ));
2347
2348 let floor_sum = prefix_expression(
2349 r#"spec test
2350data x: 1
2351rule out: floor (x + 1)"#,
2352 );
2353 let ExpressionKind::MathematicalComputation(MathematicalComputation::Floor, operand) =
2354 floor_sum.root_kind()
2355 else {
2356 panic!("floor (x + 1) must be floor of a sum, got {floor_sum}");
2357 };
2358 assert!(matches!(
2359 floor_sum.at(*operand).kind(),
2360 ExpressionKind::Arithmetic { .. }
2361 ));
2362
2363 let floor_abs = prefix_expression(
2364 r#"spec test
2365data x: 1
2366rule out: floor abs x"#,
2367 );
2368 let ExpressionKind::MathematicalComputation(MathematicalComputation::Floor, operand) =
2369 floor_abs.root_kind()
2370 else {
2371 panic!("floor abs x must be floor of abs, got {floor_abs}");
2372 };
2373 assert!(matches!(
2374 floor_abs.at(*operand).kind(),
2375 ExpressionKind::MathematicalComputation(MathematicalComputation::Abs, _)
2376 ));
2377
2378 let lower_range = prefix_expression(
2379 r#"spec test
2380rule out: lower 5...10"#,
2381 );
2382 let ExpressionKind::RangeBound(RangeBound::Lower, operand) = lower_range.root_kind() else {
2383 panic!("lower 5...10 must be lower of a range, got {lower_range}");
2384 };
2385 assert!(matches!(
2386 lower_range.at(*operand).kind(),
2387 ExpressionKind::RangeLiteral(..)
2388 ));
2389 assert_eq!(
2390 rule_expression(
2391 r#"spec test
2392rule out: lower 5...10"#,
2393 "out"
2394 )
2395 .to_string(),
2396 "lower 5...10"
2397 );
2398
2399 let lower_extended = prefix_expression(
2400 r#"spec test
2401data a: 1
2402data b: 2
2403rule out: lower a...b + 1"#,
2404 );
2405 let ExpressionKind::RangeBound(RangeBound::Lower, operand) = lower_extended.root_kind()
2406 else {
2407 panic!("expected lower of a range, got {lower_extended}");
2408 };
2409 let operand = lower_extended.at(*operand);
2410 let ExpressionKind::RangeLiteral(_, right) = operand.kind() else {
2411 panic!("+ must stay inside the range, got {:?}", operand.kind());
2412 };
2413 assert!(matches!(
2414 binary_arithmetic(operand.child(*right)),
2415 Some((_, ArithmeticComputation::Add, _))
2416 ));
2417
2418 let not_comparison = prefix_expression(
2419 r#"spec test
2420data x: 1
2421rule out: not (x > 1)"#,
2422 );
2423 let ExpressionKind::LogicalNegation(operand, _) = not_comparison.root_kind() else {
2424 panic!("not (x > 1) must negate the comparison, got {not_comparison}");
2425 };
2426 assert!(matches!(
2427 not_comparison.at(*operand).kind(),
2428 ExpressionKind::Comparison(..)
2429 ));
2430 }
2431
2432 #[test]
2433 fn format_prefix_operand_round_trips() {
2434 let source = r#"spec test
2435data x: 1.00
2436rule tight: floor(x) + 1
2437rule wide: floor (x + 1)
2438rule ends: lower 5...10
2439rule edge: (lower x)...(upper x)
2440"#;
2441 let formatted =
2442 crate::formatting::format_source(source, crate::parsing::source::SourceType::Volatile)
2443 .expect("format");
2444 let again = crate::formatting::format_source(
2445 &formatted,
2446 crate::parsing::source::SourceType::Volatile,
2447 )
2448 .expect("reformat");
2449 assert_eq!(formatted, again, "format must be stable");
2450
2451 for rule in ["tight", "wide", "ends", "edge"] {
2452 let original = rule_expression(source, rule);
2453 let reparsed = rule_expression(&formatted, rule);
2454 assert_eq!(
2455 original.to_string(),
2456 reparsed.to_string(),
2457 "{rule} changed meaning across format/parse"
2458 );
2459 }
2460
2461 let tight = rule_expression(&formatted, "tight");
2462 let Some((left, ArithmeticComputation::Add, _)) = binary_arithmetic(tight.root()) else {
2463 panic!("floor(x) + 1 must stay an addition, got {}", tight);
2464 };
2465 assert!(
2466 matches!(
2467 left.kind(),
2468 ExpressionKind::MathematicalComputation(MathematicalComputation::Floor, _)
2469 ),
2470 "floor(x) + 1 must stay floor-then-add, got {tight}"
2471 );
2472 }
2473}