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::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)]
25mod tests {
26 use super::{parse, ArithmeticComputation, Expression, ExpressionKind};
27 use crate::formatting::format_parse_result;
28 use crate::Error;
29 use crate::ResourceLimits;
30
31 #[test]
32 fn parse_empty_input_returns_no_specs() {
33 let result = parse(
34 "",
35 crate::parsing::source::SourceType::Volatile,
36 &ResourceLimits::default(),
37 )
38 .unwrap()
39 .into_flattened_specs();
40 assert_eq!(result.len(), 0);
41 }
42
43 #[test]
44 fn parse_workspace_file_yields_expected_spec_datas_and_rules() {
45 let input = r#"spec person
46data name: "John Doe"
47rule adult: true"#;
48 let result = parse(
49 input,
50 crate::parsing::source::SourceType::Volatile,
51 &ResourceLimits::default(),
52 )
53 .unwrap()
54 .into_flattened_specs();
55 assert_eq!(result.len(), 1);
56 assert_eq!(result[0].name, "person");
57 assert_eq!(result[0].data.len(), 1);
58 assert_eq!(result[0].rules.len(), 1);
59 assert_eq!(result[0].rules[0].name, "adult");
60 }
61
62 #[test]
63 fn mixing_data_and_rules_is_collected_into_spec() {
64 let input = r#"spec test
65data name: "John"
66rule is_adult: age >= 18
67data age: 25
68rule can_drink: age >= 21
69data status: "active"
70rule is_eligible: is_adult and status is "active""#;
71
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].data.len(), 3);
81 assert_eq!(result[0].rules.len(), 3);
82 }
83
84 #[test]
85 fn parse_simple_spec_collects_data() {
86 let input = r#"spec person
87data name: "John"
88data age: 25"#;
89 let result = parse(
90 input,
91 crate::parsing::source::SourceType::Volatile,
92 &ResourceLimits::default(),
93 )
94 .unwrap()
95 .into_flattened_specs();
96 assert_eq!(result.len(), 1);
97 assert_eq!(result[0].name, "person");
98 assert_eq!(result[0].data.len(), 2);
99 }
100
101 #[test]
102 fn parse_dotted_spec_name() {
103 let input = r#"spec contracts.employment.jack
104data name: "Jack""#;
105 let result = parse(
106 input,
107 crate::parsing::source::SourceType::Volatile,
108 &ResourceLimits::default(),
109 )
110 .unwrap()
111 .into_flattened_specs();
112 assert_eq!(result.len(), 1);
113 assert_eq!(result[0].name, "contracts.employment.jack");
114 }
115
116 #[test]
117 fn parse_slashed_spec_name() {
118 let input = "spec contracts/employment/jack\ndata x: 1";
119 let result = parse(
120 input,
121 crate::parsing::source::SourceType::Volatile,
122 &ResourceLimits::default(),
123 )
124 .unwrap()
125 .into_flattened_specs();
126 assert_eq!(result.len(), 1);
127 assert_eq!(result[0].name, "contracts/employment/jack");
128 }
129
130 #[test]
131 fn parse_spec_name_no_version_tag() {
132 let input = "spec myspec\nrule x: 1";
133 let result = parse(
134 input,
135 crate::parsing::source::SourceType::Volatile,
136 &ResourceLimits::default(),
137 )
138 .unwrap()
139 .into_flattened_specs();
140 assert_eq!(result.len(), 1);
141 assert_eq!(result[0].name, "myspec");
142 assert_eq!(result[0].effective_from(), None);
143 }
144
145 #[test]
146 fn parse_commentary_block_is_attached_to_spec() {
147 let input = r#"spec person
148"""
149This is a markdown comment
150uses **bold** text
151"""
152data name: "John""#;
153 let result = parse(
154 input,
155 crate::parsing::source::SourceType::Volatile,
156 &ResourceLimits::default(),
157 )
158 .unwrap()
159 .into_flattened_specs();
160 assert_eq!(result.len(), 1);
161 assert!(result[0].commentary.is_some());
162 assert!(result[0].commentary.as_ref().unwrap().contains("**bold**"));
163 }
164
165 #[test]
166 fn parse_spec_with_rule_collects_rule() {
167 let input = r#"spec person
168rule is_adult: age >= 18"#;
169 let result = parse(
170 input,
171 crate::parsing::source::SourceType::Volatile,
172 &ResourceLimits::default(),
173 )
174 .unwrap()
175 .into_flattened_specs();
176 assert_eq!(result.len(), 1);
177 assert_eq!(result[0].rules.len(), 1);
178 assert_eq!(result[0].rules[0].name, "is_adult");
179 }
180
181 #[test]
182 fn parse_multiple_specs_returns_all_specs() {
183 let input = r#"spec person
184data name: "John"
185
186spec company
187data name: "Acme Corp""#;
188 let result = parse(
189 input,
190 crate::parsing::source::SourceType::Volatile,
191 &ResourceLimits::default(),
192 )
193 .unwrap()
194 .into_flattened_specs();
195 assert_eq!(result.len(), 2);
196 assert_eq!(result[0].name, "person");
197 assert_eq!(result[1].name, "company");
198 }
199
200 #[test]
201 fn parse_allows_duplicate_data_names() {
202 let input = r#"spec person
203data name: "John"
204data name: "Jane""#;
205 let result = parse(
206 input,
207 crate::parsing::source::SourceType::Volatile,
208 &ResourceLimits::default(),
209 );
210 assert!(
211 result.is_ok(),
212 "Parser should succeed even with duplicate data"
213 );
214 }
215
216 #[test]
217 fn parse_allows_duplicate_rule_names() {
218 let input = r#"spec person
219rule is_adult: age >= 18
220rule is_adult: age >= 21"#;
221 let result = parse(
222 input,
223 crate::parsing::source::SourceType::Volatile,
224 &ResourceLimits::default(),
225 );
226 assert!(
227 result.is_ok(),
228 "Parser should succeed even with duplicate rules"
229 );
230 }
231
232 #[test]
233 fn parse_rejects_malformed_input() {
234 let input = "invalid syntax here";
235 let result = parse(
236 input,
237 crate::parsing::source::SourceType::Volatile,
238 &ResourceLimits::default(),
239 );
240 assert!(result.is_err());
241 }
242
243 #[test]
244 fn parse_handles_whitespace_variants_in_expressions() {
245 let test_cases = vec![
246 ("spec test\nrule test: 2+3", "no spaces in arithmetic"),
247 ("spec test\nrule test: age>=18", "no spaces in comparison"),
248 (
249 "spec test\nrule test: age >= 18 and salary>50000",
250 "spaces around and keyword",
251 ),
252 (
253 "spec test\nrule test: age >= 18 and salary > 50000",
254 "extra spaces",
255 ),
256 (
257 "spec test\nrule test: \n age >= 18 \n and \n salary > 50000",
258 "newlines in expression",
259 ),
260 ];
261
262 for (input, description) in test_cases {
263 let result = parse(
264 input,
265 crate::parsing::source::SourceType::Volatile,
266 &ResourceLimits::default(),
267 );
268 assert!(
269 result.is_ok(),
270 "Failed to parse {} ({}): {:?}",
271 input,
272 description,
273 result.err()
274 );
275 }
276 }
277
278 #[test]
279 fn parse_error_cases_are_rejected() {
280 let error_cases = vec![
281 (
282 "spec test\ndata name: \"unclosed string",
283 "unclosed string literal",
284 ),
285 ("spec test\nrule test: (2 + 3", "unclosed parenthesis"),
286 ("spec test\nrule test: 2 + 3)", "extra closing paren"),
287 ("spec test\ndata spec: 123", "reserved keyword as data name"),
288 (
289 "spec test\nrule rule: true",
290 "reserved keyword as rule name",
291 ),
292 ];
293
294 for (input, description) in error_cases {
295 let result = parse(
296 input,
297 crate::parsing::source::SourceType::Volatile,
298 &ResourceLimits::default(),
299 );
300 assert!(
301 result.is_err(),
302 "Expected error for {} but got success",
303 description
304 );
305 }
306 }
307
308 #[test]
309 fn parse_duration_literals_in_rules() {
310 let test_cases = vec![
311 ("2 years", "years"),
312 ("6 months", "months"),
313 ("52 weeks", "weeks"),
314 ("365 days", "days"),
315 ("24 hours", "hours"),
316 ("60 minutes", "minutes"),
317 ("3600 seconds", "seconds"),
318 ("1000 milliseconds", "milliseconds"),
319 ("500000 microseconds", "microseconds"),
320 ("50 percent", "percent"),
321 ];
322
323 for (expr, description) in test_cases {
324 let input = format!("spec test\nrule test: {}", expr);
325 let result = parse(
326 &input,
327 crate::parsing::source::SourceType::Volatile,
328 &ResourceLimits::default(),
329 );
330 assert!(
331 result.is_ok(),
332 "Failed to parse literal {} ({}): {:?}",
333 expr,
334 description,
335 result.err()
336 );
337 }
338 }
339
340 #[test]
341 fn parse_comparisons_with_duration_unit_conversions() {
342 let test_cases = vec![
343 (
344 "(duration as hours) > 2",
345 "duration conversion in comparison with parens",
346 ),
347 (
348 "(meeting_time as minutes) >= 30",
349 "duration conversion with gte",
350 ),
351 (
352 "(project_length as days) < 100",
353 "duration conversion with lt",
354 ),
355 (
356 "(delay as seconds) is 60",
357 "duration conversion with equality",
358 ),
359 (
360 "(1 hours) > (30 minutes)",
361 "duration conversions on both sides",
362 ),
363 (
364 "duration as hours > 2",
365 "duration conversion without parens",
366 ),
367 (
368 "meeting_time as seconds > 3600",
369 "variable duration conversion in comparison",
370 ),
371 (
372 "project_length as days > deadline_days",
373 "two variables with duration conversion",
374 ),
375 (
376 "duration as hours >= 1 and duration as hours <= 8",
377 "multiple duration comparisons",
378 ),
379 (
380 "(2024-06-01...2024-06-15) as days as number >= 7",
381 "chained as conversion before comparison",
382 ),
383 ("duration as hours as number > 2", "chained as on duration"),
384 ];
385
386 for (expr, description) in test_cases {
387 let input = format!("spec test\nrule test: {}", expr);
388 let result = parse(
389 &input,
390 crate::parsing::source::SourceType::Volatile,
391 &ResourceLimits::default(),
392 );
393 assert!(
394 result.is_ok(),
395 "Failed to parse {} ({}): {:?}",
396 expr,
397 description,
398 result.err()
399 );
400 }
401 }
402
403 #[test]
404 fn parse_rejects_token_after_unit_conversion() {
405 let result = parse(
406 "spec test\nuses lemma units\nrule ok: (2024-06-01...2024-06-15) as days foo",
407 crate::parsing::source::SourceType::Volatile,
408 &ResourceLimits::default(),
409 );
410 let err = result.expect_err("expected parse error");
411 let msg = err.to_string();
412 assert!(
413 msg.contains("Unexpected token") && msg.contains("foo"),
414 "expected error at 'foo', got: {}",
415 msg
416 );
417 assert!(
418 !msg.contains("Expected 'data'"),
419 "should not defer to spec-level error, got: {}",
420 msg
421 );
422 }
423
424 #[test]
425 fn parse_unit_conversion_before_next_spec() {
426 let result = parse(
427 r#"spec pricing
428rule hourly_rate: 150 eur
429 unless loyalty is "silver" then 140 eur
430 unless loyalty is "gold" then 125 usd as eur
431
432spec other
433rule x: 1"#,
434 crate::parsing::source::SourceType::Volatile,
435 &ResourceLimits::default(),
436 );
437 assert!(
438 result.is_ok(),
439 "unless branch ending with 'as' must parse before next spec: {:?}",
440 result.err()
441 );
442 }
443
444 #[test]
445 fn parse_unit_conversion_before_sibling_rule() {
446 let result = parse(
447 r#"spec s
448rule a: 100 usd as eur
449rule b: 1"#,
450 crate::parsing::source::SourceType::Volatile,
451 &ResourceLimits::default(),
452 );
453 assert!(
454 result.is_ok(),
455 "rule ending with 'as' must parse before sibling rule: {:?}",
456 result.err()
457 );
458 }
459
460 #[test]
461 fn parse_unit_conversion_before_uses() {
462 let result = parse(
463 r#"spec s
464rule rate: 10 usd as eur
465uses lemma units
466rule hours: 1 hour"#,
467 crate::parsing::source::SourceType::Volatile,
468 &ResourceLimits::default(),
469 );
470 assert!(
471 result.is_ok(),
472 "rule ending with 'as' must parse before uses: {:?}",
473 result.err()
474 );
475 }
476
477 #[test]
479 fn parse_unit_conversion_before_expression_boundaries() {
480 let cases: &[(&str, &str)] = &[
481 (
482 "sibling data",
483 r#"spec s
484rule rate: 10 usd as eur
485data price: 100 eur"#,
486 ),
487 (
488 "sibling with",
489 r#"spec s
490rule rate: 10 usd as eur
491with cfg.rate: rate"#,
492 ),
493 (
494 "sibling meta",
495 r#"spec s
496rule rate: 10 usd as eur
497meta version: 1"#,
498 ),
499 (
500 "another unless",
501 r#"spec s
502rule rate: 10 usd
503 unless active then 5 usd as eur
504 unless premium then 3 usd as eur"#,
505 ),
506 (
507 "eof",
508 r#"spec s
509rule rate: 10 usd as eur"#,
510 ),
511 (
512 "next repo",
513 r#"spec s
514rule rate: 10 usd as eur
515
516repo other
517spec t
518rule x: 1"#,
519 ),
520 (
521 "unless then before next unless",
522 r#"spec s
523rule rate: 10 usd
524 unless a then 1 usd as eur
525 unless b then 2"#,
526 ),
527 (
528 "chained as before sibling rule",
529 r#"spec s
530rule rate: (2024-01-01...2024-01-02) as days as number
531rule other: 1"#,
532 ),
533 ];
534
535 for (label, source) in cases {
536 let result = parse(
537 source,
538 crate::parsing::source::SourceType::Volatile,
539 &ResourceLimits::default(),
540 );
541 assert!(
542 result.is_ok(),
543 "unit conversion before {label} must parse: {:?}",
544 result.err()
545 );
546 }
547 }
548
549 #[test]
550 fn parse_rejects_plain_number_plus_converted_operand() {
551 let result = parse(
552 r#"spec test
553data c: quantity
554 -> unit eur 1
555 -> unit usd 0.84
556rule z: 5 + c as usd"#,
557 crate::parsing::source::SourceType::Volatile,
558 &ResourceLimits::default(),
559 );
560 let err = result.expect_err("expected parse error for 5 + c as usd");
561 let msg = err.to_string();
562 assert!(
563 msg.contains("plain number") || msg.contains("each operand"),
564 "expected conversion-before-+ error, got: {msg}"
565 );
566 }
567
568 #[test]
569 fn parse_accepts_conversion_on_each_additive_operand() {
570 let cases: &[(&str, &str)] = &[
571 (
572 "money",
573 r#"spec test
574data c: quantity
575 -> unit eur 1
576 -> unit usd 0.84
577rule z: 5 as usd + c as usd"#,
578 ),
579 (
580 "duration + literal",
581 r#"spec test
582uses lemma units
583rule z: duration as hours + 1"#,
584 ),
585 (
586 "duration + comparison",
587 r#"spec test
588uses lemma units
589data duration: units.duration
590 -> default 1 hour
591rule z: duration as hours + 1 > 0"#,
592 ),
593 (
594 "date range + ref",
595 r#"spec test
596uses lemma units
597data age: date range
598data c: quantity
599 -> unit eur 1
600rule z: age as days + c"#,
601 ),
602 ];
603 for (label, source) in cases {
604 let result = parse(
605 source,
606 crate::parsing::source::SourceType::Volatile,
607 &ResourceLimits::default(),
608 );
609 assert!(
610 result.is_ok(),
611 "expected {label} to parse, got: {:?}",
612 result.err()
613 );
614 }
615 }
616
617 fn rule_expression(source: &str, rule_name: &str) -> Expression {
618 let parsed = parse(
619 source,
620 crate::parsing::source::SourceType::Volatile,
621 &ResourceLimits::default(),
622 )
623 .expect("expected parse");
624 let spec = parsed
625 .flatten_specs()
626 .into_iter()
627 .next()
628 .expect("expected one spec");
629 spec.rules
630 .iter()
631 .find(|rule| rule.name == rule_name)
632 .unwrap_or_else(|| panic!("rule '{rule_name}' not found"))
633 .expression
634 .clone()
635 }
636
637 fn assert_multiply_range_side(expression: &Expression, range_on_left: bool, label: &str) {
638 let ExpressionKind::Arithmetic(left, ArithmeticComputation::Multiply, right) =
639 &expression.kind
640 else {
641 panic!("{label}: expected Multiply, got {:?}", expression.kind);
642 };
643 let (range, other) = if range_on_left {
644 (left.as_ref(), right.as_ref())
645 } else {
646 (right.as_ref(), left.as_ref())
647 };
648 assert!(
649 matches!(range.kind, ExpressionKind::RangeLiteral(..)),
650 "{label}: expected RangeLiteral on {} of *, got {:?}",
651 if range_on_left { "left" } else { "right" },
652 range.kind
653 );
654 assert!(
655 !matches!(other.kind, ExpressionKind::RangeLiteral(..)),
656 "{label}: expected non-range on other side of *"
657 );
658 }
659
660 #[test]
661 fn parse_range_binds_tighter_than_multiply() {
662 let base = r#"spec test
663uses lemma units
664data rate: quantity -> unit eur 1
665data period_start: 2026-01-01
666data period_end: 2026-01-02
667"#;
668 assert_multiply_range_side(
669 &rule_expression(
670 &format!("{base}rule rhs: rate * period_start...period_end"),
671 "rhs",
672 ),
673 false,
674 "rate * period_start...period_end",
675 );
676 assert_multiply_range_side(
677 &rule_expression(
678 &format!("{base}rule lhs: period_start...period_end * rate"),
679 "lhs",
680 ),
681 true,
682 "period_start...period_end * rate",
683 );
684 }
685
686 #[test]
687 fn parse_range_in_additive_term_before_plus() {
688 let expression = rule_expression(
689 r#"spec test
690uses lemma units
691data period_start: 2026-01-01
692data period_end: 2026-01-02
693rule span: period_start...period_end + 1 day"#,
694 "span",
695 );
696 let ExpressionKind::Arithmetic(left, ArithmeticComputation::Add, right) = &expression.kind
697 else {
698 panic!("expected Add, got {:?}", expression.kind);
699 };
700 assert!(matches!(left.kind, ExpressionKind::RangeLiteral(..)));
701 assert!(!matches!(right.kind, ExpressionKind::RangeLiteral(..)));
702 }
703
704 #[test]
705 fn parse_range_multiply_with_conversion_without_inner_parens() {
706 let expression = rule_expression(
707 r#"spec test
708uses lemma units
709data money: quantity -> unit eur 1
710data rate: quantity -> unit eur_per_hour eur/hour
711data hourly_rate: 50 eur_per_hour
712data period_start: 2026-01-01
713data period_end: 2026-01-02
714rule pay: (hourly_rate * period_start...period_end) as eur"#,
715 "pay",
716 );
717 let ExpressionKind::UnitConversion(inner, _) = &expression.kind else {
718 panic!("expected UnitConversion, got {:?}", expression.kind);
719 };
720 assert_multiply_range_side(inner, false, "pay");
721 }
722
723 #[test]
724 fn parse_error_includes_attribute_and_parse_error_spec_name() {
725 let result = parse(
726 r#"
727spec test
728data name: "Unclosed string
729data age: 25
730"#,
731 crate::parsing::source::SourceType::Volatile,
732 &ResourceLimits::default(),
733 );
734
735 match result {
736 Err(Error::Parsing(details)) => {
737 let src = details
738 .source
739 .as_ref()
740 .expect("BUG: parsing errors always have source");
741 assert_eq!(
742 src.source_type,
743 crate::parsing::source::SourceType::Volatile
744 );
745 }
746 Err(e) => panic!("Expected Parse error, got: {e:?}"),
747 Ok(_) => panic!("Expected parse error for unclosed string"),
748 }
749 }
750
751 #[test]
752 fn parse_single_spec_file() {
753 let input = r#"spec somespec
754data name: "Alice""#;
755 let parsed = parse(
756 input,
757 crate::parsing::source::SourceType::Volatile,
758 &ResourceLimits::default(),
759 )
760 .unwrap();
761 let specs = parsed.flatten_specs();
762 assert_eq!(specs.len(), 1);
763 assert_eq!(specs[0].name, "somespec");
764 }
765
766 #[test]
767 fn parse_uses_registry_spec_explicit_alias() {
768 let input = r#"spec example
769uses external: @user/workspace somespec"#;
770 let specs = parse(
771 input,
772 crate::parsing::source::SourceType::Volatile,
773 &ResourceLimits::default(),
774 )
775 .unwrap()
776 .into_flattened_specs();
777 assert_eq!(specs.len(), 1);
778 assert_eq!(specs[0].data.len(), 1);
779 match &specs[0].data[0].value {
780 crate::parsing::ast::DataValue::Import(spec_ref) => {
781 assert_eq!(spec_ref.name, "somespec");
782 let repository_hdr = spec_ref
783 .repository
784 .as_ref()
785 .expect("expected repository qualifier");
786 assert_eq!(repository_hdr.name, "@user/workspace");
787 }
788 other => panic!("Expected Import, got: {:?}", other),
789 }
790 }
791
792 #[test]
793 fn parse_multiple_specs_cross_reference_in_file() {
794 let input = r#"spec spec_a
795data x: 10
796
797spec spec_b
798data y: 20
799uses a: spec_a"#;
800 let parsed = parse(
801 input,
802 crate::parsing::source::SourceType::Volatile,
803 &ResourceLimits::default(),
804 )
805 .unwrap();
806 let specs = parsed.flatten_specs();
807 assert_eq!(specs.len(), 2);
808 assert_eq!(specs[0].name, "spec_a");
809 assert_eq!(specs[1].name, "spec_b");
810 }
811
812 #[test]
813 fn parse_uses_registry_spec_default_alias() {
814 let input = "spec example\nuses @owner/repo somespec";
815 let specs = parse(
816 input,
817 crate::parsing::source::SourceType::Volatile,
818 &ResourceLimits::default(),
819 )
820 .unwrap()
821 .into_flattened_specs();
822 match &specs[0].data[0].value {
823 crate::parsing::ast::DataValue::Import(spec_ref) => {
824 assert_eq!(spec_ref.name, "somespec");
825 let repository_hdr = spec_ref
826 .repository
827 .as_ref()
828 .expect("expected repository qualifier");
829 assert_eq!(repository_hdr.name, "@owner/repo");
830 }
831 other => panic!("Expected Import, got: {:?}", other),
832 }
833 }
834
835 #[test]
836 fn parse_uses_local_spec_default_alias() {
837 let input = "spec example\nuses myspec";
838 let specs = parse(
839 input,
840 crate::parsing::source::SourceType::Volatile,
841 &ResourceLimits::default(),
842 )
843 .unwrap()
844 .into_flattened_specs();
845 match &specs[0].data[0].value {
846 crate::parsing::ast::DataValue::Import(spec_ref) => {
847 assert_eq!(spec_ref.name, "myspec");
848 assert!(
849 spec_ref.repository.is_none(),
850 "same-repository reference must omit repository qualifier"
851 );
852 }
853 other => panic!("Expected Import, got: {:?}", other),
854 }
855 }
856
857 #[test]
858 fn parse_spec_name_with_trailing_dot_is_error() {
859 let input = "spec myspec.\ndata x: 1";
860 let result = parse(
861 input,
862 crate::parsing::source::SourceType::Volatile,
863 &ResourceLimits::default(),
864 );
865 assert!(
866 result.is_err(),
867 "Trailing dot after spec name should be a parse error"
868 );
869 }
870
871 #[test]
872 fn parse_multiple_specs_in_same_file() {
873 let input = "spec myspec_a\nrule x: 1\n\nspec myspec_b\nrule x: 2";
874 let result = parse(
875 input,
876 crate::parsing::source::SourceType::Volatile,
877 &ResourceLimits::default(),
878 )
879 .unwrap()
880 .into_flattened_specs();
881 assert_eq!(result.len(), 2);
882 assert_eq!(result[0].name, "myspec_a");
883 assert_eq!(result[1].name, "myspec_b");
884 }
885
886 #[test]
887 fn parse_uses_accepts_name_only() {
888 let input = "spec consumer\nuses other";
889 let result = parse(
890 input,
891 crate::parsing::source::SourceType::Volatile,
892 &ResourceLimits::default(),
893 );
894 assert!(result.is_ok(), "uses name should parse");
895 let specs = result.unwrap().into_flattened_specs();
896 let spec_ref = match &specs[0].data[0].value {
897 crate::parsing::ast::DataValue::Import(r) => r,
898 _ => panic!("expected Import"),
899 };
900 assert_eq!(spec_ref.name, "other");
901 }
902
903 #[test]
904 fn parse_uses_bare_year_effective() {
905 let input = "spec consumer\nuses other 2026";
906 let result = parse(
907 input,
908 crate::parsing::source::SourceType::Volatile,
909 &ResourceLimits::default(),
910 )
911 .unwrap();
912 let specs = result.into_flattened_specs();
913 let spec_ref = match &specs[0].data[0].value {
914 crate::parsing::ast::DataValue::Import(r) => r,
915 _ => panic!("expected Import"),
916 };
917 assert_eq!(spec_ref.name, "other");
918 let eff = spec_ref.effective.as_ref().expect("effective");
919 assert_eq!(eff.year, 2026);
920 assert_eq!(eff.month, 1);
921 assert_eq!(eff.day, 1);
922 }
923
924 #[test]
925 fn parse_uses_registry_spec_ref_records_repository_and_target_spans() {
926 let input = "spec consumer\nuses @lemma/std finance 2026";
927 let result = parse(
928 input,
929 crate::parsing::source::SourceType::Volatile,
930 &ResourceLimits::default(),
931 )
932 .unwrap();
933 let spec = &result.flatten_specs()[0];
934 let sr = match &spec.data[0].value {
935 crate::parsing::ast::DataValue::Import(r) => r,
936 _ => panic!("expected Import"),
937 };
938 let rs = sr
939 .repository_span
940 .as_ref()
941 .expect("repository_span should be set for @-qualified uses");
942 let ts = sr
943 .target_span
944 .as_ref()
945 .expect("target_span should cover spec name and effective");
946 assert_eq!(&input[rs.start..rs.end], "@lemma/std");
947 assert_eq!(&input[ts.start..ts.end], "finance 2026");
948 }
949
950 #[test]
951 fn parse_uses_alias_no_comma_continuation() {
952 let input = "spec consumer\nuses alias: pricing retail\ndata x: 1";
953 let result = parse(
954 input,
955 crate::parsing::source::SourceType::Volatile,
956 &ResourceLimits::default(),
957 )
958 .unwrap();
959 let data = &result.flatten_specs()[0].data;
960 assert_eq!(data.len(), 2);
961 assert_eq!(data[0].reference.name, "alias");
962 let sr = match &data[0].value {
963 crate::parsing::ast::DataValue::Import(r) => r,
964 _ => panic!("expected Import"),
965 };
966 assert_eq!(sr.name, "retail");
967 let repository_hdr = sr
968 .repository
969 .as_ref()
970 .expect("expected repository qualifier");
971 assert_eq!(repository_hdr.name, "pricing");
972 }
973
974 #[test]
975 fn parse_data_qualified_type_with_effective_and_repository_on_uses() {
976 let input = "spec consumer\nuses @lemma/std finance 2026-06-01\ndata price: finance.number -> minimum 0";
977 let result = parse(
978 input,
979 crate::parsing::source::SourceType::Volatile,
980 &ResourceLimits::default(),
981 )
982 .unwrap()
983 .into_flattened_specs();
984 let spec_ref = match &result[0].data[0].value {
985 crate::parsing::ast::DataValue::Import(sr) => sr,
986 other => panic!("expected Import on uses row, got: {:?}", other),
987 };
988 assert_eq!(spec_ref.name, "finance");
989
990 let eff = spec_ref
991 .effective
992 .as_ref()
993 .expect("expected effective datetime");
994 assert_eq!(eff.year, 2026);
995 assert_eq!(eff.month, 6);
996
997 let qualifier = spec_ref
998 .repository
999 .as_ref()
1000 .expect("expected repository qualifier");
1001 assert_eq!(qualifier.name, "@lemma/std");
1002
1003 match &result[0].data[1].value {
1004 crate::parsing::ast::DataValue::Definition {
1005 base,
1006 constraints,
1007 value,
1008 } => {
1009 assert!(value.is_none());
1010 assert_eq!(
1011 base.as_ref().expect("expected base"),
1012 &crate::parsing::ast::ParentType::Qualified {
1013 spec_alias: "finance".into(),
1014 inner: Box::new(crate::parsing::ast::ParentType::Primitive {
1015 primitive: crate::parsing::ast::PrimitiveKind::Number,
1016 }),
1017 }
1018 );
1019
1020 let cs = constraints
1021 .as_ref()
1022 .expect("expected trailing constraint chain");
1023 assert_eq!(cs.len(), 1);
1024 }
1025 other => panic!("expected Definition, got: {:?}", other),
1026 }
1027 }
1028
1029 #[test]
1030 fn parse_error_is_returned_for_garbage_input() {
1031 let result = parse(
1032 r#"
1033spec test
1034this is not valid lemma syntax @#$%
1035"#,
1036 crate::parsing::source::SourceType::Volatile,
1037 &ResourceLimits::default(),
1038 );
1039
1040 assert!(result.is_err(), "Should fail on malformed input");
1041 match result {
1042 Err(Error::Parsing { .. }) => {
1043 }
1045 Err(e) => panic!("Expected Parse error, got: {e:?}"),
1046 Ok(_) => panic!("Expected parse error"),
1047 }
1048 }
1049
1050 #[test]
1053 fn parse_local_with_literal_rejected() {
1054 let err = parse(
1055 r#"spec s
1056with x: 42"#,
1057 crate::parsing::source::SourceType::Volatile,
1058 &ResourceLimits::default(),
1059 )
1060 .unwrap_err();
1061 let msg = err.to_string();
1062 assert!(
1063 msg.contains("imported spec") || msg.contains("alias.field"),
1064 "expected local with rejection, got: {msg}"
1065 );
1066 }
1067
1068 #[test]
1069 fn parse_local_with_import_reference_rejected() {
1070 let err = parse(
1071 r#"spec s
1072uses i: inner
1073with copy: i.v"#,
1074 crate::parsing::source::SourceType::Volatile,
1075 &ResourceLimits::default(),
1076 )
1077 .unwrap_err();
1078 let msg = err.to_string();
1079 assert!(
1080 msg.contains("imported spec") || msg.contains("alias.field"),
1081 "expected local with rejection, got: {msg}"
1082 );
1083 }
1084
1085 #[test]
1086 fn parse_local_with_dotted_rhs_rejected() {
1087 let err = parse(
1088 r#"spec s
1089with x: a.something"#,
1090 crate::parsing::source::SourceType::Volatile,
1091 &ResourceLimits::default(),
1092 )
1093 .unwrap_err();
1094 let msg = err.to_string();
1095 assert!(
1096 msg.contains("imported spec") || msg.contains("alias.field"),
1097 "expected local with rejection, got: {msg}"
1098 );
1099 }
1100
1101 #[test]
1102 fn parse_local_with_multi_segment_rhs_rejected() {
1103 let err = parse(
1104 r#"spec s
1105with x: alpha.beta.gamma.delta"#,
1106 crate::parsing::source::SourceType::Volatile,
1107 &ResourceLimits::default(),
1108 )
1109 .unwrap_err();
1110 let msg = err.to_string();
1111 assert!(
1112 msg.contains("imported spec") || msg.contains("alias.field"),
1113 "expected local with rejection, got: {msg}"
1114 );
1115 }
1116
1117 #[test]
1120 fn parse_local_non_dotted_rhs_stays_definition_with_custom_base() {
1121 let input = r#"spec s
1122data x: myothertype"#;
1123 let result = parse(
1124 input,
1125 crate::parsing::source::SourceType::Volatile,
1126 &ResourceLimits::default(),
1127 )
1128 .unwrap()
1129 .into_flattened_specs();
1130 let value = &result[0].data[0].value;
1131 assert!(
1132 matches!(
1133 value,
1134 crate::parsing::ast::DataValue::Definition {
1135 base: Some(crate::parsing::ast::ParentType::Custom { .. }),
1136 ..
1137 }
1138 ),
1139 "non-dotted local RHS must stay Definition with custom base, got: {:?}",
1140 value
1141 );
1142 }
1143
1144 #[test]
1146 fn parse_binding_non_dotted_rhs_is_with_reference() {
1147 let input = r#"spec s
1148with child.slot: somename"#;
1149 let result = parse(
1150 input,
1151 crate::parsing::source::SourceType::Volatile,
1152 &ResourceLimits::default(),
1153 )
1154 .unwrap()
1155 .into_flattened_specs();
1156 let value = &result[0].data[0].value;
1157 assert!(
1158 matches!(
1159 value,
1160 crate::parsing::ast::DataValue::With(
1161 crate::parsing::ast::WithRhs::Reference { .. }
1162 )
1163 ),
1164 "non-dotted RHS in binding context must yield With(Reference); got: {:?}",
1165 value
1166 );
1167 }
1168
1169 #[test]
1171 fn parse_data_colon_spec_rhs_is_rejected() {
1172 let result = parse(
1173 r#"
1174spec s
1175data x: spec other
1176"#,
1177 crate::parsing::source::SourceType::Volatile,
1178 &ResourceLimits::default(),
1179 );
1180 match result {
1181 Ok(_) => panic!("`data x: spec other` must fail to parse"),
1182 Err(err) => {
1183 let msg = err.to_string();
1184 assert!(
1185 msg.contains("uses") && msg.contains("spec"),
1186 "error must direct to `uses` for spec import, got: {msg}"
1187 );
1188 }
1189 }
1190 }
1191
1192 #[test]
1195 fn parse_binding_with_dotted_rhs_preserves_both_sides() {
1196 let input = r#"spec s
1197with outer.inner: target.field"#;
1198 let result = parse(
1199 input,
1200 crate::parsing::source::SourceType::Volatile,
1201 &ResourceLimits::default(),
1202 )
1203 .unwrap()
1204 .into_flattened_specs();
1205 let datum = &result[0].data[0];
1206 assert_eq!(datum.reference.segments, vec!["outer"]);
1207 assert_eq!(datum.reference.name, "inner");
1208 match &datum.value {
1209 crate::parsing::ast::DataValue::With(crate::parsing::ast::WithRhs::Reference {
1210 target,
1211 }) => {
1212 assert_eq!(target.segments, vec!["target"]);
1213 assert_eq!(target.name, "field");
1214 }
1215 other => panic!("expected With(Reference), got: {:?}", other),
1216 }
1217 }
1218
1219 #[test]
1220 fn parse_data_on_binding_path_is_rejected_with_with_hint() {
1221 let result = parse(
1222 r#"spec s
1223data outer.inner: 1"#,
1224 crate::parsing::source::SourceType::Volatile,
1225 &ResourceLimits::default(),
1226 );
1227 match result {
1228 Ok(_) => panic!("data with binding path must not parse"),
1229 Err(err) => {
1230 let msg = err.to_string();
1231 assert!(
1232 msg.contains("with"),
1233 "error should steer authors toward with; got: {msg}"
1234 );
1235 }
1236 }
1237 }
1238
1239 #[test]
1240 fn parse_bare_file_yields_single_anonymous_repository_group() {
1241 let input = "spec a\ndata x: 1\nspec b\ndata y: 2";
1242 let parsed = parse(
1243 input,
1244 crate::parsing::source::SourceType::Volatile,
1245 &ResourceLimits::default(),
1246 )
1247 .unwrap();
1248 assert_eq!(parsed.repositories.len(), 1);
1249 let (repo, specs) = parsed.repositories.iter().next().unwrap();
1250 assert!(repo.name.is_none());
1251 assert_eq!(specs.len(), 2);
1252 assert_eq!(specs[0].name, "a");
1253 assert_eq!(specs[1].name, "b");
1254 }
1255
1256 #[test]
1257 fn parse_repo_sections_preserve_order_and_names() {
1258 let input = r#"repo r1
1259
1260spec a
1261data x: 1
1262
1263repo r2
1264
1265spec b
1266data y: 2"#;
1267 let parsed = parse(
1268 input,
1269 crate::parsing::source::SourceType::Volatile,
1270 &ResourceLimits::default(),
1271 )
1272 .unwrap();
1273 assert_eq!(parsed.repositories.len(), 2);
1274 let keys: Vec<_> = parsed.repositories.keys().collect();
1275 assert_eq!(keys[0].name.as_deref(), Some("r1"));
1276 assert_eq!(keys[1].name.as_deref(), Some("r2"));
1277 }
1278
1279 #[test]
1280 fn parse_duplicate_repo_name_merges_spec_lists() {
1281 let input = r#"repo dup
1282
1283spec a
1284data x: 1
1285
1286repo dup
1287
1288spec b
1289data y: 2"#;
1290 let parsed = parse(
1291 input,
1292 crate::parsing::source::SourceType::Volatile,
1293 &ResourceLimits::default(),
1294 )
1295 .unwrap();
1296 assert_eq!(parsed.repositories.len(), 1);
1297 assert_eq!(parsed.flatten_specs().len(), 2);
1298 }
1299
1300 #[test]
1301 fn parse_repo_with_no_specs_then_eof_yields_empty_spec_vec_for_that_repo() {
1302 let input = "repo empty";
1303 let parsed = parse(
1304 input,
1305 crate::parsing::source::SourceType::Volatile,
1306 &ResourceLimits::default(),
1307 )
1308 .unwrap();
1309 assert_eq!(parsed.repositories.len(), 1);
1310 let (_repo, specs) = parsed.repositories.iter().next().unwrap();
1311 assert_eq!(specs.len(), 0);
1312 }
1313
1314 #[test]
1315 fn parse_repo_followed_by_repo_without_specs_first_repo_empty_second_has_spec() {
1316 let input = "repo a\n\nrepo b\n\nspec s\ndata x: 1";
1317 let parsed = parse(
1318 input,
1319 crate::parsing::source::SourceType::Volatile,
1320 &ResourceLimits::default(),
1321 )
1322 .unwrap();
1323 assert_eq!(parsed.repositories.len(), 2);
1324 let names: Vec<_> = parsed
1325 .repositories
1326 .keys()
1327 .map(|r| r.name.as_deref())
1328 .collect();
1329 assert_eq!(names, vec![Some("a"), Some("b")]);
1330 assert!(parsed.repositories.values().next().unwrap().is_empty());
1331 assert_eq!(parsed.repositories.values().nth(1).unwrap().len(), 1);
1332 }
1333
1334 #[test]
1335 fn parse_spec_named_repo_keyword_should_be_rejected() {
1336 assert!(
1337 parse(
1338 "spec repo\ndata x: 1",
1339 crate::parsing::source::SourceType::Volatile,
1340 &ResourceLimits::default(),
1341 )
1342 .is_err(),
1343 "spec must not be allowed to use reserved keyword `repo` as its name"
1344 );
1345 }
1346
1347 #[test]
1348 fn parse_repo_declaration_cannot_use_spec_keyword_as_repository_name() {
1349 assert!(
1350 parse(
1351 "repo spec\n\nspec z\ndata q: 1\nrule r: q",
1352 crate::parsing::source::SourceType::Volatile,
1353 &ResourceLimits::default(),
1354 )
1355 .is_err(),
1356 "repository name cannot be the token `spec`"
1357 );
1358 }
1359
1360 #[test]
1361 fn parse_repo_declaration_cannot_use_data_keyword_as_repository_name() {
1362 assert!(
1363 parse(
1364 "repo data\n\nspec z\ndata q: 1\nrule r: q",
1365 crate::parsing::source::SourceType::Volatile,
1366 &ResourceLimits::default(),
1367 )
1368 .is_err(),
1369 "repository name cannot be the token `data`"
1370 );
1371 }
1372
1373 #[test]
1374 fn parse_repo_declaration_cannot_use_rule_keyword_as_repository_name() {
1375 assert!(
1376 parse(
1377 "repo rule\n\nspec z\ndata q: 1\nrule r: q",
1378 crate::parsing::source::SourceType::Volatile,
1379 &ResourceLimits::default(),
1380 )
1381 .is_err(),
1382 "repository name cannot be the token `rule`"
1383 );
1384 }
1385
1386 #[test]
1387 fn parse_data_named_repo_keyword_is_rejected() {
1388 let err = parse(
1389 "spec s\ndata repo: 1",
1390 crate::parsing::source::SourceType::Volatile,
1391 &ResourceLimits::default(),
1392 )
1393 .unwrap_err();
1394 assert!(
1395 err.to_string().contains("repo"),
1396 "data named repo should not parse: {}",
1397 err
1398 );
1399 }
1400
1401 #[test]
1402 fn parse_rule_named_repo_keyword_is_rejected() {
1403 let err = parse(
1404 "spec s\ndata x: 1\nrule repo: x",
1405 crate::parsing::source::SourceType::Volatile,
1406 &ResourceLimits::default(),
1407 )
1408 .unwrap_err();
1409 let msg = err.to_string();
1410 assert!(
1411 msg.contains("repo") || msg.contains("reserved"),
1412 "rule named repo should not parse: {msg}"
1413 );
1414 }
1415
1416 #[test]
1417 fn parse_repo_declaration_accepts_non_keyword_repository_identifier() {
1418 let parsed = parse(
1419 "repo warehouse\n\nspec z\ndata q: 1\nrule r: q",
1420 crate::parsing::source::SourceType::Volatile,
1421 &ResourceLimits::default(),
1422 )
1423 .unwrap();
1424 assert_eq!(parsed.repositories.len(), 1);
1425 assert_eq!(
1426 parsed.repositories.keys().next().unwrap().name.as_deref(),
1427 Some("warehouse")
1428 );
1429 }
1430
1431 #[test]
1432 fn parse_repo_name_case_insensitive_same_repository_merged() {
1433 let input = "repo Foo\n\nspec a\ndata x: 1\n\nrepo foo\n\nspec b\ndata y: 2";
1434 let parsed = parse(
1435 input,
1436 crate::parsing::source::SourceType::Volatile,
1437 &ResourceLimits::default(),
1438 )
1439 .unwrap();
1440 assert_eq!(
1441 parsed.repositories.len(),
1442 1,
1443 "Foo and foo are the same repository after canonicalization"
1444 );
1445 let specs: Vec<_> = parsed.repositories.values().next().unwrap().clone();
1446 assert_eq!(specs.len(), 2);
1447 assert_eq!(specs[0].name, "a");
1448 assert_eq!(specs[1].name, "b");
1449 }
1450
1451 #[test]
1452 fn parse_repo_empty_name_errors() {
1453 let err = parse(
1454 "repo \nspec a\ndata x: 1",
1455 crate::parsing::source::SourceType::Volatile,
1456 &ResourceLimits::default(),
1457 )
1458 .unwrap_err();
1459 assert!(
1460 !err.to_string().is_empty(),
1461 "empty repo name should not parse quietly: {err}"
1462 );
1463 }
1464
1465 #[test]
1466 fn parse_repo_numeric_name_behavior() {
1467 let input = "repo 123\n\nspec a\ndata x: 1";
1468 let result = parse(
1469 input,
1470 crate::parsing::source::SourceType::Volatile,
1471 &ResourceLimits::default(),
1472 );
1473 match result {
1474 Ok(parsed) => {
1475 assert_eq!(
1476 parsed.repositories.keys().next().unwrap().name.as_deref(),
1477 Some("123"),
1478 "if numeric repo names parse, identity must be stable"
1479 );
1480 }
1481 Err(e) => {
1482 assert!(
1483 !e.to_string().is_empty(),
1484 "rejecting numeric repo name is ok if explicit: {e}"
1485 );
1486 }
1487 }
1488 }
1489
1490 #[test]
1491 fn parse_duplicate_repo_three_sections_preserves_spec_order_abc() {
1492 let input = r#"repo dup
1493
1494spec a
1495data x: 1
1496
1497repo dup
1498
1499spec b
1500data y: 2
1501
1502repo dup
1503
1504spec c
1505data z: 3"#;
1506 let parsed = parse(
1507 input,
1508 crate::parsing::source::SourceType::Volatile,
1509 &ResourceLimits::default(),
1510 )
1511 .unwrap();
1512 assert_eq!(parsed.repositories.len(), 1);
1513 let specs = parsed.repositories.values().next().unwrap();
1514 assert_eq!(
1515 specs.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
1516 vec!["a", "b", "c"]
1517 );
1518 }
1519
1520 #[test]
1521 fn parse_repo_single_section_roundtrips_through_formatter() {
1522 let input = "repo r\n\nspec a\ndata x: 1";
1523 let parsed = parse(
1524 input,
1525 crate::parsing::source::SourceType::Volatile,
1526 &ResourceLimits::default(),
1527 )
1528 .unwrap();
1529 let formatted = format_parse_result(&parsed);
1530 let again = parse(
1531 &formatted,
1532 crate::parsing::source::SourceType::Volatile,
1533 &ResourceLimits::default(),
1534 )
1535 .unwrap();
1536 assert_eq!(again.repositories.len(), parsed.repositories.len());
1537 assert_eq!(again.flatten_specs().len(), parsed.flatten_specs().len());
1538 assert_eq!(
1539 again.flatten_specs()[0].name,
1540 parsed.flatten_specs()[0].name
1541 );
1542 }
1543
1544 #[test]
1545 fn parse_repo_two_sections_roundtrips_through_formatter() {
1546 let input = "repo r1\n\nspec a\ndata x: 1\n\nrepo r2\n\nspec b\ndata y: 2";
1547 let parsed = parse(
1548 input,
1549 crate::parsing::source::SourceType::Volatile,
1550 &ResourceLimits::default(),
1551 )
1552 .unwrap();
1553 let formatted = format_parse_result(&parsed);
1554 let again = parse(
1555 &formatted,
1556 crate::parsing::source::SourceType::Volatile,
1557 &ResourceLimits::default(),
1558 )
1559 .unwrap();
1560 assert_eq!(again.repositories.len(), 2);
1561 assert_eq!(again.flatten_specs().len(), 2);
1562 }
1563
1564 #[test]
1565 fn parse_repo_duplicate_merge_formatter_emits_single_repo_block_or_equivalent_parse() {
1566 let input = r#"repo dup
1567
1568spec a
1569data x: 1
1570
1571repo dup
1572
1573spec b
1574data y: 2"#;
1575 let parsed = parse(
1576 input,
1577 crate::parsing::source::SourceType::Volatile,
1578 &ResourceLimits::default(),
1579 )
1580 .unwrap();
1581 let formatted = format_parse_result(&parsed);
1582 let again = parse(
1583 &formatted,
1584 crate::parsing::source::SourceType::Volatile,
1585 &ResourceLimits::default(),
1586 )
1587 .unwrap();
1588 assert_eq!(
1589 again.repositories.len(),
1590 1,
1591 "formatted duplicate-repo file must still merge to one logical repo"
1592 );
1593 assert_eq!(again.flatten_specs().len(), 2);
1594 }
1595}