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::{DepthTracker, Span};
10pub use source::Source;
11
12pub use ast::*;
13pub use parser::ParseResult;
14
15pub fn parse(
16 content: &str,
17 source_type: source::SourceType,
18 limits: &ResourceLimits,
19) -> Result<ParseResult, Error> {
20 parser::parse(content, source_type, limits)
21}
22
23#[cfg(test)]
28mod tests {
29 use super::parse;
30 use crate::formatting::format_parse_result;
31 use crate::Error;
32 use crate::ResourceLimits;
33
34 #[test]
35 fn parse_empty_input_returns_no_specs() {
36 let result = parse(
37 "",
38 crate::parsing::source::SourceType::Volatile,
39 &ResourceLimits::default(),
40 )
41 .unwrap()
42 .into_flattened_specs();
43 assert_eq!(result.len(), 0);
44 }
45
46 #[test]
47 fn parse_workspace_file_yields_expected_spec_datas_and_rules() {
48 let input = r#"spec person
49data name: "John Doe"
50rule adult: true"#;
51 let result = parse(
52 input,
53 crate::parsing::source::SourceType::Volatile,
54 &ResourceLimits::default(),
55 )
56 .unwrap()
57 .into_flattened_specs();
58 assert_eq!(result.len(), 1);
59 assert_eq!(result[0].name, "person");
60 assert_eq!(result[0].data.len(), 1);
61 assert_eq!(result[0].rules.len(), 1);
62 assert_eq!(result[0].rules[0].name, "adult");
63 }
64
65 #[test]
66 fn mixing_data_and_rules_is_collected_into_spec() {
67 let input = r#"spec test
68data name: "John"
69rule is_adult: age >= 18
70data age: 25
71rule can_drink: age >= 21
72data status: "active"
73rule is_eligible: is_adult and status is "active""#;
74
75 let result = parse(
76 input,
77 crate::parsing::source::SourceType::Volatile,
78 &ResourceLimits::default(),
79 )
80 .unwrap()
81 .into_flattened_specs();
82 assert_eq!(result.len(), 1);
83 assert_eq!(result[0].data.len(), 3);
84 assert_eq!(result[0].rules.len(), 3);
85 }
86
87 #[test]
88 fn parse_simple_spec_collects_data() {
89 let input = r#"spec person
90data name: "John"
91data age: 25"#;
92 let result = parse(
93 input,
94 crate::parsing::source::SourceType::Volatile,
95 &ResourceLimits::default(),
96 )
97 .unwrap()
98 .into_flattened_specs();
99 assert_eq!(result.len(), 1);
100 assert_eq!(result[0].name, "person");
101 assert_eq!(result[0].data.len(), 2);
102 }
103
104 #[test]
105 fn parse_dotted_spec_name() {
106 let input = r#"spec contracts.employment.jack
107data name: "Jack""#;
108 let result = parse(
109 input,
110 crate::parsing::source::SourceType::Volatile,
111 &ResourceLimits::default(),
112 )
113 .unwrap()
114 .into_flattened_specs();
115 assert_eq!(result.len(), 1);
116 assert_eq!(result[0].name, "contracts.employment.jack");
117 }
118
119 #[test]
120 fn parse_slashed_spec_name() {
121 let input = "spec contracts/employment/jack\ndata x: 1";
122 let result = parse(
123 input,
124 crate::parsing::source::SourceType::Volatile,
125 &ResourceLimits::default(),
126 )
127 .unwrap()
128 .into_flattened_specs();
129 assert_eq!(result.len(), 1);
130 assert_eq!(result[0].name, "contracts/employment/jack");
131 }
132
133 #[test]
134 fn parse_spec_name_no_version_tag() {
135 let input = "spec myspec\nrule x: 1";
136 let result = parse(
137 input,
138 crate::parsing::source::SourceType::Volatile,
139 &ResourceLimits::default(),
140 )
141 .unwrap()
142 .into_flattened_specs();
143 assert_eq!(result.len(), 1);
144 assert_eq!(result[0].name, "myspec");
145 assert_eq!(result[0].effective_from(), None);
146 }
147
148 #[test]
149 fn parse_commentary_block_is_attached_to_spec() {
150 let input = r#"spec person
151"""
152This is a markdown comment
153uses **bold** text
154"""
155data name: "John""#;
156 let result = parse(
157 input,
158 crate::parsing::source::SourceType::Volatile,
159 &ResourceLimits::default(),
160 )
161 .unwrap()
162 .into_flattened_specs();
163 assert_eq!(result.len(), 1);
164 assert!(result[0].commentary.is_some());
165 assert!(result[0].commentary.as_ref().unwrap().contains("**bold**"));
166 }
167
168 #[test]
169 fn parse_spec_with_rule_collects_rule() {
170 let input = r#"spec person
171rule is_adult: age >= 18"#;
172 let result = parse(
173 input,
174 crate::parsing::source::SourceType::Volatile,
175 &ResourceLimits::default(),
176 )
177 .unwrap()
178 .into_flattened_specs();
179 assert_eq!(result.len(), 1);
180 assert_eq!(result[0].rules.len(), 1);
181 assert_eq!(result[0].rules[0].name, "is_adult");
182 }
183
184 #[test]
185 fn parse_multiple_specs_returns_all_specs() {
186 let input = r#"spec person
187data name: "John"
188
189spec company
190data name: "Acme Corp""#;
191 let result = parse(
192 input,
193 crate::parsing::source::SourceType::Volatile,
194 &ResourceLimits::default(),
195 )
196 .unwrap()
197 .into_flattened_specs();
198 assert_eq!(result.len(), 2);
199 assert_eq!(result[0].name, "person");
200 assert_eq!(result[1].name, "company");
201 }
202
203 #[test]
204 fn parse_allows_duplicate_data_names() {
205 let input = r#"spec person
206data name: "John"
207data name: "Jane""#;
208 let result = parse(
209 input,
210 crate::parsing::source::SourceType::Volatile,
211 &ResourceLimits::default(),
212 );
213 assert!(
214 result.is_ok(),
215 "Parser should succeed even with duplicate data"
216 );
217 }
218
219 #[test]
220 fn parse_allows_duplicate_rule_names() {
221 let input = r#"spec person
222rule is_adult: age >= 18
223rule is_adult: age >= 21"#;
224 let result = parse(
225 input,
226 crate::parsing::source::SourceType::Volatile,
227 &ResourceLimits::default(),
228 );
229 assert!(
230 result.is_ok(),
231 "Parser should succeed even with duplicate rules"
232 );
233 }
234
235 #[test]
236 fn parse_rejects_malformed_input() {
237 let input = "invalid syntax here";
238 let result = parse(
239 input,
240 crate::parsing::source::SourceType::Volatile,
241 &ResourceLimits::default(),
242 );
243 assert!(result.is_err());
244 }
245
246 #[test]
247 fn parse_handles_whitespace_variants_in_expressions() {
248 let test_cases = vec![
249 ("spec test\nrule test: 2+3", "no spaces in arithmetic"),
250 ("spec test\nrule test: age>=18", "no spaces in comparison"),
251 (
252 "spec test\nrule test: age >= 18 and salary>50000",
253 "spaces around and keyword",
254 ),
255 (
256 "spec test\nrule test: age >= 18 and salary > 50000",
257 "extra spaces",
258 ),
259 (
260 "spec test\nrule test: \n age >= 18 \n and \n salary > 50000",
261 "newlines in expression",
262 ),
263 ];
264
265 for (input, description) in test_cases {
266 let result = parse(
267 input,
268 crate::parsing::source::SourceType::Volatile,
269 &ResourceLimits::default(),
270 );
271 assert!(
272 result.is_ok(),
273 "Failed to parse {} ({}): {:?}",
274 input,
275 description,
276 result.err()
277 );
278 }
279 }
280
281 #[test]
282 fn parse_error_cases_are_rejected() {
283 let error_cases = vec![
284 (
285 "spec test\ndata name: \"unclosed string",
286 "unclosed string literal",
287 ),
288 ("spec test\nrule test: (2 + 3", "unclosed parenthesis"),
289 ("spec test\nrule test: 2 + 3)", "extra closing paren"),
290 ("spec test\ndata spec: 123", "reserved keyword as data name"),
291 (
292 "spec test\nrule rule: true",
293 "reserved keyword as rule name",
294 ),
295 ];
296
297 for (input, description) in error_cases {
298 let result = parse(
299 input,
300 crate::parsing::source::SourceType::Volatile,
301 &ResourceLimits::default(),
302 );
303 assert!(
304 result.is_err(),
305 "Expected error for {} but got success",
306 description
307 );
308 }
309 }
310
311 #[test]
312 fn parse_duration_literals_in_rules() {
313 let test_cases = vec![
314 ("2 years", "years"),
315 ("6 months", "months"),
316 ("52 weeks", "weeks"),
317 ("365 days", "days"),
318 ("24 hours", "hours"),
319 ("60 minutes", "minutes"),
320 ("3600 seconds", "seconds"),
321 ("1000 milliseconds", "milliseconds"),
322 ("500000 microseconds", "microseconds"),
323 ("50 percent", "percent"),
324 ];
325
326 for (expr, description) in test_cases {
327 let input = format!("spec test\nrule test: {}", expr);
328 let result = parse(
329 &input,
330 crate::parsing::source::SourceType::Volatile,
331 &ResourceLimits::default(),
332 );
333 assert!(
334 result.is_ok(),
335 "Failed to parse literal {} ({}): {:?}",
336 expr,
337 description,
338 result.err()
339 );
340 }
341 }
342
343 #[test]
344 fn parse_comparisons_with_duration_unit_conversions() {
345 let test_cases = vec![
346 (
347 "(duration in hours) > 2",
348 "duration conversion in comparison with parens",
349 ),
350 (
351 "(meeting_time in minutes) >= 30",
352 "duration conversion with gte",
353 ),
354 (
355 "(project_length in days) < 100",
356 "duration conversion with lt",
357 ),
358 (
359 "(delay in seconds) is 60",
360 "duration conversion with equality",
361 ),
362 (
363 "(1 hours) > (30 minutes)",
364 "duration conversions on both sides",
365 ),
366 (
367 "duration in hours > 2",
368 "duration conversion without parens",
369 ),
370 (
371 "meeting_time in seconds > 3600",
372 "variable duration conversion in comparison",
373 ),
374 (
375 "project_length in days > deadline_days",
376 "two variables with duration conversion",
377 ),
378 (
379 "duration in hours >= 1 and duration in hours <= 8",
380 "multiple duration comparisons",
381 ),
382 ];
383
384 for (expr, description) in test_cases {
385 let input = format!("spec test\nrule test: {}", expr);
386 let result = parse(
387 &input,
388 crate::parsing::source::SourceType::Volatile,
389 &ResourceLimits::default(),
390 );
391 assert!(
392 result.is_ok(),
393 "Failed to parse {} ({}): {:?}",
394 expr,
395 description,
396 result.err()
397 );
398 }
399 }
400
401 #[test]
402 fn parse_error_includes_attribute_and_parse_error_spec_name() {
403 let result = parse(
404 r#"
405spec test
406data name: "Unclosed string
407data age: 25
408"#,
409 crate::parsing::source::SourceType::Volatile,
410 &ResourceLimits::default(),
411 );
412
413 match result {
414 Err(Error::Parsing(details)) => {
415 let src = details
416 .source
417 .as_ref()
418 .expect("BUG: parsing errors always have source");
419 assert_eq!(
420 src.source_type,
421 crate::parsing::source::SourceType::Volatile
422 );
423 }
424 Err(e) => panic!("Expected Parse error, got: {e:?}"),
425 Ok(_) => panic!("Expected parse error for unclosed string"),
426 }
427 }
428
429 #[test]
430 fn parse_single_spec_file() {
431 let input = r#"spec somespec
432data name: "Alice""#;
433 let parsed = parse(
434 input,
435 crate::parsing::source::SourceType::Volatile,
436 &ResourceLimits::default(),
437 )
438 .unwrap();
439 let specs = parsed.flatten_specs();
440 assert_eq!(specs.len(), 1);
441 assert_eq!(specs[0].name, "somespec");
442 }
443
444 #[test]
445 fn parse_uses_registry_spec_explicit_alias() {
446 let input = r#"spec example
447uses external: @user/workspace somespec"#;
448 let specs = parse(
449 input,
450 crate::parsing::source::SourceType::Volatile,
451 &ResourceLimits::default(),
452 )
453 .unwrap()
454 .into_flattened_specs();
455 assert_eq!(specs.len(), 1);
456 assert_eq!(specs[0].data.len(), 1);
457 match &specs[0].data[0].value {
458 crate::parsing::ast::DataValue::Import(spec_ref) => {
459 assert_eq!(spec_ref.name, "somespec");
460 let repository_hdr = spec_ref
461 .repository
462 .as_ref()
463 .expect("expected repository qualifier");
464 assert_eq!(repository_hdr.name, "@user/workspace");
465 }
466 other => panic!("Expected Import, got: {:?}", other),
467 }
468 }
469
470 #[test]
471 fn parse_multiple_specs_cross_reference_in_file() {
472 let input = r#"spec spec_a
473data x: 10
474
475spec spec_b
476data y: 20
477uses a: spec_a"#;
478 let parsed = parse(
479 input,
480 crate::parsing::source::SourceType::Volatile,
481 &ResourceLimits::default(),
482 )
483 .unwrap();
484 let specs = parsed.flatten_specs();
485 assert_eq!(specs.len(), 2);
486 assert_eq!(specs[0].name, "spec_a");
487 assert_eq!(specs[1].name, "spec_b");
488 }
489
490 #[test]
491 fn parse_uses_registry_spec_default_alias() {
492 let input = "spec example\nuses @owner/repo somespec";
493 let specs = parse(
494 input,
495 crate::parsing::source::SourceType::Volatile,
496 &ResourceLimits::default(),
497 )
498 .unwrap()
499 .into_flattened_specs();
500 match &specs[0].data[0].value {
501 crate::parsing::ast::DataValue::Import(spec_ref) => {
502 assert_eq!(spec_ref.name, "somespec");
503 let repository_hdr = spec_ref
504 .repository
505 .as_ref()
506 .expect("expected repository qualifier");
507 assert_eq!(repository_hdr.name, "@owner/repo");
508 }
509 other => panic!("Expected Import, got: {:?}", other),
510 }
511 }
512
513 #[test]
514 fn parse_uses_local_spec_default_alias() {
515 let input = "spec example\nuses myspec";
516 let specs = parse(
517 input,
518 crate::parsing::source::SourceType::Volatile,
519 &ResourceLimits::default(),
520 )
521 .unwrap()
522 .into_flattened_specs();
523 match &specs[0].data[0].value {
524 crate::parsing::ast::DataValue::Import(spec_ref) => {
525 assert_eq!(spec_ref.name, "myspec");
526 assert!(
527 spec_ref.repository.is_none(),
528 "same-repository reference must omit repository qualifier"
529 );
530 }
531 other => panic!("Expected Import, got: {:?}", other),
532 }
533 }
534
535 #[test]
536 fn parse_spec_name_with_trailing_dot_is_error() {
537 let input = "spec myspec.\ndata x: 1";
538 let result = parse(
539 input,
540 crate::parsing::source::SourceType::Volatile,
541 &ResourceLimits::default(),
542 );
543 assert!(
544 result.is_err(),
545 "Trailing dot after spec name should be a parse error"
546 );
547 }
548
549 #[test]
550 fn parse_multiple_specs_in_same_file() {
551 let input = "spec myspec_a\nrule x: 1\n\nspec myspec_b\nrule x: 2";
552 let result = parse(
553 input,
554 crate::parsing::source::SourceType::Volatile,
555 &ResourceLimits::default(),
556 )
557 .unwrap()
558 .into_flattened_specs();
559 assert_eq!(result.len(), 2);
560 assert_eq!(result[0].name, "myspec_a");
561 assert_eq!(result[1].name, "myspec_b");
562 }
563
564 #[test]
565 fn parse_uses_accepts_name_only() {
566 let input = "spec consumer\nuses other";
567 let result = parse(
568 input,
569 crate::parsing::source::SourceType::Volatile,
570 &ResourceLimits::default(),
571 );
572 assert!(result.is_ok(), "uses name should parse");
573 let specs = result.unwrap().into_flattened_specs();
574 let spec_ref = match &specs[0].data[0].value {
575 crate::parsing::ast::DataValue::Import(r) => r,
576 _ => panic!("expected Import"),
577 };
578 assert_eq!(spec_ref.name, "other");
579 }
580
581 #[test]
582 fn parse_uses_bare_year_effective() {
583 let input = "spec consumer\nuses other 2026";
584 let result = parse(
585 input,
586 crate::parsing::source::SourceType::Volatile,
587 &ResourceLimits::default(),
588 )
589 .unwrap();
590 let specs = result.into_flattened_specs();
591 let spec_ref = match &specs[0].data[0].value {
592 crate::parsing::ast::DataValue::Import(r) => r,
593 _ => panic!("expected Import"),
594 };
595 assert_eq!(spec_ref.name, "other");
596 let eff = spec_ref.effective.as_ref().expect("effective");
597 assert_eq!(eff.year, 2026);
598 assert_eq!(eff.month, 1);
599 assert_eq!(eff.day, 1);
600 }
601
602 #[test]
603 fn parse_uses_comma_separated_bare() {
604 let input = "spec consumer\nuses a, b, c";
605 let result = parse(
606 input,
607 crate::parsing::source::SourceType::Volatile,
608 &ResourceLimits::default(),
609 )
610 .unwrap();
611 let data = &result.flatten_specs()[0].data;
612 assert_eq!(data.len(), 3);
613 for (i, expected) in ["a", "b", "c"].iter().enumerate() {
614 let sr = match &data[i].value {
615 crate::parsing::ast::DataValue::Import(r) => r,
616 _ => panic!("expected Import for item {i}"),
617 };
618 assert_eq!(sr.name, *expected);
619 assert_eq!(data[i].reference.name, *expected);
620 assert!(sr.effective.is_none());
621 }
622 }
623
624 #[test]
625 fn parse_uses_comma_separated_cross_repository() {
626 let input = "spec consumer\nuses pricing retail, pricing wholesale";
627 let result = parse(
628 input,
629 crate::parsing::source::SourceType::Volatile,
630 &ResourceLimits::default(),
631 )
632 .unwrap();
633 let data = &result.flatten_specs()[0].data;
634 assert_eq!(data.len(), 2);
635 let sr0 = match &data[0].value {
636 crate::parsing::ast::DataValue::Import(r) => r,
637 _ => panic!("expected Import"),
638 };
639 assert_eq!(sr0.name, "retail");
640 let repository_hdr0 = sr0
641 .repository
642 .as_ref()
643 .expect("expected repository qualifier");
644 assert_eq!(repository_hdr0.name, "pricing");
645 assert_eq!(data[0].reference.name, "retail");
646 let sr1 = match &data[1].value {
647 crate::parsing::ast::DataValue::Import(r) => r,
648 _ => panic!("expected Import"),
649 };
650 assert_eq!(sr1.name, "wholesale");
651 let repository_hdr1 = sr1
652 .repository
653 .as_ref()
654 .expect("expected repository qualifier");
655 assert_eq!(repository_hdr1.name, "pricing");
656 assert_eq!(data[1].reference.name, "wholesale");
657 }
658
659 #[test]
660 fn parse_uses_comma_separated_registry() {
661 let input = "spec consumer\nuses @org/repo spec_a, @org/repo spec_b";
662 let result = parse(
663 input,
664 crate::parsing::source::SourceType::Volatile,
665 &ResourceLimits::default(),
666 )
667 .unwrap();
668 let data = &result.flatten_specs()[0].data;
669 assert_eq!(data.len(), 2);
670 assert_eq!(data[0].reference.name, "spec_a");
671 assert_eq!(data[1].reference.name, "spec_b");
672 for sr in [&data[0].value, &data[1].value] {
673 let r = match sr {
674 crate::parsing::ast::DataValue::Import(r) => r,
675 _ => panic!("expected Import"),
676 };
677 let repository_hdr = r
678 .repository
679 .as_ref()
680 .expect("expected repository qualifier");
681 assert_eq!(repository_hdr.name, "@org/repo");
682 }
683 }
684
685 #[test]
686 fn parse_uses_registry_spec_ref_records_repository_and_target_spans() {
687 let input = "spec consumer\nuses @lemma/std finance 2026";
688 let result = parse(
689 input,
690 crate::parsing::source::SourceType::Volatile,
691 &ResourceLimits::default(),
692 )
693 .unwrap();
694 let spec = &result.flatten_specs()[0];
695 let sr = match &spec.data[0].value {
696 crate::parsing::ast::DataValue::Import(r) => r,
697 _ => panic!("expected Import"),
698 };
699 let rs = sr
700 .repository_span
701 .as_ref()
702 .expect("repository_span should be set for @-qualified uses");
703 let ts = sr
704 .target_span
705 .as_ref()
706 .expect("target_span should cover spec name and effective");
707 assert_eq!(&input[rs.start..rs.end], "@lemma/std");
708 assert_eq!(&input[ts.start..ts.end], "finance 2026");
709 }
710
711 #[test]
712 fn parse_uses_alias_no_comma_continuation() {
713 let input = "spec consumer\nuses alias: pricing retail\ndata x: 1";
714 let result = parse(
715 input,
716 crate::parsing::source::SourceType::Volatile,
717 &ResourceLimits::default(),
718 )
719 .unwrap();
720 let data = &result.flatten_specs()[0].data;
721 assert_eq!(data.len(), 2);
722 assert_eq!(data[0].reference.name, "alias");
723 let sr = match &data[0].value {
724 crate::parsing::ast::DataValue::Import(r) => r,
725 _ => panic!("expected Import"),
726 };
727 assert_eq!(sr.name, "retail");
728 let repository_hdr = sr
729 .repository
730 .as_ref()
731 .expect("expected repository qualifier");
732 assert_eq!(repository_hdr.name, "pricing");
733 }
734
735 #[test]
736 fn parse_data_import_with_effective_and_repository_qualifier() {
737 let input =
738 "spec consumer\ndata price: number from @lemma/std finance 2026-06-01 -> minimum 0";
739 let result = parse(
740 input,
741 crate::parsing::source::SourceType::Volatile,
742 &ResourceLimits::default(),
743 )
744 .unwrap()
745 .into_flattened_specs();
746 match &result[0].data[0].value {
747 crate::parsing::ast::DataValue::Definition {
748 base,
749 constraints,
750 from,
751 value,
752 } => {
753 assert!(value.is_none());
754 assert_eq!(
755 base.as_ref().expect("expected base"),
756 &crate::parsing::ast::ParentType::Primitive {
757 primitive: crate::parsing::ast::PrimitiveKind::Number
758 }
759 );
760 let spec_ref = from.as_ref().expect("expected from clause");
761 assert_eq!(spec_ref.name, "finance");
762
763 let eff = spec_ref
764 .effective
765 .as_ref()
766 .expect("expected effective datetime");
767 assert_eq!(eff.year, 2026);
768 assert_eq!(eff.month, 6);
769
770 let qualifier = spec_ref
771 .repository
772 .as_ref()
773 .expect("expected repository qualifier");
774 assert_eq!(qualifier.name, "@lemma/std");
775
776 let cs = constraints
777 .as_ref()
778 .expect("expected trailing constraint chain");
779 assert_eq!(cs.len(), 1);
780 }
781 other => panic!("expected Definition, got: {:?}", other),
782 }
783 }
784
785 #[test]
786 fn parse_error_is_returned_for_garbage_input() {
787 let result = parse(
788 r#"
789spec test
790this is not valid lemma syntax @#$%
791"#,
792 crate::parsing::source::SourceType::Volatile,
793 &ResourceLimits::default(),
794 );
795
796 assert!(result.is_err(), "Should fail on malformed input");
797 match result {
798 Err(Error::Parsing { .. }) => {
799 }
801 Err(e) => panic!("Expected Parse error, got: {e:?}"),
802 Ok(_) => panic!("Expected parse error"),
803 }
804 }
805
806 #[test]
811 fn parse_data_with_dotted_rhs_is_reference() {
812 let input = r#"spec s
813data a: number -> default 1
814data x: a.something"#;
815 let result = parse(
816 input,
817 crate::parsing::source::SourceType::Volatile,
818 &ResourceLimits::default(),
819 )
820 .unwrap()
821 .into_flattened_specs();
822 let x_value = &result[0]
823 .data
824 .iter()
825 .find(|d| d.reference.name == "x")
826 .expect("data x not found")
827 .value;
828 assert!(
829 matches!(x_value, crate::parsing::ast::DataValue::Reference { .. }),
830 "dotted RHS must yield DataValue::Reference, got: {:?}",
831 x_value
832 );
833 }
834
835 #[test]
837 fn parse_data_with_multi_segment_reference_rhs() {
838 let input = r#"spec s
839data x: alpha.beta.gamma.delta"#;
840 let result = parse(
841 input,
842 crate::parsing::source::SourceType::Volatile,
843 &ResourceLimits::default(),
844 )
845 .unwrap()
846 .into_flattened_specs();
847 let value = &result[0].data[0].value;
848 match value {
849 crate::parsing::ast::DataValue::Reference { target, .. } => {
850 assert_eq!(target.segments, vec!["alpha", "beta", "gamma"]);
851 assert_eq!(target.name, "delta");
852 }
853 other => panic!("expected Reference, got: {:?}", other),
854 }
855 }
856
857 #[test]
860 fn parse_reference_with_trailing_constraint_captures_constraints() {
861 let input = r#"spec s
862data x: foo.bar -> minimum 5"#;
863 let result = parse(
864 input,
865 crate::parsing::source::SourceType::Volatile,
866 &ResourceLimits::default(),
867 )
868 .unwrap()
869 .into_flattened_specs();
870 let value = &result[0].data[0].value;
871 match value {
872 crate::parsing::ast::DataValue::Reference { constraints, .. } => {
873 let c = constraints.as_ref().expect("constraints expected");
874 assert_eq!(c.len(), 1, "exactly one constraint expected, got: {:?}", c);
875 }
876 other => panic!("expected Reference, got: {:?}", other),
877 }
878 }
879
880 #[test]
883 fn parse_local_non_dotted_rhs_stays_definition_with_custom_base() {
884 let input = r#"spec s
885data x: myothertype"#;
886 let result = parse(
887 input,
888 crate::parsing::source::SourceType::Volatile,
889 &ResourceLimits::default(),
890 )
891 .unwrap()
892 .into_flattened_specs();
893 let value = &result[0].data[0].value;
894 assert!(
895 matches!(
896 value,
897 crate::parsing::ast::DataValue::Definition {
898 base: Some(crate::parsing::ast::ParentType::Custom { .. }),
899 ..
900 }
901 ),
902 "non-dotted local RHS must stay Definition with custom base, got: {:?}",
903 value
904 );
905 }
906
907 #[test]
911 fn parse_binding_non_dotted_rhs_is_reference() {
912 let input = r#"spec s
913data child.slot: somename"#;
914 let result = parse(
915 input,
916 crate::parsing::source::SourceType::Volatile,
917 &ResourceLimits::default(),
918 )
919 .unwrap()
920 .into_flattened_specs();
921 let value = &result[0].data[0].value;
922 assert!(
923 matches!(value, crate::parsing::ast::DataValue::Reference { .. }),
924 "non-dotted RHS in binding context must yield Reference; got: {:?}",
925 value
926 );
927 }
928
929 #[test]
931 fn parse_legacy_data_colon_spec_rhs_is_rejected() {
932 let result = parse(
933 r#"
934spec s
935data x: spec other
936"#,
937 crate::parsing::source::SourceType::Volatile,
938 &ResourceLimits::default(),
939 );
940 match result {
941 Ok(_) => panic!("legacy `data x: spec other` must fail to parse"),
942 Err(err) => {
943 let msg = err.to_string();
944 assert!(
945 msg.contains("spec") && (msg.contains("removed") || msg.contains("syntax")),
946 "error must indicate the legacy syntax was removed, got: {msg}"
947 );
948 }
949 }
950 }
951
952 #[test]
955 fn parse_binding_with_dotted_rhs_preserves_both_sides() {
956 let input = r#"spec s
957data outer.inner: target.field"#;
958 let result = parse(
959 input,
960 crate::parsing::source::SourceType::Volatile,
961 &ResourceLimits::default(),
962 )
963 .unwrap()
964 .into_flattened_specs();
965 let datum = &result[0].data[0];
966 assert_eq!(datum.reference.segments, vec!["outer"]);
967 assert_eq!(datum.reference.name, "inner");
968 match &datum.value {
969 crate::parsing::ast::DataValue::Reference {
970 target,
971 constraints,
972 ..
973 } => {
974 assert_eq!(target.segments, vec!["target"]);
975 assert_eq!(target.name, "field");
976 assert!(constraints.is_none(), "no trailing constraints expected");
977 }
978 other => panic!("expected Reference, got: {:?}", other),
979 }
980 }
981
982 #[test]
983 fn parse_bare_file_yields_single_anonymous_repository_group() {
984 let input = "spec a\ndata x: 1\nspec b\ndata y: 2";
985 let parsed = parse(
986 input,
987 crate::parsing::source::SourceType::Volatile,
988 &ResourceLimits::default(),
989 )
990 .unwrap();
991 assert_eq!(parsed.repositories.len(), 1);
992 let (repo, specs) = parsed.repositories.iter().next().unwrap();
993 assert!(repo.name.is_none());
994 assert_eq!(specs.len(), 2);
995 assert_eq!(specs[0].name, "a");
996 assert_eq!(specs[1].name, "b");
997 }
998
999 #[test]
1000 fn parse_repo_sections_preserve_order_and_names() {
1001 let input = r#"repo r1
1002
1003spec a
1004data x: 1
1005
1006repo r2
1007
1008spec b
1009data y: 2"#;
1010 let parsed = parse(
1011 input,
1012 crate::parsing::source::SourceType::Volatile,
1013 &ResourceLimits::default(),
1014 )
1015 .unwrap();
1016 assert_eq!(parsed.repositories.len(), 2);
1017 let keys: Vec<_> = parsed.repositories.keys().collect();
1018 assert_eq!(keys[0].name.as_deref(), Some("r1"));
1019 assert_eq!(keys[1].name.as_deref(), Some("r2"));
1020 }
1021
1022 #[test]
1023 fn parse_duplicate_repo_name_merges_spec_lists() {
1024 let input = r#"repo dup
1025
1026spec a
1027data x: 1
1028
1029repo dup
1030
1031spec b
1032data y: 2"#;
1033 let parsed = parse(
1034 input,
1035 crate::parsing::source::SourceType::Volatile,
1036 &ResourceLimits::default(),
1037 )
1038 .unwrap();
1039 assert_eq!(parsed.repositories.len(), 1);
1040 assert_eq!(parsed.flatten_specs().len(), 2);
1041 }
1042
1043 #[test]
1044 fn parse_repo_with_no_specs_then_eof_yields_empty_spec_vec_for_that_repo() {
1045 let input = "repo empty";
1046 let parsed = parse(
1047 input,
1048 crate::parsing::source::SourceType::Volatile,
1049 &ResourceLimits::default(),
1050 )
1051 .unwrap();
1052 assert_eq!(parsed.repositories.len(), 1);
1053 let (_repo, specs) = parsed.repositories.iter().next().unwrap();
1054 assert_eq!(specs.len(), 0);
1055 }
1056
1057 #[test]
1058 fn parse_repo_followed_by_repo_without_specs_first_repo_empty_second_has_spec() {
1059 let input = "repo a\n\nrepo b\n\nspec s\ndata x: 1";
1060 let parsed = parse(
1061 input,
1062 crate::parsing::source::SourceType::Volatile,
1063 &ResourceLimits::default(),
1064 )
1065 .unwrap();
1066 assert_eq!(parsed.repositories.len(), 2);
1067 let names: Vec<_> = parsed
1068 .repositories
1069 .keys()
1070 .map(|r| r.name.as_deref())
1071 .collect();
1072 assert_eq!(names, vec![Some("a"), Some("b")]);
1073 assert!(parsed.repositories.values().next().unwrap().is_empty());
1074 assert_eq!(parsed.repositories.values().nth(1).unwrap().len(), 1);
1075 }
1076
1077 #[test]
1078 fn parse_spec_named_repo_keyword_should_be_rejected() {
1079 assert!(
1080 parse(
1081 "spec repo\ndata x: 1",
1082 crate::parsing::source::SourceType::Volatile,
1083 &ResourceLimits::default(),
1084 )
1085 .is_err(),
1086 "spec must not be allowed to use reserved keyword `repo` as its name"
1087 );
1088 }
1089
1090 #[test]
1091 fn parse_repo_declaration_cannot_use_spec_keyword_as_repository_name() {
1092 assert!(
1093 parse(
1094 "repo spec\n\nspec z\ndata q: 1\nrule r: q",
1095 crate::parsing::source::SourceType::Volatile,
1096 &ResourceLimits::default(),
1097 )
1098 .is_err(),
1099 "repository name cannot be the token `spec`"
1100 );
1101 }
1102
1103 #[test]
1104 fn parse_repo_declaration_cannot_use_data_keyword_as_repository_name() {
1105 assert!(
1106 parse(
1107 "repo data\n\nspec z\ndata q: 1\nrule r: q",
1108 crate::parsing::source::SourceType::Volatile,
1109 &ResourceLimits::default(),
1110 )
1111 .is_err(),
1112 "repository name cannot be the token `data`"
1113 );
1114 }
1115
1116 #[test]
1117 fn parse_repo_declaration_cannot_use_rule_keyword_as_repository_name() {
1118 assert!(
1119 parse(
1120 "repo rule\n\nspec z\ndata q: 1\nrule r: q",
1121 crate::parsing::source::SourceType::Volatile,
1122 &ResourceLimits::default(),
1123 )
1124 .is_err(),
1125 "repository name cannot be the token `rule`"
1126 );
1127 }
1128
1129 #[test]
1130 fn parse_data_named_repo_keyword_is_rejected() {
1131 let err = parse(
1132 "spec s\ndata repo: 1",
1133 crate::parsing::source::SourceType::Volatile,
1134 &ResourceLimits::default(),
1135 )
1136 .unwrap_err();
1137 assert!(
1138 err.to_string().contains("repo"),
1139 "data named repo should not parse: {}",
1140 err
1141 );
1142 }
1143
1144 #[test]
1145 fn parse_rule_named_repo_keyword_is_rejected() {
1146 let err = parse(
1147 "spec s\ndata x: 1\nrule repo: x",
1148 crate::parsing::source::SourceType::Volatile,
1149 &ResourceLimits::default(),
1150 )
1151 .unwrap_err();
1152 let msg = err.to_string();
1153 assert!(
1154 msg.contains("repo") || msg.contains("reserved"),
1155 "rule named repo should not parse: {msg}"
1156 );
1157 }
1158
1159 #[test]
1160 fn parse_repo_declaration_accepts_non_keyword_repository_identifier() {
1161 let parsed = parse(
1162 "repo warehouse\n\nspec z\ndata q: 1\nrule r: q",
1163 crate::parsing::source::SourceType::Volatile,
1164 &ResourceLimits::default(),
1165 )
1166 .unwrap();
1167 assert_eq!(parsed.repositories.len(), 1);
1168 assert_eq!(
1169 parsed.repositories.keys().next().unwrap().name.as_deref(),
1170 Some("warehouse")
1171 );
1172 }
1173
1174 #[test]
1175 fn parse_repo_name_case_distinctness_two_repositories_not_merged() {
1176 let input = "repo Foo\n\nspec a\ndata x: 1\n\nrepo foo\n\nspec b\ndata y: 2";
1177 let parsed = parse(
1178 input,
1179 crate::parsing::source::SourceType::Volatile,
1180 &ResourceLimits::default(),
1181 )
1182 .unwrap();
1183 assert_eq!(
1184 parsed.repositories.len(),
1185 2,
1186 "Foo and foo must be distinct repository identities"
1187 );
1188 }
1189
1190 #[test]
1191 fn parse_repo_empty_name_errors() {
1192 let err = parse(
1193 "repo \nspec a\ndata x: 1",
1194 crate::parsing::source::SourceType::Volatile,
1195 &ResourceLimits::default(),
1196 )
1197 .unwrap_err();
1198 assert!(
1199 !err.to_string().is_empty(),
1200 "empty repo name should not parse quietly: {err}"
1201 );
1202 }
1203
1204 #[test]
1205 fn parse_repo_numeric_name_behavior() {
1206 let input = "repo 123\n\nspec a\ndata x: 1";
1207 let result = parse(
1208 input,
1209 crate::parsing::source::SourceType::Volatile,
1210 &ResourceLimits::default(),
1211 );
1212 match result {
1213 Ok(parsed) => {
1214 assert_eq!(
1215 parsed.repositories.keys().next().unwrap().name.as_deref(),
1216 Some("123"),
1217 "if numeric repo names parse, identity must be stable"
1218 );
1219 }
1220 Err(e) => {
1221 assert!(
1222 !e.to_string().is_empty(),
1223 "rejecting numeric repo name is ok if explicit: {e}"
1224 );
1225 }
1226 }
1227 }
1228
1229 #[test]
1230 fn parse_duplicate_repo_three_sections_preserves_spec_order_abc() {
1231 let input = r#"repo dup
1232
1233spec a
1234data x: 1
1235
1236repo dup
1237
1238spec b
1239data y: 2
1240
1241repo dup
1242
1243spec c
1244data z: 3"#;
1245 let parsed = parse(
1246 input,
1247 crate::parsing::source::SourceType::Volatile,
1248 &ResourceLimits::default(),
1249 )
1250 .unwrap();
1251 assert_eq!(parsed.repositories.len(), 1);
1252 let specs = parsed.repositories.values().next().unwrap();
1253 assert_eq!(
1254 specs.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
1255 vec!["a", "b", "c"]
1256 );
1257 }
1258
1259 #[test]
1260 fn parse_repo_single_section_roundtrips_through_formatter() {
1261 let input = "repo r\n\nspec a\ndata x: 1";
1262 let parsed = parse(
1263 input,
1264 crate::parsing::source::SourceType::Volatile,
1265 &ResourceLimits::default(),
1266 )
1267 .unwrap();
1268 let formatted = format_parse_result(&parsed);
1269 let again = parse(
1270 &formatted,
1271 crate::parsing::source::SourceType::Volatile,
1272 &ResourceLimits::default(),
1273 )
1274 .unwrap();
1275 assert_eq!(again.repositories.len(), parsed.repositories.len());
1276 assert_eq!(again.flatten_specs().len(), parsed.flatten_specs().len());
1277 assert_eq!(
1278 again.flatten_specs()[0].name,
1279 parsed.flatten_specs()[0].name
1280 );
1281 }
1282
1283 #[test]
1284 fn parse_repo_two_sections_roundtrips_through_formatter() {
1285 let input = "repo r1\n\nspec a\ndata x: 1\n\nrepo r2\n\nspec b\ndata y: 2";
1286 let parsed = parse(
1287 input,
1288 crate::parsing::source::SourceType::Volatile,
1289 &ResourceLimits::default(),
1290 )
1291 .unwrap();
1292 let formatted = format_parse_result(&parsed);
1293 let again = parse(
1294 &formatted,
1295 crate::parsing::source::SourceType::Volatile,
1296 &ResourceLimits::default(),
1297 )
1298 .unwrap();
1299 assert_eq!(again.repositories.len(), 2);
1300 assert_eq!(again.flatten_specs().len(), 2);
1301 }
1302
1303 #[test]
1304 fn parse_repo_duplicate_merge_formatter_emits_single_repo_block_or_equivalent_parse() {
1305 let input = r#"repo dup
1306
1307spec a
1308data x: 1
1309
1310repo dup
1311
1312spec b
1313data y: 2"#;
1314 let parsed = parse(
1315 input,
1316 crate::parsing::source::SourceType::Volatile,
1317 &ResourceLimits::default(),
1318 )
1319 .unwrap();
1320 let formatted = format_parse_result(&parsed);
1321 let again = parse(
1322 &formatted,
1323 crate::parsing::source::SourceType::Volatile,
1324 &ResourceLimits::default(),
1325 )
1326 .unwrap();
1327 assert_eq!(
1328 again.repositories.len(),
1329 1,
1330 "formatted duplicate-repo file must still merge to one logical repo"
1331 );
1332 assert_eq!(again.flatten_specs().len(), 2);
1333 }
1334}