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