Skip to main content

lemma/parsing/
mod.rs

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// ============================================================================
24// Tests
25// ============================================================================
26
27#[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                // Expected
800            }
801            Err(e) => panic!("Expected Parse error, got: {e:?}"),
802            Ok(_) => panic!("Expected parse error"),
803        }
804    }
805
806    // ─── Parser-level pins for DataValue variants ────────────────────
807
808    /// `data x: a.b` (local LHS, dotted RHS) must be parsed as Reference.
809    /// This is the value-copy reference form for local references.
810    #[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    /// `data x: a.b.c.d` (3+ segment RHS) must parse and preserve segments.
836    #[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    /// `data x: a.b -> minimum 5` must parse as Reference WITH the
858    /// trailing constraint chain captured in `constraints`.
859    #[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    /// `data x: notdotted` (local LHS, non-dotted RHS) MUST stay a
881    /// Definition with an explicit custom parent — not silently reinterpreted as a Reference.
882    #[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    /// `data x.y: notdotted` (binding LHS, non-dotted RHS) IS parsed as
908    /// Reference per the current implementation — even though the AST doc
909    /// comment claims otherwise. Pin the real behavior.
910    #[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    /// Legacy shorthand `data x: spec …` after `spec` keyword introduced; rejected.
930    #[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    /// `data x.y: z.w` (binding LHS, dotted RHS) → Reference with two LHS
953    /// segments and two RHS segments.
954    #[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}