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, Reference, RoundingMode,
30 TypeConstraintCommand, 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_colon_reference_payload() {
775 let spec = parse_single_spec(
776 r#"spec fx
777data rate: number
778data money: measure
779 -> unit gbp: rate
780"#,
781 );
782 let rows = measure_unit_constraints(&spec);
783 assert_eq!(rows.len(), 1);
784 let (name, unit_arg) = unit_name_and_arg(&rows[0]);
785 assert_eq!(name, "gbp");
786 assert_eq!(
787 unit_arg,
788 UnitArg::Reference(Reference::local("rate".to_string()))
789 );
790 assert!(!rows[0].deprecated_without_colon);
791 }
792
793 #[test]
794 fn parse_unit_colon_dotted_reference_payload() {
795 let specs = parse(
796 r#"spec rate_src
797data rate: number
798spec consumer
799uses rs: rate_src
800data money: measure
801 -> unit gbp: rs.rate
802"#,
803 crate::parsing::source::SourceType::Volatile,
804 &ResourceLimits::default(),
805 )
806 .expect("spec must parse")
807 .into_flattened_specs();
808 let consumer = specs
809 .iter()
810 .find(|s| s.name == "consumer")
811 .expect("consumer spec");
812 let rows = measure_unit_constraints(consumer);
813 assert_eq!(rows.len(), 1);
814 let (name, unit_arg) = unit_name_and_arg(&rows[0]);
815 assert_eq!(name, "gbp");
816 assert_eq!(
817 unit_arg,
818 UnitArg::Reference(Reference {
819 segments: vec!["rs".to_string()],
820 name: "rate".to_string(),
821 })
822 );
823 }
824
825 #[test]
826 fn parse_unit_colon_reference_display_roundtrips() {
827 let local = UnitArg::Reference(Reference::local("rate".to_string()));
828 assert_eq!(format!("{local}"), "rate");
829 let dotted = UnitArg::Reference(Reference {
830 segments: vec!["rs".to_string()],
831 name: "rate".to_string(),
832 });
833 assert_eq!(format!("{dotted}"), "rs.rate");
834 }
835
836 #[test]
837 fn parse_unit_colon_compound_still_parses_after_reference_support() {
838 let spec = parse_single_spec(
842 r#"spec rates
843uses lemma units
844
845data money: measure
846 -> unit eur_per_hour: eur/hour
847"#,
848 );
849 let rows = measure_unit_constraints(&spec);
850 assert_eq!(rows.len(), 1);
851 let (name, unit_arg) = unit_name_and_arg(&rows[0]);
852 assert_eq!(name, "eur_per_hour");
853 assert_eq!(
854 unit_arg,
855 UnitArg::Expr(
856 rust_decimal::Decimal::ONE,
857 vec![
858 UnitFactor {
859 measure_ref: "eur".to_string(),
860 exp: 1,
861 },
862 UnitFactor {
863 measure_ref: "hour".to_string(),
864 exp: -1,
865 }
866 ],
867 )
868 );
869 }
870
871 #[test]
874 fn parse_unit_deprecated_space_factor_payload() {
875 let spec = parse_single_spec(
876 r#"spec money_spec
877data money: measure
878 -> unit eur 1.00
879"#,
880 );
881 let rows = measure_unit_constraints(&spec);
882 assert_eq!(rows.len(), 1);
883 let (name, unit_arg) = unit_name_and_arg(&rows[0]);
884 assert_eq!(name, "eur");
885 assert_eq!(unit_arg, UnitArg::Factor(decimal("1.00")));
886 assert!(rows[0].deprecated_without_colon);
887 }
888
889 #[test]
890 fn parse_unit_deprecated_space_compound_only_payload() {
891 let spec = parse_single_spec(
892 r#"spec rates
893uses lemma units
894
895data money: measure
896 -> unit eur 1.00
897
898data rate: measure
899 -> unit eur_per_hour eur/hour
900"#,
901 );
902 let rows = measure_unit_constraints(&spec);
903 assert_eq!(rows.len(), 2);
904 let (name, unit_arg) = unit_name_and_arg(&rows[1]);
905 assert_eq!(name, "eur_per_hour");
906 assert_eq!(
907 unit_arg,
908 UnitArg::Expr(
909 rust_decimal::Decimal::ONE,
910 vec![
911 UnitFactor {
912 measure_ref: "eur".to_string(),
913 exp: 1,
914 },
915 UnitFactor {
916 measure_ref: "hour".to_string(),
917 exp: -1,
918 }
919 ],
920 )
921 );
922 assert!(rows[1].deprecated_without_colon);
923 }
924
925 #[test]
926 fn parse_unit_deprecated_space_numeric_prefix_and_compound_payload() {
927 let spec = parse_single_spec(
928 r#"spec speed
929uses lemma units
930
931data velocity: measure
932 -> unit kmh 3.6 meter/second
933"#,
934 );
935 let rows = measure_unit_constraints(&spec);
936 assert_eq!(rows.len(), 1);
937 let (name, unit_arg) = unit_name_and_arg(&rows[0]);
938 assert_eq!(name, "kmh");
939 assert_eq!(
940 unit_arg,
941 UnitArg::Expr(
942 decimal("3.6"),
943 vec![
944 UnitFactor {
945 measure_ref: "meter".to_string(),
946 exp: 1,
947 },
948 UnitFactor {
949 measure_ref: "second".to_string(),
950 exp: -1,
951 }
952 ],
953 )
954 );
955 assert!(rows[0].deprecated_without_colon);
956 }
957
958 #[test]
959 fn parse_unit_deprecated_space_matches_colon_payload() {
960 let canonical = parse_single_spec(
961 r#"spec rates
962uses lemma units
963
964data money: measure
965 -> unit eur: 1.00
966
967data rate: measure
968 -> unit eur_per_hour: eur/hour
969"#,
970 );
971 let deprecated = parse_single_spec(
972 r#"spec rates
973uses lemma units
974
975data money: measure
976 -> unit eur 1.00
977
978data rate: measure
979 -> unit eur_per_hour eur/hour
980"#,
981 );
982 let canonical_args: Vec<UnitArg> = measure_unit_constraints(&canonical)
983 .iter()
984 .map(|row| unit_name_and_arg(row).1)
985 .collect();
986 let deprecated_args: Vec<UnitArg> = measure_unit_constraints(&deprecated)
987 .iter()
988 .map(|row| unit_name_and_arg(row).1)
989 .collect();
990 assert_eq!(canonical_args, deprecated_args);
991 }
992
993 #[test]
994 fn parse_unit_colon_without_payload_errors() {
995 let result = parse(
996 r#"spec s
997data money: measure
998 -> unit eur:
999"#,
1000 crate::parsing::source::SourceType::Volatile,
1001 &ResourceLimits::default(),
1002 );
1003 assert!(
1004 result.is_err(),
1005 "empty unit payload after colon must not parse"
1006 );
1007 }
1008
1009 fn rule_expression(source: &str, rule_name: &str) -> Expression {
1010 let parsed = parse(
1011 source,
1012 crate::parsing::source::SourceType::Volatile,
1013 &ResourceLimits::default(),
1014 )
1015 .expect("expected parse");
1016 let spec = parsed
1017 .flatten_specs()
1018 .into_iter()
1019 .next()
1020 .expect("expected one spec");
1021 spec.rules
1022 .iter()
1023 .find(|rule| rule.name == rule_name)
1024 .unwrap_or_else(|| panic!("rule '{rule_name}' not found"))
1025 .expression
1026 .clone()
1027 }
1028
1029 fn assert_multiply_range_side(
1030 root: ExprRef<'_, ExpressionKind>,
1031 range_on_left: bool,
1032 label: &str,
1033 ) {
1034 let Some((left, ArithmeticComputation::Multiply, right)) = binary_arithmetic(root) else {
1035 panic!("{label}: expected Multiply, got {:?}", root.kind());
1036 };
1037 let (range, other) = if range_on_left {
1038 (left, right)
1039 } else {
1040 (right, left)
1041 };
1042 assert!(
1043 matches!(range.kind(), ExpressionKind::RangeLiteral(..)),
1044 "{label}: expected RangeLiteral on {} of *, got {:?}",
1045 if range_on_left { "left" } else { "right" },
1046 range.kind()
1047 );
1048 assert!(
1049 !matches!(other.kind(), ExpressionKind::RangeLiteral(..)),
1050 "{label}: expected non-range on other side of *"
1051 );
1052 }
1053
1054 #[test]
1055 fn parse_range_binds_tighter_than_multiply() {
1056 let base = r#"spec test
1057uses lemma units
1058data rate: measure -> unit eur: 1
1059data period_start: 2026-01-01
1060data period_end: 2026-01-02
1061"#;
1062 let rhs = rule_expression(
1063 &format!("{base}rule rhs: rate * period_start...period_end"),
1064 "rhs",
1065 );
1066 assert_multiply_range_side(rhs.root(), false, "rate * period_start...period_end");
1067 let lhs = rule_expression(
1068 &format!("{base}rule lhs: period_start...period_end * rate"),
1069 "lhs",
1070 );
1071 assert_multiply_range_side(lhs.root(), true, "period_start...period_end * rate");
1072 }
1073
1074 #[test]
1075 fn parse_range_additive_right_endpoint_binds_inside_literal() {
1076 let expression = rule_expression(
1077 r#"spec test
1078uses lemma units
1079data start: date
1080data length: units.duration
1081rule valid: now in start...start + length"#,
1082 "valid",
1083 );
1084 let root = expression.root();
1085 let ExpressionKind::RangeContainment(value, range) = root.kind() else {
1086 panic!("expected RangeContainment, got {:?}", root.kind());
1087 };
1088 let value = root.child(*value);
1089 assert!(
1090 matches!(value.kind(), ExpressionKind::Now),
1091 "expected now as containment value, got {:?}",
1092 value.kind()
1093 );
1094 let range = root.child(*range);
1095 let ExpressionKind::RangeLiteral(left, right) = range.kind() else {
1096 panic!(
1097 "expected RangeLiteral as containment range, got {:?}",
1098 range.kind()
1099 );
1100 };
1101 let left = range.child(*left);
1102 assert!(
1103 matches!(left.kind(), ExpressionKind::Reference(..)),
1104 "expected start reference as left endpoint, got {:?}",
1105 left.kind()
1106 );
1107 let right = range.child(*right);
1108 let Some((add_left, ArithmeticComputation::Add, add_right)) = binary_arithmetic(right)
1109 else {
1110 panic!(
1111 "expected start + length as right endpoint, got {:?}",
1112 right.kind()
1113 );
1114 };
1115 assert!(
1116 matches!(add_left.kind(), ExpressionKind::Reference(..)),
1117 "expected start reference in right endpoint add, got {:?}",
1118 add_left.kind()
1119 );
1120 assert!(
1121 matches!(add_right.kind(), ExpressionKind::Reference(..)),
1122 "expected length reference in right endpoint add, got {:?}",
1123 add_right.kind()
1124 );
1125 }
1126
1127 #[test]
1128 fn parse_range_multiply_with_conversion_without_inner_parens() {
1129 let expression = rule_expression(
1130 r#"spec test
1131uses lemma units
1132data money: measure -> unit eur: 1
1133data rate: measure -> unit eur_per_hour: eur/hour
1134data hourly_rate: 50 eur_per_hour
1135data period_start: 2026-01-01
1136data period_end: 2026-01-02
1137rule pay: (hourly_rate * period_start...period_end) as eur"#,
1138 "pay",
1139 );
1140 let ExpressionKind::UnitConversion(inner, _) = expression.root_kind() else {
1141 panic!("expected UnitConversion, got {:?}", expression.root_kind());
1142 };
1143 assert_multiply_range_side(expression.at(*inner), false, "pay");
1144 }
1145
1146 #[test]
1147 fn parse_error_includes_attribute_and_parse_error_spec_name() {
1148 let result = parse(
1149 r#"
1150spec test
1151data name: "Unclosed string
1152data age: 25
1153"#,
1154 crate::parsing::source::SourceType::Volatile,
1155 &ResourceLimits::default(),
1156 );
1157
1158 match result {
1159 Err(Error::Parsing(details)) => {
1160 let src = details
1161 .source
1162 .as_ref()
1163 .expect("BUG: parsing errors always have source");
1164 assert_eq!(
1165 src.source_type,
1166 crate::parsing::source::SourceType::Volatile
1167 );
1168 }
1169 Err(e) => panic!("Expected Parse error, got: {e:?}"),
1170 Ok(_) => panic!("Expected parse error for unclosed string"),
1171 }
1172 }
1173
1174 #[test]
1175 fn parse_single_spec_file() {
1176 let input = r#"spec somespec
1177data name: "Alice""#;
1178 let parsed = parse(
1179 input,
1180 crate::parsing::source::SourceType::Volatile,
1181 &ResourceLimits::default(),
1182 )
1183 .unwrap();
1184 let specs = parsed.flatten_specs();
1185 assert_eq!(specs.len(), 1);
1186 assert_eq!(specs[0].name, "somespec");
1187 }
1188
1189 #[test]
1190 fn parse_uses_registry_spec_explicit_alias() {
1191 let input = r#"spec example
1192uses external: @user/workspace somespec"#;
1193 let specs = parse(
1194 input,
1195 crate::parsing::source::SourceType::Volatile,
1196 &ResourceLimits::default(),
1197 )
1198 .unwrap()
1199 .into_flattened_specs();
1200 assert_eq!(specs.len(), 1);
1201 assert_eq!(specs[0].data.len(), 1);
1202 match &specs[0].data[0].value {
1203 crate::parsing::ast::DataValue::Import { spec_ref, .. } => {
1204 assert_eq!(spec_ref.name, "somespec");
1205 let repository_hdr = spec_ref
1206 .repository
1207 .as_ref()
1208 .expect("expected repository qualifier");
1209 assert_eq!(repository_hdr.name, "@user/workspace");
1210 }
1211 other => panic!("Expected Import, got: {:?}", other),
1212 }
1213 }
1214
1215 #[test]
1216 fn parse_multiple_specs_cross_reference_in_file() {
1217 let input = r#"spec spec_a
1218data x: 10
1219
1220spec spec_b
1221data y: 20
1222uses a: spec_a"#;
1223 let parsed = parse(
1224 input,
1225 crate::parsing::source::SourceType::Volatile,
1226 &ResourceLimits::default(),
1227 )
1228 .unwrap();
1229 let specs = parsed.flatten_specs();
1230 assert_eq!(specs.len(), 2);
1231 assert_eq!(specs[0].name, "spec_a");
1232 assert_eq!(specs[1].name, "spec_b");
1233 }
1234
1235 #[test]
1236 fn parse_uses_registry_spec_default_alias() {
1237 let input = "spec example\nuses @owner/repo somespec";
1238 let specs = parse(
1239 input,
1240 crate::parsing::source::SourceType::Volatile,
1241 &ResourceLimits::default(),
1242 )
1243 .unwrap()
1244 .into_flattened_specs();
1245 match &specs[0].data[0].value {
1246 crate::parsing::ast::DataValue::Import { spec_ref, .. } => {
1247 assert_eq!(spec_ref.name, "somespec");
1248 let repository_hdr = spec_ref
1249 .repository
1250 .as_ref()
1251 .expect("expected repository qualifier");
1252 assert_eq!(repository_hdr.name, "@owner/repo");
1253 }
1254 other => panic!("Expected Import, got: {:?}", other),
1255 }
1256 }
1257
1258 #[test]
1259 fn parse_uses_local_spec_default_alias() {
1260 let input = "spec example\nuses myspec";
1261 let specs = parse(
1262 input,
1263 crate::parsing::source::SourceType::Volatile,
1264 &ResourceLimits::default(),
1265 )
1266 .unwrap()
1267 .into_flattened_specs();
1268 match &specs[0].data[0].value {
1269 crate::parsing::ast::DataValue::Import { spec_ref, .. } => {
1270 assert_eq!(spec_ref.name, "myspec");
1271 assert!(
1272 spec_ref.repository.is_none(),
1273 "same-repository reference must omit repository qualifier"
1274 );
1275 }
1276 other => panic!("Expected Import, got: {:?}", other),
1277 }
1278 }
1279
1280 #[test]
1281 fn parse_spec_name_with_trailing_dot_is_opaque_name() {
1282 let input = "spec myspec.\ndata x: 1";
1283 let result = parse(
1284 input,
1285 crate::parsing::source::SourceType::Volatile,
1286 &ResourceLimits::default(),
1287 )
1288 .unwrap()
1289 .into_flattened_specs();
1290 assert_eq!(result[0].name, "myspec.");
1291 }
1292
1293 #[test]
1294 fn parse_multiple_specs_in_same_file() {
1295 let input = "spec myspec_a\nrule x: 1\n\nspec myspec_b\nrule x: 2";
1296 let result = parse(
1297 input,
1298 crate::parsing::source::SourceType::Volatile,
1299 &ResourceLimits::default(),
1300 )
1301 .unwrap()
1302 .into_flattened_specs();
1303 assert_eq!(result.len(), 2);
1304 assert_eq!(result[0].name, "myspec_a");
1305 assert_eq!(result[1].name, "myspec_b");
1306 }
1307
1308 #[test]
1309 fn parse_uses_accepts_name_only() {
1310 let input = "spec consumer\nuses other";
1311 let result = parse(
1312 input,
1313 crate::parsing::source::SourceType::Volatile,
1314 &ResourceLimits::default(),
1315 );
1316 assert!(result.is_ok(), "uses name should parse");
1317 let specs = result.unwrap().into_flattened_specs();
1318 let spec_ref = match &specs[0].data[0].value {
1319 crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1320 _ => panic!("expected Import"),
1321 };
1322 assert_eq!(spec_ref.name, "other");
1323 }
1324
1325 #[test]
1326 fn parse_uses_bare_year_effective() {
1327 let input = "spec consumer\nuses other 2026";
1328 let result = parse(
1329 input,
1330 crate::parsing::source::SourceType::Volatile,
1331 &ResourceLimits::default(),
1332 )
1333 .unwrap();
1334 let specs = result.into_flattened_specs();
1335 let spec_ref = match &specs[0].data[0].value {
1336 crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1337 _ => panic!("expected Import"),
1338 };
1339 assert_eq!(spec_ref.name, "other");
1340 let eff = spec_ref.effective.as_ref().expect("effective");
1341 assert_eq!(eff.year, 2026);
1342 assert_eq!(eff.month, 1);
1343 assert_eq!(eff.day, 1);
1344 }
1345
1346 #[test]
1347 fn parse_uses_registry_spec_ref_records_repository_and_target_spans() {
1348 let input = "spec consumer\nuses @iso/countries alpha2 2026";
1349 let result = parse(
1350 input,
1351 crate::parsing::source::SourceType::Volatile,
1352 &ResourceLimits::default(),
1353 )
1354 .unwrap();
1355 let spec = &result.flatten_specs()[0];
1356 let sr = match &spec.data[0].value {
1357 crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1358 _ => panic!("expected Import"),
1359 };
1360 let rs = sr
1361 .repository_span
1362 .as_ref()
1363 .expect("repository_span should be set for @-qualified uses");
1364 let ts = sr
1365 .target_span
1366 .as_ref()
1367 .expect("target_span should cover spec name and effective");
1368 assert_eq!(&input[rs.start..rs.end], "@iso/countries");
1369 assert_eq!(&input[ts.start..ts.end], "alpha2 2026");
1370 }
1371
1372 #[test]
1373 fn parse_uses_alias_no_comma_continuation() {
1374 let input = "spec consumer\nuses alias: pricing retail\ndata x: 1";
1375 let result = parse(
1376 input,
1377 crate::parsing::source::SourceType::Volatile,
1378 &ResourceLimits::default(),
1379 )
1380 .unwrap();
1381 let data = &result.flatten_specs()[0].data;
1382 assert_eq!(data.len(), 2);
1383 assert_eq!(data[0].reference.name, "alias");
1384 let sr = match &data[0].value {
1385 crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1386 _ => panic!("expected Import"),
1387 };
1388 assert_eq!(sr.name, "retail");
1389 let repository_hdr = sr
1390 .repository
1391 .as_ref()
1392 .expect("expected repository qualifier");
1393 assert_eq!(repository_hdr.name, "pricing");
1394 }
1395
1396 #[test]
1397 fn parse_data_qualified_type_with_effective_and_repository_on_uses() {
1398 let input = "spec consumer\nuses @iso/countries alpha2 2026-06-01\ndata country: alpha2.code -> option \"NL\"";
1399 let result = parse(
1400 input,
1401 crate::parsing::source::SourceType::Volatile,
1402 &ResourceLimits::default(),
1403 )
1404 .unwrap()
1405 .into_flattened_specs();
1406 let spec_ref = match &result[0].data[0].value {
1407 crate::parsing::ast::DataValue::Import { spec_ref: sr, .. } => sr,
1408 other => panic!("expected Import on uses row, got: {:?}", other),
1409 };
1410 assert_eq!(spec_ref.name, "alpha2");
1411
1412 let eff = spec_ref
1413 .effective
1414 .as_ref()
1415 .expect("expected effective datetime");
1416 assert_eq!(eff.year, 2026);
1417 assert_eq!(eff.month, 6);
1418
1419 let qualifier = spec_ref
1420 .repository
1421 .as_ref()
1422 .expect("expected repository qualifier");
1423 assert_eq!(qualifier.name, "@iso/countries");
1424
1425 match &result[0].data[1].value {
1426 crate::parsing::ast::DataValue::Definition {
1427 base,
1428 constraints,
1429 value,
1430 list: _,
1431 } => {
1432 assert!(value.is_none());
1433 assert_eq!(
1434 base.as_ref().expect("expected base"),
1435 &crate::parsing::ast::ParentType::Qualified {
1436 spec_alias: "alpha2".into(),
1437 inner: Box::new(crate::parsing::ast::ParentType::custom("code")),
1438 }
1439 );
1440
1441 let cs = constraints
1442 .as_ref()
1443 .expect("expected trailing constraint chain");
1444 assert_eq!(cs.len(), 1);
1445 }
1446 other => panic!("expected Definition, got: {:?}", other),
1447 }
1448 }
1449
1450 #[test]
1451 fn parse_qualified_type_with_slashed_opaque_spec_name() {
1452 let input = r#"
1453spec logistics/terms
1454data incoterm_type: text
1455
1456spec logistics/shipping
1457uses logistics/terms
1458data incoterm: logistics/terms.incoterm_type
1459"#;
1460 let result = parse(
1461 input,
1462 crate::parsing::source::SourceType::Volatile,
1463 &ResourceLimits::default(),
1464 )
1465 .unwrap()
1466 .into_flattened_specs();
1467 assert_eq!(result.len(), 2);
1468 assert_eq!(result[0].name, "logistics/terms");
1469 assert_eq!(result[1].name, "logistics/shipping");
1470 assert_eq!(result[1].data[0].reference.name, "logistics/terms");
1471 let import = match &result[1].data[0].value {
1472 crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1473 other => panic!("expected Import, got: {other:?}"),
1474 };
1475 assert_eq!(import.name, "logistics/terms");
1476 assert!(import.repository.is_none());
1477 match &result[1].data[1].value {
1478 crate::parsing::ast::DataValue::Definition {
1479 base: Some(base), ..
1480 } => {
1481 assert_eq!(
1482 base,
1483 &crate::parsing::ast::ParentType::Qualified {
1484 spec_alias: "logistics/terms".into(),
1485 inner: Box::new(crate::parsing::ast::ParentType::custom("incoterm_type",)),
1486 }
1487 );
1488 }
1489 other => panic!("expected Definition with qualified base, got: {other:?}"),
1490 }
1491 }
1492
1493 #[test]
1494 fn parse_uses_slashed_name_alias_is_full_opaque_name() {
1495 let input = "spec consumer\nuses finance/units\ndata x: 1";
1496 let result = parse(
1497 input,
1498 crate::parsing::source::SourceType::Volatile,
1499 &ResourceLimits::default(),
1500 )
1501 .unwrap()
1502 .into_flattened_specs();
1503 assert_eq!(result[0].data[0].reference.name, "finance/units");
1504 let sr = match &result[0].data[0].value {
1505 crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1506 other => panic!("expected Import, got: {other:?}"),
1507 };
1508 assert_eq!(sr.name, "finance/units");
1509 assert!(sr.repository.is_none());
1510 }
1511
1512 #[test]
1513 fn parse_uses_terms_is_spec_named_terms_not_sibling_of_logistics() {
1514 let input = "spec logistics/shipping\nuses terms\ndata x: 1";
1515 let result = parse(
1516 input,
1517 crate::parsing::source::SourceType::Volatile,
1518 &ResourceLimits::default(),
1519 )
1520 .unwrap()
1521 .into_flattened_specs();
1522 assert_eq!(result[0].data[0].reference.name, "terms");
1523 let sr = match &result[0].data[0].value {
1524 crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1525 other => panic!("expected Import, got: {other:?}"),
1526 };
1527 assert_eq!(sr.name, "terms");
1528 assert!(sr.repository.is_none());
1529 }
1530
1531 #[test]
1532 fn parse_error_is_returned_for_garbage_input() {
1533 let result = parse(
1534 r#"
1535spec test
1536this is not valid lemma syntax @#$%
1537"#,
1538 crate::parsing::source::SourceType::Volatile,
1539 &ResourceLimits::default(),
1540 );
1541
1542 assert!(result.is_err(), "Should fail on malformed input");
1543 match result {
1544 Err(Error::Parsing { .. }) => {
1545 }
1547 Err(e) => panic!("Expected Parse error, got: {e:?}"),
1548 Ok(_) => panic!("Expected parse error"),
1549 }
1550 }
1551
1552 #[test]
1555 fn parse_local_with_literal_rejected() {
1556 let err = parse(
1557 r#"spec s
1558with x: 42"#,
1559 crate::parsing::source::SourceType::Volatile,
1560 &ResourceLimits::default(),
1561 )
1562 .unwrap_err();
1563 let msg = err.to_string();
1564 assert!(
1565 msg.contains("Standalone") || msg.contains("uses"),
1566 "expected standalone with rejection, got: {msg}"
1567 );
1568 }
1569
1570 #[test]
1571 fn parse_local_with_import_reference_rejected() {
1572 let err = parse(
1573 r#"spec s
1574uses i: inner
1575with copy: i.v"#,
1576 crate::parsing::source::SourceType::Volatile,
1577 &ResourceLimits::default(),
1578 )
1579 .unwrap_err();
1580 let msg = err.to_string();
1581 assert!(
1582 msg.contains("Standalone") || msg.contains("uses"),
1583 "expected standalone with rejection, got: {msg}"
1584 );
1585 }
1586
1587 #[test]
1588 fn parse_local_with_dotted_rhs_rejected() {
1589 let err = parse(
1590 r#"spec s
1591with x: a.something"#,
1592 crate::parsing::source::SourceType::Volatile,
1593 &ResourceLimits::default(),
1594 )
1595 .unwrap_err();
1596 let msg = err.to_string();
1597 assert!(
1598 msg.contains("Standalone") || msg.contains("uses"),
1599 "expected standalone with rejection, got: {msg}"
1600 );
1601 }
1602
1603 #[test]
1604 fn parse_local_with_multi_segment_rhs_rejected() {
1605 let err = parse(
1606 r#"spec s
1607with x: alpha.beta.gamma.delta"#,
1608 crate::parsing::source::SourceType::Volatile,
1609 &ResourceLimits::default(),
1610 )
1611 .unwrap_err();
1612 let msg = err.to_string();
1613 assert!(
1614 msg.contains("Standalone") || msg.contains("uses"),
1615 "expected standalone with rejection, got: {msg}"
1616 );
1617 }
1618
1619 #[test]
1622 fn parse_local_non_dotted_rhs_stays_definition_with_custom_base() {
1623 let input = r#"spec s
1624data x: myothertype"#;
1625 let result = parse(
1626 input,
1627 crate::parsing::source::SourceType::Volatile,
1628 &ResourceLimits::default(),
1629 )
1630 .unwrap()
1631 .into_flattened_specs();
1632 let value = &result[0].data[0].value;
1633 assert!(
1634 matches!(
1635 value,
1636 crate::parsing::ast::DataValue::Definition {
1637 base: Some(crate::parsing::ast::ParentType::Custom { .. }),
1638 ..
1639 }
1640 ),
1641 "non-dotted local RHS must stay Definition with custom base, got: {:?}",
1642 value
1643 );
1644 }
1645
1646 #[test]
1648 fn parse_uses_binding_non_dotted_rhs_is_reference() {
1649 let input = r#"spec s
1650uses child: other
1651 -> with slot: somename"#;
1652 let result = parse(
1653 input,
1654 crate::parsing::source::SourceType::Volatile,
1655 &ResourceLimits::default(),
1656 )
1657 .unwrap()
1658 .into_flattened_specs();
1659 let bindings = match &result[0].data[0].value {
1660 crate::parsing::ast::DataValue::Import { bindings, .. } => bindings,
1661 other => panic!("expected Import, got: {:?}", other),
1662 };
1663 assert_eq!(bindings.len(), 1);
1664 assert!(
1665 matches!(
1666 &bindings[0].rhs,
1667 crate::parsing::ast::UsesBindingRhs::References { targets }
1668 if targets.len() == 1
1669 ),
1670 "non-dotted RHS must yield Reference; got: {:?}",
1671 bindings[0].rhs
1672 );
1673 }
1674
1675 #[test]
1676 fn parse_uses_binding_keeps_ordered_aliases_in_one_binding() {
1677 let input = r#"spec s
1678uses tree: node
1679 -> with children: left, right"#;
1680 let result = parse(
1681 input,
1682 crate::parsing::source::SourceType::Volatile,
1683 &ResourceLimits::default(),
1684 )
1685 .unwrap()
1686 .into_flattened_specs();
1687 let bindings = match &result[0].data[0].value {
1688 crate::parsing::ast::DataValue::Import { bindings, .. } => bindings,
1689 other => panic!("expected Import, got: {other:?}"),
1690 };
1691 assert_eq!(bindings.len(), 1);
1692 assert_eq!(bindings[0].path.name, "children");
1693 let crate::parsing::ast::UsesBindingRhs::References { targets } = &bindings[0].rhs else {
1694 panic!("expected ordered references, got: {:?}", bindings[0].rhs);
1695 };
1696 assert_eq!(
1697 targets.iter().map(ToString::to_string).collect::<Vec<_>>(),
1698 ["left", "right"]
1699 );
1700 }
1701
1702 #[test]
1703 fn parse_uses_alias_sequence_can_precede_another_field_pair() {
1704 let input = r#"spec s
1705uses tree: node
1706 -> with children: left, right, weight: 2"#;
1707 let result = parse(
1708 input,
1709 crate::parsing::source::SourceType::Volatile,
1710 &ResourceLimits::default(),
1711 )
1712 .unwrap()
1713 .into_flattened_specs();
1714 let bindings = match &result[0].data[0].value {
1715 crate::parsing::ast::DataValue::Import { bindings, .. } => bindings,
1716 other => panic!("expected Import, got: {other:?}"),
1717 };
1718 assert_eq!(bindings.len(), 2);
1719 assert_eq!(bindings[0].path.name, "children");
1720 assert_eq!(bindings[1].path.name, "weight");
1721 }
1722
1723 #[test]
1724 fn parse_uses_alias_sequence_rejects_literal_member() {
1725 let err = parse(
1726 "spec s\nuses tree: node\n -> with children: left, 2",
1727 crate::parsing::source::SourceType::Volatile,
1728 &ResourceLimits::default(),
1729 )
1730 .expect_err("alias sequence containing a literal must fail");
1731 assert!(
1732 err.to_string().contains("cannot contain literals"),
1733 "unexpected error: {err}"
1734 );
1735 }
1736
1737 #[test]
1739 fn parse_data_colon_spec_rhs_is_rejected() {
1740 let result = parse(
1741 r#"
1742spec s
1743data x: spec other
1744"#,
1745 crate::parsing::source::SourceType::Volatile,
1746 &ResourceLimits::default(),
1747 );
1748 match result {
1749 Ok(_) => panic!("`data x: spec other` must fail to parse"),
1750 Err(err) => {
1751 let msg = err.to_string();
1752 assert!(
1753 msg.contains("uses") && msg.contains("spec"),
1754 "error must direct to `uses` for spec import, got: {msg}"
1755 );
1756 }
1757 }
1758 }
1759
1760 #[test]
1762 fn parse_uses_binding_with_dotted_rhs_preserves_both_sides() {
1763 let input = r#"spec s
1764uses outer: other
1765 -> with inner: target.field"#;
1766 let result = parse(
1767 input,
1768 crate::parsing::source::SourceType::Volatile,
1769 &ResourceLimits::default(),
1770 )
1771 .unwrap()
1772 .into_flattened_specs();
1773 let bindings = match &result[0].data[0].value {
1774 crate::parsing::ast::DataValue::Import { bindings, .. } => bindings,
1775 other => panic!("expected Import, got: {:?}", other),
1776 };
1777 assert_eq!(bindings.len(), 1);
1778 assert_eq!(bindings[0].path.segments, vec![] as Vec<String>);
1779 assert_eq!(bindings[0].path.name, "inner");
1780 match &bindings[0].rhs {
1781 crate::parsing::ast::UsesBindingRhs::References { targets } => {
1782 let [target] = targets.as_slice() else {
1783 panic!("expected one reference, got: {targets:?}");
1784 };
1785 assert_eq!(target.segments, vec!["target"]);
1786 assert_eq!(target.name, "field");
1787 }
1788 other => panic!("expected Reference rhs, got: {:?}", other),
1789 }
1790 }
1791
1792 #[test]
1793 fn parse_deprecated_standalone_with_is_error() {
1794 let input = r#"spec inner
1795data x: number
1796
1797spec outer
1798uses i: inner
1799with i.x: 42
1800rule r: i.x"#;
1801 let result = parse(
1802 input,
1803 crate::parsing::source::SourceType::Volatile,
1804 &ResourceLimits::default(),
1805 );
1806 match result {
1807 Ok(_) => panic!("standalone with after uses must not parse"),
1808 Err(err) => {
1809 let msg = err.to_string();
1810 assert!(
1811 msg.contains("with"),
1812 "error should mention with; got: {msg}"
1813 );
1814 }
1815 }
1816 }
1817
1818 #[test]
1819 fn parse_deprecated_standalone_with_before_uses_is_error() {
1820 let input = r#"spec inner
1821data slot: number
1822
1823spec outer
1824with i.slot: 99
1825uses i: inner
1826rule r: i.slot"#;
1827 let result = parse(
1828 input,
1829 crate::parsing::source::SourceType::Volatile,
1830 &ResourceLimits::default(),
1831 );
1832 match result {
1833 Ok(_) => panic!("standalone with before uses must not parse"),
1834 Err(err) => {
1835 let msg = err.to_string();
1836 assert!(
1837 msg.contains("with"),
1838 "error should mention with; got: {msg}"
1839 );
1840 }
1841 }
1842 }
1843
1844 #[test]
1845 fn parse_standalone_with_without_matching_uses_errors() {
1846 let result = parse(
1847 r#"spec s
1848with outer.inner: 1"#,
1849 crate::parsing::source::SourceType::Volatile,
1850 &ResourceLimits::default(),
1851 );
1852 match result {
1853 Ok(_) => panic!("standalone with without uses must not parse"),
1854 Err(err) => {
1855 let msg = err.to_string();
1856 assert!(
1857 msg.contains("with"),
1858 "error should mention with; got: {msg}"
1859 );
1860 }
1861 }
1862 }
1863
1864 #[test]
1865 fn parse_data_on_binding_path_is_rejected_with_with_hint() {
1866 let result = parse(
1867 r#"spec s
1868data outer.inner: 1"#,
1869 crate::parsing::source::SourceType::Volatile,
1870 &ResourceLimits::default(),
1871 );
1872 match result {
1873 Ok(_) => panic!("data with binding path must not parse"),
1874 Err(err) => {
1875 let msg = err.to_string();
1876 assert!(
1877 msg.contains("with"),
1878 "error should steer authors toward with; got: {msg}"
1879 );
1880 }
1881 }
1882 }
1883
1884 #[test]
1885 fn parse_bare_file_yields_single_anonymous_repository_group() {
1886 let input = "spec a\ndata x: 1\nspec b\ndata y: 2";
1887 let parsed = parse(
1888 input,
1889 crate::parsing::source::SourceType::Volatile,
1890 &ResourceLimits::default(),
1891 )
1892 .unwrap();
1893 assert_eq!(parsed.repositories.len(), 1);
1894 let (repo, specs) = parsed.repositories.iter().next().unwrap();
1895 assert!(repo.name.is_none());
1896 assert_eq!(specs.len(), 2);
1897 assert_eq!(specs[0].name, "a");
1898 assert_eq!(specs[1].name, "b");
1899 }
1900
1901 #[test]
1902 fn parse_repo_sections_preserve_order_and_names() {
1903 let input = r#"repo r1
1904
1905spec a
1906data x: 1
1907
1908repo r2
1909
1910spec b
1911data y: 2"#;
1912 let parsed = parse(
1913 input,
1914 crate::parsing::source::SourceType::Volatile,
1915 &ResourceLimits::default(),
1916 )
1917 .unwrap();
1918 assert_eq!(parsed.repositories.len(), 2);
1919 let keys: Vec<_> = parsed.repositories.keys().collect();
1920 assert_eq!(keys[0].name.as_deref(), Some("r1"));
1921 assert_eq!(keys[1].name.as_deref(), Some("r2"));
1922 }
1923
1924 #[test]
1925 fn parse_duplicate_repo_name_merges_spec_lists() {
1926 let input = r#"repo dup
1927
1928spec a
1929data x: 1
1930
1931repo dup
1932
1933spec b
1934data y: 2"#;
1935 let parsed = parse(
1936 input,
1937 crate::parsing::source::SourceType::Volatile,
1938 &ResourceLimits::default(),
1939 )
1940 .unwrap();
1941 assert_eq!(parsed.repositories.len(), 1);
1942 assert_eq!(parsed.flatten_specs().len(), 2);
1943 }
1944
1945 #[test]
1946 fn parse_repo_with_no_specs_then_eof_yields_empty_spec_vec_for_that_repo() {
1947 let input = "repo empty";
1948 let parsed = parse(
1949 input,
1950 crate::parsing::source::SourceType::Volatile,
1951 &ResourceLimits::default(),
1952 )
1953 .unwrap();
1954 assert_eq!(parsed.repositories.len(), 1);
1955 let (_, specs) = parsed.repositories.iter().next().unwrap();
1956 assert_eq!(specs.len(), 0);
1957 }
1958
1959 #[test]
1960 fn parse_repo_followed_by_repo_without_specs_first_repo_empty_second_has_spec() {
1961 let input = "repo a\n\nrepo b\n\nspec s\ndata x: 1";
1962 let parsed = parse(
1963 input,
1964 crate::parsing::source::SourceType::Volatile,
1965 &ResourceLimits::default(),
1966 )
1967 .unwrap();
1968 assert_eq!(parsed.repositories.len(), 2);
1969 let names: Vec<_> = parsed
1970 .repositories
1971 .keys()
1972 .map(|r| r.name.as_deref())
1973 .collect();
1974 assert_eq!(names, vec![Some("a"), Some("b")]);
1975 assert!(parsed.repositories.values().next().unwrap().is_empty());
1976 assert_eq!(parsed.repositories.values().nth(1).unwrap().len(), 1);
1977 }
1978
1979 #[test]
1980 fn parse_spec_named_repo_keyword_should_be_rejected() {
1981 assert!(
1982 parse(
1983 "spec repo\ndata x: 1",
1984 crate::parsing::source::SourceType::Volatile,
1985 &ResourceLimits::default(),
1986 )
1987 .is_err(),
1988 "spec must not be allowed to use reserved keyword `repo` as its name"
1989 );
1990 }
1991
1992 #[test]
1993 fn parse_repo_declaration_cannot_use_spec_keyword_as_repository_name() {
1994 assert!(
1995 parse(
1996 "repo spec\n\nspec z\ndata q: 1\nrule r: q",
1997 crate::parsing::source::SourceType::Volatile,
1998 &ResourceLimits::default(),
1999 )
2000 .is_err(),
2001 "repository name cannot be the token `spec`"
2002 );
2003 }
2004
2005 #[test]
2006 fn parse_repo_declaration_cannot_use_data_keyword_as_repository_name() {
2007 assert!(
2008 parse(
2009 "repo data\n\nspec z\ndata q: 1\nrule r: q",
2010 crate::parsing::source::SourceType::Volatile,
2011 &ResourceLimits::default(),
2012 )
2013 .is_err(),
2014 "repository name cannot be the token `data`"
2015 );
2016 }
2017
2018 #[test]
2019 fn parse_repo_declaration_cannot_use_rule_keyword_as_repository_name() {
2020 assert!(
2021 parse(
2022 "repo rule\n\nspec z\ndata q: 1\nrule r: q",
2023 crate::parsing::source::SourceType::Volatile,
2024 &ResourceLimits::default(),
2025 )
2026 .is_err(),
2027 "repository name cannot be the token `rule`"
2028 );
2029 }
2030
2031 #[test]
2032 fn parse_data_named_repo_keyword_is_rejected() {
2033 let err = parse(
2034 "spec s\ndata repo: 1",
2035 crate::parsing::source::SourceType::Volatile,
2036 &ResourceLimits::default(),
2037 )
2038 .unwrap_err();
2039 assert!(
2040 err.to_string().contains("repo"),
2041 "data named repo should not parse: {}",
2042 err
2043 );
2044 }
2045
2046 #[test]
2047 fn parse_rule_named_repo_keyword_is_rejected() {
2048 let err = parse(
2049 "spec s\ndata x: 1\nrule repo: x",
2050 crate::parsing::source::SourceType::Volatile,
2051 &ResourceLimits::default(),
2052 )
2053 .unwrap_err();
2054 let msg = err.to_string();
2055 assert!(
2056 msg.contains("repo") || msg.contains("reserved"),
2057 "rule named repo should not parse: {msg}"
2058 );
2059 }
2060
2061 #[test]
2062 fn parse_repo_declaration_accepts_non_keyword_repository_identifier() {
2063 let parsed = parse(
2064 "repo warehouse\n\nspec z\ndata q: 1\nrule r: q",
2065 crate::parsing::source::SourceType::Volatile,
2066 &ResourceLimits::default(),
2067 )
2068 .unwrap();
2069 assert_eq!(parsed.repositories.len(), 1);
2070 assert_eq!(
2071 parsed.repositories.keys().next().unwrap().name.as_deref(),
2072 Some("warehouse")
2073 );
2074 }
2075
2076 #[test]
2077 fn parse_repo_name_case_insensitive_same_repository_merged() {
2078 let input = "repo Foo\n\nspec a\ndata x: 1\n\nrepo foo\n\nspec b\ndata y: 2";
2079 let parsed = parse(
2080 input,
2081 crate::parsing::source::SourceType::Volatile,
2082 &ResourceLimits::default(),
2083 )
2084 .unwrap();
2085 assert_eq!(
2086 parsed.repositories.len(),
2087 1,
2088 "Foo and foo are the same repository after canonicalization"
2089 );
2090 let specs: Vec<_> = parsed.repositories.values().next().unwrap().clone();
2091 assert_eq!(specs.len(), 2);
2092 assert_eq!(specs[0].name, "a");
2093 assert_eq!(specs[1].name, "b");
2094 }
2095
2096 #[test]
2097 fn parse_repo_empty_name_errors() {
2098 let err = parse(
2099 "repo \nspec a\ndata x: 1",
2100 crate::parsing::source::SourceType::Volatile,
2101 &ResourceLimits::default(),
2102 )
2103 .unwrap_err();
2104 assert!(
2105 !err.to_string().is_empty(),
2106 "empty repo name should not parse quietly: {err}"
2107 );
2108 }
2109
2110 #[test]
2111 fn parse_repo_numeric_name_behavior() {
2112 let input = "repo 123\n\nspec a\ndata x: 1";
2113 let result = parse(
2114 input,
2115 crate::parsing::source::SourceType::Volatile,
2116 &ResourceLimits::default(),
2117 );
2118 match result {
2119 Ok(parsed) => {
2120 assert_eq!(
2121 parsed.repositories.keys().next().unwrap().name.as_deref(),
2122 Some("123"),
2123 "if numeric repo names parse, identity must be stable"
2124 );
2125 }
2126 Err(e) => {
2127 assert!(
2128 !e.to_string().is_empty(),
2129 "rejecting numeric repo name is ok if explicit: {e}"
2130 );
2131 }
2132 }
2133 }
2134
2135 #[test]
2136 fn parse_duplicate_repo_three_sections_preserves_spec_order_abc() {
2137 let input = r#"repo dup
2138
2139spec a
2140data x: 1
2141
2142repo dup
2143
2144spec b
2145data y: 2
2146
2147repo dup
2148
2149spec c
2150data z: 3"#;
2151 let parsed = parse(
2152 input,
2153 crate::parsing::source::SourceType::Volatile,
2154 &ResourceLimits::default(),
2155 )
2156 .unwrap();
2157 assert_eq!(parsed.repositories.len(), 1);
2158 let specs = parsed.repositories.values().next().unwrap();
2159 assert_eq!(
2160 specs.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
2161 vec!["a", "b", "c"]
2162 );
2163 }
2164
2165 #[test]
2166 fn parse_repo_single_section_roundtrips_through_formatter() {
2167 let input = "repo r\n\nspec a\ndata x: 1";
2168 let parsed = parse(
2169 input,
2170 crate::parsing::source::SourceType::Volatile,
2171 &ResourceLimits::default(),
2172 )
2173 .unwrap();
2174 let formatted = format_parse_result(&parsed);
2175 let again = parse(
2176 &formatted,
2177 crate::parsing::source::SourceType::Volatile,
2178 &ResourceLimits::default(),
2179 )
2180 .unwrap();
2181 assert_eq!(again.repositories.len(), parsed.repositories.len());
2182 assert_eq!(again.flatten_specs().len(), parsed.flatten_specs().len());
2183 assert_eq!(
2184 again.flatten_specs()[0].name,
2185 parsed.flatten_specs()[0].name
2186 );
2187 }
2188
2189 #[test]
2190 fn parse_repo_two_sections_roundtrips_through_formatter() {
2191 let input = "repo r1\n\nspec a\ndata x: 1\n\nrepo r2\n\nspec b\ndata y: 2";
2192 let parsed = parse(
2193 input,
2194 crate::parsing::source::SourceType::Volatile,
2195 &ResourceLimits::default(),
2196 )
2197 .unwrap();
2198 let formatted = format_parse_result(&parsed);
2199 let again = parse(
2200 &formatted,
2201 crate::parsing::source::SourceType::Volatile,
2202 &ResourceLimits::default(),
2203 )
2204 .unwrap();
2205 assert_eq!(again.repositories.len(), 2);
2206 assert_eq!(again.flatten_specs().len(), 2);
2207 }
2208
2209 #[test]
2210 fn parse_repo_duplicate_merge_formatter_emits_single_repo_block_or_equivalent_parse() {
2211 let input = r#"repo dup
2212
2213spec a
2214data x: 1
2215
2216repo dup
2217
2218spec b
2219data y: 2"#;
2220 let parsed = parse(
2221 input,
2222 crate::parsing::source::SourceType::Volatile,
2223 &ResourceLimits::default(),
2224 )
2225 .unwrap();
2226 let formatted = format_parse_result(&parsed);
2227 let again = parse(
2228 &formatted,
2229 crate::parsing::source::SourceType::Volatile,
2230 &ResourceLimits::default(),
2231 )
2232 .unwrap();
2233 assert_eq!(
2234 again.repositories.len(),
2235 1,
2236 "formatted duplicate-repo file must still merge to one logical repo"
2237 );
2238 assert_eq!(again.flatten_specs().len(), 2);
2239 }
2240
2241 #[test]
2242 fn parse_rejects_data_named_that_in_any_case() {
2243 for name in ["that", "That", "THAT"] {
2244 let result = parse(
2245 &format!("spec s\ndata {name}: 1"),
2246 crate::parsing::source::SourceType::Volatile,
2247 &ResourceLimits::default(),
2248 );
2249 let err = result.expect_err("that is reserved");
2250 assert!(
2251 err.to_string().to_ascii_lowercase().contains("that"),
2252 "{name}: expected reserved-keyword error, got: {err}"
2253 );
2254 }
2255 }
2256
2257 #[test]
2258 fn parse_rejects_data_named_measure() {
2259 let result = parse(
2260 "spec s\ndata measure: 1",
2261 crate::parsing::source::SourceType::Volatile,
2262 &ResourceLimits::default(),
2263 );
2264 assert!(
2265 result.is_err(),
2266 "data named measure (type keyword) must be rejected"
2267 );
2268 }
2269
2270 #[test]
2271 fn parse_rejects_data_named_number() {
2272 let result = parse(
2273 "spec s\ndata number: 42",
2274 crate::parsing::source::SourceType::Volatile,
2275 &ResourceLimits::default(),
2276 );
2277 assert!(
2278 result.is_err(),
2279 "data named number (type keyword) must be rejected"
2280 );
2281 }
2282
2283 #[test]
2284 fn parse_rejects_data_named_text() {
2285 let result = parse(
2286 "spec s\ndata text: \"hello\"",
2287 crate::parsing::source::SourceType::Volatile,
2288 &ResourceLimits::default(),
2289 );
2290 assert!(
2291 result.is_err(),
2292 "data named text (type keyword) must be rejected"
2293 );
2294 }
2295
2296 #[test]
2297 fn parse_rejects_data_named_date() {
2298 let result = parse(
2299 "spec s\ndata date: 2024-01-01",
2300 crate::parsing::source::SourceType::Volatile,
2301 &ResourceLimits::default(),
2302 );
2303 assert!(
2304 result.is_err(),
2305 "data named date (type keyword) must be rejected"
2306 );
2307 }
2308
2309 #[test]
2310 fn parse_rejects_data_named_boolean() {
2311 let result = parse(
2312 "spec s\ndata boolean: true",
2313 crate::parsing::source::SourceType::Volatile,
2314 &ResourceLimits::default(),
2315 );
2316 assert!(
2317 result.is_err(),
2318 "data named boolean (type keyword) must be rejected"
2319 );
2320 }
2321
2322 #[test]
2323 fn parse_rejects_data_named_ratio() {
2324 let result = parse(
2325 "spec s\ndata ratio: 5%",
2326 crate::parsing::source::SourceType::Volatile,
2327 &ResourceLimits::default(),
2328 );
2329 assert!(
2330 result.is_err(),
2331 "data named ratio (type keyword) must be rejected"
2332 );
2333 }
2334
2335 #[test]
2336 fn parse_rejects_rule_named_measure() {
2337 let result = parse(
2338 "spec s\ndata x: 1\nrule measure: x",
2339 crate::parsing::source::SourceType::Volatile,
2340 &ResourceLimits::default(),
2341 );
2342 assert!(
2343 result.is_err(),
2344 "rule named measure (type keyword) must be rejected"
2345 );
2346 }
2347
2348 #[test]
2349 fn parse_rejects_rule_named_number() {
2350 let result = parse(
2351 "spec s\ndata x: 1\nrule number: x",
2352 crate::parsing::source::SourceType::Volatile,
2353 &ResourceLimits::default(),
2354 );
2355 assert!(
2356 result.is_err(),
2357 "rule named number (type keyword) must be rejected"
2358 );
2359 }
2360
2361 #[test]
2362 fn parse_rejects_rule_named_text() {
2363 let result = parse(
2364 "spec s\ndata x: 1\nrule text: x",
2365 crate::parsing::source::SourceType::Volatile,
2366 &ResourceLimits::default(),
2367 );
2368 assert!(
2369 result.is_err(),
2370 "rule named text (type keyword) must be rejected"
2371 );
2372 }
2373
2374 #[test]
2375 fn parse_rejects_rule_named_date() {
2376 let result = parse(
2377 "spec s\ndata x: 1\nrule date: x",
2378 crate::parsing::source::SourceType::Volatile,
2379 &ResourceLimits::default(),
2380 );
2381 assert!(
2382 result.is_err(),
2383 "rule named date (type keyword) must be rejected"
2384 );
2385 }
2386
2387 #[test]
2388 fn parse_rejects_rule_named_boolean() {
2389 let result = parse(
2390 "spec s\ndata x: 1\nrule boolean: x",
2391 crate::parsing::source::SourceType::Volatile,
2392 &ResourceLimits::default(),
2393 );
2394 assert!(
2395 result.is_err(),
2396 "rule named boolean (type keyword) must be rejected"
2397 );
2398 }
2399
2400 #[test]
2401 fn parse_rejects_rule_named_ratio() {
2402 let result = parse(
2403 "spec s\ndata x: 1\nrule ratio: x",
2404 crate::parsing::source::SourceType::Volatile,
2405 &ResourceLimits::default(),
2406 );
2407 assert!(
2408 result.is_err(),
2409 "rule named ratio (type keyword) must be rejected"
2410 );
2411 }
2412
2413 fn assert_reserved_name(source: &str) {
2414 let result = parse(
2415 source,
2416 crate::parsing::source::SourceType::Volatile,
2417 &ResourceLimits::default(),
2418 );
2419 let message = result
2420 .expect_err("reserved name must not parse")
2421 .to_string();
2422 assert!(
2423 message.contains("reserved keyword"),
2424 "expected reserved-keyword error, got: {message}"
2425 );
2426 }
2427
2428 #[test]
2429 fn parse_rejects_lower_and_upper_as_names() {
2430 assert_reserved_name("spec s\ndata lower: 1");
2431 assert_reserved_name("spec s\ndata upper: 1");
2432 assert_reserved_name("spec s\nrule lower: 1");
2433 assert_reserved_name("spec s\nrule upper: 1");
2434 }
2435
2436 #[test]
2437 fn parse_constraint_lower_and_upper_still_parse() {
2438 let parsed = parse(
2439 r#"spec window
2440data period: date range
2441 -> lower 2020-01-01
2442 -> upper 2030-12-31
2443"#,
2444 crate::parsing::source::SourceType::Volatile,
2445 &ResourceLimits::default(),
2446 )
2447 .expect("-> lower / -> upper are constraint commands");
2448 let spec = parsed.flatten_specs().into_iter().next().expect("one spec");
2449 let period = spec
2450 .data
2451 .iter()
2452 .find(|data| data.reference.name == "period")
2453 .expect("period");
2454 let DataValue::Definition {
2455 constraints: Some(rows),
2456 ..
2457 } = &period.value
2458 else {
2459 panic!("expected constraints, got {:?}", period.value);
2460 };
2461 let commands: Vec<_> = rows.iter().map(|row| row.command).collect();
2462 assert_eq!(
2463 commands,
2464 vec![TypeConstraintCommand::Lower, TypeConstraintCommand::Upper]
2465 );
2466 }
2467
2468 fn prefix_expression(source: &str) -> Expression {
2469 rule_expression(source, "out")
2470 }
2471
2472 fn prefix_parse_error(source: &str) -> String {
2473 match parse(
2474 source,
2475 crate::parsing::source::SourceType::Volatile,
2476 &ResourceLimits::default(),
2477 ) {
2478 Err(Error::Parsing(details)) => details.message,
2479 other => panic!("expected parse Error, got {other:?}"),
2480 }
2481 }
2482
2483 #[test]
2484 fn parse_prefix_operand_is_call_or_one_operand() {
2485 for (body, operator) in [
2486 ("abs x + 1", "+"),
2487 ("abs x as gram", "as"),
2488 ("sqrt x ^ 2", "^"),
2489 ("lower p + 1", "+"),
2490 ("not ok and ok", "and"),
2491 ("not x > 1", ">"),
2492 ] {
2493 let message = prefix_parse_error(&format!(
2494 "spec test\nuses lemma units\ndata x: 1\ndata p: number range\ndata ok: boolean\nrule out: {body}"
2495 ));
2496 assert!(
2497 message.contains(&format!("found '{operator}' after it")),
2498 "{body}: {message}"
2499 );
2500 }
2501 let message = prefix_parse_error("spec test\nrule out: abs -1");
2502 assert!(message.contains("`abs(...)`"), "{message}");
2503
2504 let abs_plus = prefix_expression(
2505 r#"spec test
2506data x: 1
2507rule out: abs(x) + 1"#,
2508 );
2509 let Some((left, ArithmeticComputation::Add, _)) = binary_arithmetic(abs_plus.root()) else {
2510 panic!("abs(x) + 1 must be add, got {abs_plus}");
2511 };
2512 assert!(matches!(
2513 left.kind(),
2514 ExpressionKind::MathematicalComputation(MathematicalComputation::Abs, _)
2515 ));
2516
2517 let abs_sum = prefix_expression(
2518 r#"spec test
2519data x: 1
2520rule out: abs (x + 1)"#,
2521 );
2522 let ExpressionKind::MathematicalComputation(MathematicalComputation::Abs, operand) =
2523 abs_sum.root_kind()
2524 else {
2525 panic!("abs (x + 1) must be abs of a sum, got {abs_sum}");
2526 };
2527 assert!(matches!(
2528 abs_sum.at(*operand).kind(),
2529 ExpressionKind::Arithmetic { .. }
2530 ));
2531
2532 let abs_sqrt = prefix_expression(
2533 r#"spec test
2534data x: 1
2535rule out: abs sqrt x"#,
2536 );
2537 let ExpressionKind::MathematicalComputation(MathematicalComputation::Abs, operand) =
2538 abs_sqrt.root_kind()
2539 else {
2540 panic!("abs sqrt x must be abs of sqrt, got {abs_sqrt}");
2541 };
2542 assert!(matches!(
2543 abs_sqrt.at(*operand).kind(),
2544 ExpressionKind::MathematicalComputation(MathematicalComputation::Sqrt, _)
2545 ));
2546
2547 let lower_range = prefix_expression(
2548 r#"spec test
2549rule out: lower 5...10"#,
2550 );
2551 let ExpressionKind::RangeBound(RangeBound::Lower, operand) = lower_range.root_kind() else {
2552 panic!("lower 5...10 must be lower of a range, got {lower_range}");
2553 };
2554 assert!(matches!(
2555 lower_range.at(*operand).kind(),
2556 ExpressionKind::RangeLiteral(..)
2557 ));
2558 assert_eq!(
2559 rule_expression(
2560 r#"spec test
2561rule out: lower 5...10"#,
2562 "out"
2563 )
2564 .to_string(),
2565 "lower 5...10"
2566 );
2567
2568 let lower_extended = prefix_expression(
2569 r#"spec test
2570data a: 1
2571data b: 2
2572rule out: lower a...b + 1"#,
2573 );
2574 let ExpressionKind::RangeBound(RangeBound::Lower, operand) = lower_extended.root_kind()
2575 else {
2576 panic!("expected lower of a range, got {lower_extended}");
2577 };
2578 let operand = lower_extended.at(*operand);
2579 let ExpressionKind::RangeLiteral(_, right) = operand.kind() else {
2580 panic!("+ must stay inside the range, got {:?}", operand.kind());
2581 };
2582 assert!(matches!(
2583 binary_arithmetic(operand.child(*right)),
2584 Some((_, ArithmeticComputation::Add, _))
2585 ));
2586
2587 let not_comparison = prefix_expression(
2588 r#"spec test
2589data x: 1
2590rule out: not (x > 1)"#,
2591 );
2592 let ExpressionKind::LogicalNegation(operand, _) = not_comparison.root_kind() else {
2593 panic!("not (x > 1) must negate the comparison, got {not_comparison}");
2594 };
2595 assert!(matches!(
2596 not_comparison.at(*operand).kind(),
2597 ExpressionKind::Comparison(..)
2598 ));
2599 }
2600
2601 fn arrow_expression(body: &str) -> Expression {
2602 rule_expression(&format!("spec test\nrule out: {body}"), "out")
2603 }
2604
2605 #[test]
2606 fn arrow_takes_the_whole_arithmetic_operand() {
2607 let sum = arrow_expression("a + b -> sum x");
2608 let ExpressionKind::ListCommand { list, .. } = sum.root_kind() else {
2609 panic!("a + b -> sum x must be a command on a + b, got {sum}");
2610 };
2611 assert!(matches!(
2612 binary_arithmetic(sum.at(*list)),
2613 Some((_, ArithmeticComputation::Add, _))
2614 ));
2615
2616 let quotient = arrow_expression("total / weight -> round 0.001 eur_per_kg");
2617 let ExpressionKind::StepRounding {
2618 operand,
2619 mode: RoundingMode::Round,
2620 step,
2621 } = quotient.root_kind()
2622 else {
2623 panic!("total / weight -> round step must round the quotient, got {quotient}");
2624 };
2625 assert!(matches!(
2626 binary_arithmetic(quotient.at(*operand)),
2627 Some((_, ArithmeticComputation::Divide, _))
2628 ));
2629 let Some(step) = step else {
2630 panic!("total / weight -> round step must carry a step");
2631 };
2632 assert!(matches!(
2633 quotient.at(*step).kind(),
2634 ExpressionKind::Literal(_)
2635 ));
2636
2637 let prefix = arrow_expression("abs saldo -> floor 1 eur");
2638 let ExpressionKind::StepRounding {
2639 operand,
2640 mode: RoundingMode::Floor,
2641 ..
2642 } = prefix.root_kind()
2643 else {
2644 panic!("abs saldo -> floor step must floor abs saldo, got {prefix}");
2645 };
2646 assert!(matches!(
2647 prefix.at(*operand).kind(),
2648 ExpressionKind::MathematicalComputation(MathematicalComputation::Abs, _)
2649 ));
2650
2651 let call = arrow_expression("eind - start -> ceil(15 minute)");
2652 assert!(matches!(
2653 call.root_kind(),
2654 ExpressionKind::StepRounding {
2655 mode: RoundingMode::Ceil,
2656 ..
2657 }
2658 ));
2659
2660 let bare = arrow_expression("x -> round");
2661 assert!(matches!(
2662 bare.root_kind(),
2663 ExpressionKind::StepRounding {
2664 mode: RoundingMode::Round,
2665 step: None,
2666 ..
2667 }
2668 ));
2669 }
2670
2671 #[test]
2672 fn arrow_binds_tighter_than_comparison_and_logic() {
2673 let count = arrow_expression("items -> count is 0");
2674 let ExpressionKind::Comparison(left, _, _) = count.root_kind() else {
2675 panic!("items -> count is 0 must compare the count, got {count}");
2676 };
2677 assert!(matches!(
2678 count.at(*left).kind(),
2679 ExpressionKind::ListCommand { .. }
2680 ));
2681
2682 let right = arrow_expression("limit > items -> sum cost");
2683 let ExpressionKind::Comparison(_, _, right_operand) = right.root_kind() else {
2684 panic!("limit > items -> sum cost must compare against the sum, got {right}");
2685 };
2686 assert!(matches!(
2687 right.at(*right_operand).kind(),
2688 ExpressionKind::ListCommand { .. }
2689 ));
2690
2691 let logic = arrow_expression("ok and items -> count > 0");
2692 let ExpressionKind::Logical { steps, .. } = logic.root_kind() else {
2693 panic!("ok and items -> count > 0 must be a conjunction, got {logic}");
2694 };
2695 let ExpressionKind::Comparison(left, _, _) = logic.at(steps[0].operand).kind() else {
2696 panic!("second operand must be a comparison, got {logic}");
2697 };
2698 assert!(matches!(
2699 logic.at(*left).kind(),
2700 ExpressionKind::ListCommand { .. }
2701 ));
2702
2703 let chain = arrow_expression("staff -> where paid -> sum bonus");
2704 let ExpressionKind::ListCommand { list, .. } = chain.root_kind() else {
2705 panic!("chained commands must nest left to right, got {chain}");
2706 };
2707 assert!(matches!(
2708 chain.at(*list).kind(),
2709 ExpressionKind::ListCommand { .. }
2710 ));
2711 }
2712
2713 #[test]
2714 fn operator_after_a_command_is_a_parse_error() {
2715 for (body, found, command) in [
2716 ("lines -> sum(qty) * 2", "*", "sum(qty)"),
2717 ("fares -> sum - 1", "-", "sum"),
2718 ("speeds -> max as mps", "as", "max"),
2719 ("fares -> count + 1", "+", "count"),
2720 ("x -> round(0.01) ^ 2", "^", "round(0.01)"),
2721 ] {
2722 let message = prefix_parse_error(&format!("spec test\nrule out: {body}"));
2723 assert!(
2724 message.contains(&format!("found '{found}' after `-> {command}`")),
2725 "{body}: {message}"
2726 );
2727 assert!(message.contains("parentheses"), "{body}: {message}");
2728 }
2729 }
2730
2731 #[test]
2732 fn arrow_after_bare_not_is_a_parse_error() {
2733 let message = prefix_parse_error("spec test\nrule out: not items -> any paid");
2734 assert!(
2735 message.contains("`not (items -> ...)`"),
2736 "not followed by -> must suggest parentheses: {message}"
2737 );
2738 }
2739
2740 #[test]
2741 fn arrow_after_in_range_suggests_parentheses_around_the_command() {
2742 let message = prefix_parse_error("spec test\nrule out: day in periods -> first");
2743 assert!(
2744 message.contains(
2745 "found '->' after `day in periods`; `in` takes one range operand, \
2746 so put parentheses around the command: `day in (periods -> ...)`"
2747 ),
2748 "{message}"
2749 );
2750 }
2751
2752 #[test]
2753 fn arrow_after_in_calendar_suggests_parentheses_around_the_check() {
2754 let message = prefix_parse_error("spec test\nrule out: day in past calendar year -> first");
2755 assert!(
2756 message.contains("found '->' after `day in past calendar year`"),
2757 "{message}"
2758 );
2759 assert!(
2760 message.contains("`(day in past calendar year) -> ...`"),
2761 "{message}"
2762 );
2763 }
2764
2765 #[test]
2766 fn prefix_rounding_suggests_the_arrow_form() {
2767 for (body, keyword, rewrite) in [
2768 ("round(x)", "round", "`x -> round`"),
2769 ("floor x", "floor", "`x -> floor`"),
2770 ("ceil (a / b)", "ceil", "`a / b -> ceil`"),
2771 ] {
2772 let message = prefix_parse_error(&format!(
2773 "spec test\ndata x: 1\ndata a: 1\ndata b: 1\nrule out: {body}"
2774 ));
2775 assert!(
2776 message.contains(&format!("`{keyword}` is written after `->`")),
2777 "{body}: {message}"
2778 );
2779 assert!(message.contains(rewrite), "{body}: {message}");
2780 }
2781 }
2782
2783 #[test]
2784 fn format_prefix_operand_round_trips() {
2785 let source = r#"spec test
2786data x: 1.00
2787rule tight: abs(x) + 1
2788rule wide: abs (x + 1)
2789rule ends: lower 5...10
2790rule edge: (lower x)...(upper x)
2791rule round_bare: x -> round
2792rule round_step: x -> round(0.05) > 1
2793"#;
2794 let formatted =
2795 crate::formatting::format_source(source, crate::parsing::source::SourceType::Volatile)
2796 .expect("format");
2797 let again = crate::formatting::format_source(
2798 &formatted,
2799 crate::parsing::source::SourceType::Volatile,
2800 )
2801 .expect("reformat");
2802 assert_eq!(formatted, again, "format must be stable");
2803
2804 for rule in ["tight", "wide", "ends", "edge", "round_bare", "round_step"] {
2805 let original = rule_expression(source, rule);
2806 let reparsed = rule_expression(&formatted, rule);
2807 assert_eq!(
2808 original.to_string(),
2809 reparsed.to_string(),
2810 "{rule} changed meaning across format/parse"
2811 );
2812 }
2813
2814 let tight = rule_expression(&formatted, "tight");
2815 let Some((left, ArithmeticComputation::Add, _)) = binary_arithmetic(tight.root()) else {
2816 panic!("abs(x) + 1 must stay an addition, got {}", tight);
2817 };
2818 assert!(
2819 matches!(
2820 left.kind(),
2821 ExpressionKind::MathematicalComputation(MathematicalComputation::Abs, _)
2822 ),
2823 "abs(x) + 1 must stay abs-then-add, got {tight}"
2824 );
2825 }
2826}