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::*;
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// ============================================================================
21// Tests
22// ============================================================================
23
24#[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    /// Every token that may follow a completed rule / unless expression ending in `as`.
478    #[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                // Expected
1044            }
1045            Err(e) => panic!("Expected Parse error, got: {e:?}"),
1046            Ok(_) => panic!("Expected parse error"),
1047        }
1048    }
1049
1050    // ─── Parser-level pins for DataValue variants ────────────────────
1051
1052    #[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    /// `data x: notdotted` (local LHS, non-dotted RHS) MUST stay a
1118    /// Definition with an explicit custom parent — not silently reinterpreted as a Reference.
1119    #[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    /// `with x.y: notdotted` (binding LHS, non-dotted RHS) is parsed as [`DataValue::With`] with a reference payload.
1145    #[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    /// `data x: spec …` is invalid; spec imports use `uses`.
1170    #[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    /// `with x.y: z.w` (binding LHS, dotted RHS) → Reference with two LHS
1193    /// segments and two RHS segments.
1194    #[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}