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