Skip to main content

lemma/parsing/
mod.rs

1use crate::error::Error;
2use crate::limits::ResourceLimits;
3
4pub mod arena;
5pub mod ast;
6pub mod lexer;
7pub mod parser;
8pub mod source;
9
10pub use ast::*;
11pub use parser::{parse_repository_qualifier_str, ParseResult};
12
13pub fn parse(
14    content: &str,
15    source_type: source::SourceType,
16    limits: &ResourceLimits,
17) -> Result<ParseResult, Error> {
18    parser::parse(content, source_type, limits)
19}
20
21// ============================================================================
22// Tests
23// ============================================================================
24
25#[cfg(test)]
26mod tests {
27    use super::{
28        parse, ArithmeticComputation, CommandArg, Constraint, DataValue, Expression,
29        ExpressionKind, LemmaSpec, MathematicalComputation, RangeBound, Reference, RoundingMode,
30        TypeConstraintCommand, UnitArg, UnitFactor,
31    };
32    use crate::formatting::format_parse_result;
33    use crate::parsing::arena::ExprRef;
34    use crate::Error;
35    use crate::ResourceLimits;
36
37    /// `first op operand` for a one-step arithmetic chain.
38    fn binary_arithmetic(
39        node: ExprRef<'_, ExpressionKind>,
40    ) -> Option<(
41        ExprRef<'_, ExpressionKind>,
42        ArithmeticComputation,
43        ExprRef<'_, ExpressionKind>,
44    )> {
45        match node.kind() {
46            ExpressionKind::Arithmetic { first, steps } if steps.len() == 1 => Some((
47                node.child(*first),
48                steps[0].op.clone(),
49                node.child(steps[0].operand),
50            )),
51            _ => None,
52        }
53    }
54
55    #[test]
56    fn parse_empty_input_returns_no_specs() {
57        let result = parse(
58            "",
59            crate::parsing::source::SourceType::Volatile,
60            &ResourceLimits::default(),
61        )
62        .unwrap()
63        .into_flattened_specs();
64        assert_eq!(result.len(), 0);
65    }
66
67    #[test]
68    fn parse_workspace_file_yields_expected_spec_datas_and_rules() {
69        let input = r#"spec person
70data name: "John Doe"
71rule adult: true"#;
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].name, "person");
81        assert_eq!(result[0].data.len(), 1);
82        assert_eq!(result[0].rules.len(), 1);
83        assert_eq!(result[0].rules[0].name, "adult");
84    }
85
86    #[test]
87    fn mixing_data_and_rules_is_collected_into_spec() {
88        let input = r#"spec test
89data name: "John"
90rule is_adult: age >= 18
91data age: 25
92rule can_drink: age >= 21
93data status: "active"
94rule is_eligible: is_adult and status is "active""#;
95
96        let result = parse(
97            input,
98            crate::parsing::source::SourceType::Volatile,
99            &ResourceLimits::default(),
100        )
101        .unwrap()
102        .into_flattened_specs();
103        assert_eq!(result.len(), 1);
104        assert_eq!(result[0].data.len(), 3);
105        assert_eq!(result[0].rules.len(), 3);
106    }
107
108    #[test]
109    fn parse_simple_spec_collects_data() {
110        let input = r#"spec person
111data name: "John"
112data age: 25"#;
113        let result = parse(
114            input,
115            crate::parsing::source::SourceType::Volatile,
116            &ResourceLimits::default(),
117        )
118        .unwrap()
119        .into_flattened_specs();
120        assert_eq!(result.len(), 1);
121        assert_eq!(result[0].name, "person");
122        assert_eq!(result[0].data.len(), 2);
123    }
124
125    #[test]
126    fn parse_dotted_spec_name() {
127        let input = r#"spec contracts.employment.jack
128data name: "Jack""#;
129        let result = parse(
130            input,
131            crate::parsing::source::SourceType::Volatile,
132            &ResourceLimits::default(),
133        )
134        .unwrap()
135        .into_flattened_specs();
136        assert_eq!(result.len(), 1);
137        assert_eq!(result[0].name, "contracts.employment.jack");
138    }
139
140    #[test]
141    fn parse_slashed_spec_name() {
142        let input = "spec contracts/employment/jack\ndata x: 1";
143        let result = parse(
144            input,
145            crate::parsing::source::SourceType::Volatile,
146            &ResourceLimits::default(),
147        )
148        .unwrap()
149        .into_flattened_specs();
150        assert_eq!(result.len(), 1);
151        assert_eq!(result[0].name, "contracts/employment/jack");
152    }
153
154    #[test]
155    fn parse_spec_name_no_version_tag() {
156        let input = "spec myspec\nrule x: 1";
157        let result = parse(
158            input,
159            crate::parsing::source::SourceType::Volatile,
160            &ResourceLimits::default(),
161        )
162        .unwrap()
163        .into_flattened_specs();
164        assert_eq!(result.len(), 1);
165        assert_eq!(result[0].name, "myspec");
166        assert_eq!(result[0].effective_from(), None);
167    }
168
169    #[test]
170    fn parse_commentary_block_is_attached_to_spec() {
171        let input = r#"spec person
172"""
173This is a markdown comment
174uses **bold** text
175"""
176data name: "John""#;
177        let result = parse(
178            input,
179            crate::parsing::source::SourceType::Volatile,
180            &ResourceLimits::default(),
181        )
182        .unwrap()
183        .into_flattened_specs();
184        assert_eq!(result.len(), 1);
185        assert!(result[0].commentary.is_some());
186        assert!(result[0].commentary.as_ref().unwrap().contains("**bold**"));
187    }
188
189    #[test]
190    fn parse_spec_with_rule_collects_rule() {
191        let input = r#"spec person
192rule is_adult: age >= 18"#;
193        let result = parse(
194            input,
195            crate::parsing::source::SourceType::Volatile,
196            &ResourceLimits::default(),
197        )
198        .unwrap()
199        .into_flattened_specs();
200        assert_eq!(result.len(), 1);
201        assert_eq!(result[0].rules.len(), 1);
202        assert_eq!(result[0].rules[0].name, "is_adult");
203    }
204
205    #[test]
206    fn parse_multiple_specs_returns_all_specs() {
207        let input = r#"spec person
208data name: "John"
209
210spec company
211data name: "Acme Corp""#;
212        let result = parse(
213            input,
214            crate::parsing::source::SourceType::Volatile,
215            &ResourceLimits::default(),
216        )
217        .unwrap()
218        .into_flattened_specs();
219        assert_eq!(result.len(), 2);
220        assert_eq!(result[0].name, "person");
221        assert_eq!(result[1].name, "company");
222    }
223
224    #[test]
225    fn parse_allows_duplicate_data_names() {
226        let input = r#"spec person
227data name: "John"
228data name: "Jane""#;
229        let result = parse(
230            input,
231            crate::parsing::source::SourceType::Volatile,
232            &ResourceLimits::default(),
233        );
234        assert!(
235            result.is_ok(),
236            "Parser should succeed even with duplicate data"
237        );
238    }
239
240    #[test]
241    fn parse_allows_duplicate_rule_names() {
242        let input = r#"spec person
243rule is_adult: age >= 18
244rule is_adult: age >= 21"#;
245        let result = parse(
246            input,
247            crate::parsing::source::SourceType::Volatile,
248            &ResourceLimits::default(),
249        );
250        assert!(
251            result.is_ok(),
252            "Parser should succeed even with duplicate rules"
253        );
254    }
255
256    #[test]
257    fn parse_rejects_malformed_input() {
258        let input = "invalid syntax here";
259        let result = parse(
260            input,
261            crate::parsing::source::SourceType::Volatile,
262            &ResourceLimits::default(),
263        );
264        assert!(result.is_err());
265    }
266
267    #[test]
268    fn parse_handles_whitespace_variants_in_expressions() {
269        let test_cases = vec![
270            ("spec test\nrule test: 2+3", "no spaces in arithmetic"),
271            ("spec test\nrule test: age>=18", "no spaces in comparison"),
272            (
273                "spec test\nrule test: age >= 18 and salary>50000",
274                "spaces around and keyword",
275            ),
276            (
277                "spec test\nrule test: age  >=  18  and  salary  >  50000",
278                "extra spaces",
279            ),
280            (
281                "spec test\nrule test: \n  age >= 18 \n  and \n  salary > 50000",
282                "newlines in expression",
283            ),
284        ];
285
286        for (input, description) in test_cases {
287            let result = parse(
288                input,
289                crate::parsing::source::SourceType::Volatile,
290                &ResourceLimits::default(),
291            );
292            assert!(
293                result.is_ok(),
294                "Failed to parse {} ({}): {:?}",
295                input,
296                description,
297                result.err()
298            );
299        }
300    }
301
302    #[test]
303    fn parse_error_cases_are_rejected() {
304        let error_cases = vec![
305            (
306                "spec test\ndata name: \"unclosed string",
307                "unclosed string literal",
308            ),
309            ("spec test\nrule test: (2 + 3", "unclosed parenthesis"),
310            ("spec test\nrule test: 2 + 3)", "extra closing paren"),
311            ("spec test\ndata spec: 123", "reserved keyword as data name"),
312            (
313                "spec test\nrule rule: true",
314                "reserved keyword as rule name",
315            ),
316        ];
317
318        for (input, description) in error_cases {
319            let result = parse(
320                input,
321                crate::parsing::source::SourceType::Volatile,
322                &ResourceLimits::default(),
323            );
324            assert!(
325                result.is_err(),
326                "Expected error for {} but got success",
327                description
328            );
329        }
330    }
331
332    #[test]
333    fn parse_duration_literals_in_rules() {
334        let test_cases = vec![
335            ("2 year", "year"),
336            ("6 month", "month"),
337            ("52 week", "week"),
338            ("365 day", "day"),
339            ("24 hour", "hour"),
340            ("60 minute", "minute"),
341            ("3600 second", "second"),
342            ("1000 millisecond", "millisecond"),
343            ("500000 microsecond", "microsecond"),
344            ("50 percent", "percent"),
345        ];
346
347        for (expr, description) in test_cases {
348            let input = format!("spec test\nrule test: {}", expr);
349            let result = parse(
350                &input,
351                crate::parsing::source::SourceType::Volatile,
352                &ResourceLimits::default(),
353            );
354            assert!(
355                result.is_ok(),
356                "Failed to parse literal {} ({}): {:?}",
357                expr,
358                description,
359                result.err()
360            );
361        }
362    }
363
364    #[test]
365    fn parse_comparisons_with_duration_unit_conversions() {
366        let test_cases = vec![
367            (
368                "(duration as hour) > 2",
369                "duration conversion in comparison with parens",
370            ),
371            (
372                "(meeting_time as minute) >= 30",
373                "duration conversion with gte",
374            ),
375            (
376                "(project_length as day) < 100",
377                "duration conversion with lt",
378            ),
379            (
380                "(delay as second) is 60",
381                "duration conversion with equality",
382            ),
383            (
384                "(1 hour) > (30 minute)",
385                "duration conversions on both sides",
386            ),
387            ("duration as hour > 2", "duration conversion without parens"),
388            (
389                "meeting_time as second > 3600",
390                "variable duration conversion in comparison",
391            ),
392            (
393                "project_length as day > deadline_days",
394                "two variables with duration conversion",
395            ),
396            (
397                "duration as hour >= 1 and duration as hour <= 8",
398                "multiple duration comparisons",
399            ),
400            (
401                "(2024-06-01...2024-06-15) as day as number >= 7",
402                "chained as conversion before comparison",
403            ),
404            ("duration as hour as number > 2", "chained as on duration"),
405        ];
406
407        for (expr, description) in test_cases {
408            let input = format!("spec test\nrule test: {}", expr);
409            let result = parse(
410                &input,
411                crate::parsing::source::SourceType::Volatile,
412                &ResourceLimits::default(),
413            );
414            assert!(
415                result.is_ok(),
416                "Failed to parse {} ({}): {:?}",
417                expr,
418                description,
419                result.err()
420            );
421        }
422    }
423
424    #[test]
425    fn parse_rejects_token_after_unit_conversion() {
426        let result = parse(
427            "spec test\nuses lemma units\nrule ok: (2024-06-01...2024-06-15) as day foo",
428            crate::parsing::source::SourceType::Volatile,
429            &ResourceLimits::default(),
430        );
431        let err = result.expect_err("expected parse error");
432        let msg = err.to_string();
433        assert!(
434            msg.contains("Unexpected token") && msg.contains("foo"),
435            "expected error at 'foo', got: {}",
436            msg
437        );
438        assert!(
439            !msg.contains("Expected 'data'"),
440            "should not defer to spec-level error, got: {}",
441            msg
442        );
443    }
444
445    #[test]
446    fn parse_unit_conversion_before_next_spec() {
447        let result = parse(
448            r#"spec pricing
449rule hourly_rate: 150 eur
450  unless loyalty is "silver" then 140 eur
451  unless loyalty is "gold" then 125 usd as eur
452
453spec other
454rule x: 1"#,
455            crate::parsing::source::SourceType::Volatile,
456            &ResourceLimits::default(),
457        );
458        assert!(
459            result.is_ok(),
460            "unless branch ending with 'as' must parse before next spec: {:?}",
461            result.err()
462        );
463    }
464
465    #[test]
466    fn parse_unit_conversion_before_sibling_rule() {
467        let result = parse(
468            r#"spec s
469rule a: 100 usd as eur
470rule b: 1"#,
471            crate::parsing::source::SourceType::Volatile,
472            &ResourceLimits::default(),
473        );
474        assert!(
475            result.is_ok(),
476            "rule ending with 'as' must parse before sibling rule: {:?}",
477            result.err()
478        );
479    }
480
481    #[test]
482    fn parse_unit_conversion_before_uses() {
483        let result = parse(
484            r#"spec s
485rule rate: 10 usd as eur
486uses lemma units
487rule hour: 1 hour"#,
488            crate::parsing::source::SourceType::Volatile,
489            &ResourceLimits::default(),
490        );
491        assert!(
492            result.is_ok(),
493            "rule ending with 'as' must parse before uses: {:?}",
494            result.err()
495        );
496    }
497
498    /// Every token that may follow a completed rule / unless expression ending in `as`.
499    #[test]
500    fn parse_unit_conversion_before_expression_boundaries() {
501        let cases: &[(&str, &str)] = &[
502            (
503                "sibling data",
504                r#"spec s
505rule rate: 10 usd as eur
506data price: 100 eur"#,
507            ),
508            (
509                "sibling uses",
510                r#"spec s
511rule rate: 10 usd as eur
512uses other"#,
513            ),
514            (
515                "sibling meta",
516                r#"spec s
517rule rate: 10 usd as eur
518meta version: 1"#,
519            ),
520            (
521                "another unless",
522                r#"spec s
523rule rate: 10 usd
524  unless active then 5 usd as eur
525  unless premium then 3 usd as eur"#,
526            ),
527            (
528                "eof",
529                r#"spec s
530rule rate: 10 usd as eur"#,
531            ),
532            (
533                "next repo",
534                r#"spec s
535rule rate: 10 usd as eur
536
537repo other
538spec t
539rule x: 1"#,
540            ),
541            (
542                "unless then before next unless",
543                r#"spec s
544rule rate: 10 usd
545  unless a then 1 usd as eur
546  unless b then 2"#,
547            ),
548            (
549                "chained as before sibling rule",
550                r#"spec s
551rule rate: (2024-01-01...2024-01-02) as day as number
552rule other: 1"#,
553            ),
554        ];
555
556        for (label, source) in cases {
557            let result = parse(
558                source,
559                crate::parsing::source::SourceType::Volatile,
560                &ResourceLimits::default(),
561            );
562            assert!(
563                result.is_ok(),
564                "unit conversion before {label} must parse: {:?}",
565                result.err()
566            );
567        }
568    }
569
570    #[test]
571    fn parse_rejects_plain_number_plus_converted_operand() {
572        let result = parse(
573            r#"spec test
574data c: measure
575  -> unit eur: 1
576  -> unit usd: 0.84
577rule z: 5 + c as usd"#,
578            crate::parsing::source::SourceType::Volatile,
579            &ResourceLimits::default(),
580        );
581        let err = result.expect_err("expected parse error for 5 + c as usd");
582        let msg = err.to_string();
583        assert!(
584            msg.contains("plain number") || msg.contains("each operand"),
585            "expected conversion-before-+ error, got: {msg}"
586        );
587    }
588
589    #[test]
590    fn parse_accepts_conversion_on_each_additive_operand() {
591        let cases: &[(&str, &str)] = &[
592            (
593                "money",
594                r#"spec test
595data c: measure
596  -> unit eur: 1
597  -> unit usd: 0.84
598rule z: 5 as usd + c as usd"#,
599            ),
600            (
601                "duration + literal",
602                r#"spec test
603uses lemma units
604rule z: duration as hour + 1"#,
605            ),
606            (
607                "duration + comparison",
608                r#"spec test
609uses lemma units
610data duration: units.duration
611  -> suggest 1 hour
612rule z: duration as hour + 1 > 0"#,
613            ),
614            (
615                "date range + ref",
616                r#"spec test
617uses lemma units
618data age: date range
619data c: measure
620  -> unit eur: 1
621rule z: age as day + c"#,
622            ),
623        ];
624        for (label, source) in cases {
625            let result = parse(
626                source,
627                crate::parsing::source::SourceType::Volatile,
628                &ResourceLimits::default(),
629            );
630            assert!(
631                result.is_ok(),
632                "expected {label} to parse, got: {:?}",
633                result.err()
634            );
635        }
636    }
637
638    fn decimal(value: &str) -> rust_decimal::Decimal {
639        value.parse().expect("decimal literal in test")
640    }
641
642    fn parse_single_spec(source: &str) -> LemmaSpec {
643        let specs = parse(
644            source,
645            crate::parsing::source::SourceType::Volatile,
646            &ResourceLimits::default(),
647        )
648        .expect("spec must parse")
649        .into_flattened_specs();
650        assert_eq!(specs.len(), 1, "expected one spec, got {}", specs.len());
651        specs[0].clone()
652    }
653
654    fn measure_unit_constraints(spec: &LemmaSpec) -> Vec<Constraint> {
655        let mut unit_rows = Vec::new();
656        for data in &spec.data {
657            let DataValue::Definition { constraints, .. } = &data.value else {
658                continue;
659            };
660            if let Some(rows) = constraints {
661                for row in rows {
662                    if row.command == TypeConstraintCommand::Unit {
663                        unit_rows.push(row.clone());
664                    }
665                }
666            }
667        }
668        unit_rows
669    }
670
671    fn unit_name_and_arg(row: &Constraint) -> (String, UnitArg) {
672        let args = &row.args;
673        let name = match args.first() {
674            Some(CommandArg::Label(label)) => label.clone(),
675            other => panic!("unit constraint needs label name, got: {:?}", other),
676        };
677        let unit_arg = match args.get(1) {
678            Some(CommandArg::UnitExpr(unit_arg)) => unit_arg.clone(),
679            other => panic!("unit constraint needs UnitExpr payload, got: {:?}", other),
680        };
681        (name, unit_arg)
682    }
683
684    // ─── Parse: canonical colon unit form ──────────────────────────────────
685
686    #[test]
687    fn parse_unit_colon_factor_payload() {
688        let spec = parse_single_spec(
689            r#"spec money_spec
690data money: measure
691  -> unit eur: 1.00
692"#,
693        );
694        let rows = measure_unit_constraints(&spec);
695        assert_eq!(rows.len(), 1);
696        let (name, unit_arg) = unit_name_and_arg(&rows[0]);
697        assert_eq!(name, "eur");
698        assert_eq!(unit_arg, UnitArg::Factor(decimal("1.00")));
699        assert!(!rows[0].deprecated_without_colon);
700    }
701
702    #[test]
703    fn parse_unit_colon_compound_only_payload() {
704        let spec = parse_single_spec(
705            r#"spec rates
706uses lemma units
707
708data money: measure
709  -> unit eur: 1.00
710
711data rate: measure
712  -> unit eur_per_hour: eur/hour
713"#,
714        );
715        let rows = measure_unit_constraints(&spec);
716        assert_eq!(rows.len(), 2);
717        let (name, unit_arg) = unit_name_and_arg(&rows[1]);
718        assert_eq!(name, "eur_per_hour");
719        assert_eq!(
720            unit_arg,
721            UnitArg::Expr(
722                rust_decimal::Decimal::ONE,
723                vec![
724                    UnitFactor {
725                        measure_ref: "eur".to_string(),
726                        exp: 1,
727                    },
728                    UnitFactor {
729                        measure_ref: "hour".to_string(),
730                        exp: -1,
731                    }
732                ],
733            )
734        );
735        assert!(!rows[1].deprecated_without_colon);
736    }
737
738    #[test]
739    fn parse_unit_colon_numeric_prefix_and_compound_payload() {
740        let spec = parse_single_spec(
741            r#"spec speed
742uses lemma units
743
744data velocity: measure
745  -> unit kmh: 3.6 meter/second
746"#,
747        );
748        let rows = measure_unit_constraints(&spec);
749        assert_eq!(rows.len(), 1);
750        let (name, unit_arg) = unit_name_and_arg(&rows[0]);
751        assert_eq!(name, "kmh");
752        assert_eq!(
753            unit_arg,
754            UnitArg::Expr(
755                decimal("3.6"),
756                vec![
757                    UnitFactor {
758                        measure_ref: "meter".to_string(),
759                        exp: 1,
760                    },
761                    UnitFactor {
762                        measure_ref: "second".to_string(),
763                        exp: -1,
764                    }
765                ],
766            )
767        );
768        assert!(!rows[0].deprecated_without_colon);
769    }
770
771    // ─── Parse: dynamic scale reference payload ────────────────────────────
772
773    #[test]
774    fn parse_unit_colon_reference_payload() {
775        let spec = parse_single_spec(
776            r#"spec fx
777data rate: number
778data money: measure
779  -> unit gbp: rate
780"#,
781        );
782        let rows = measure_unit_constraints(&spec);
783        assert_eq!(rows.len(), 1);
784        let (name, unit_arg) = unit_name_and_arg(&rows[0]);
785        assert_eq!(name, "gbp");
786        assert_eq!(
787            unit_arg,
788            UnitArg::Reference(Reference::local("rate".to_string()))
789        );
790        assert!(!rows[0].deprecated_without_colon);
791    }
792
793    #[test]
794    fn parse_unit_colon_dotted_reference_payload() {
795        let specs = parse(
796            r#"spec rate_src
797data rate: number
798spec consumer
799uses rs: rate_src
800data money: measure
801  -> unit gbp: rs.rate
802"#,
803            crate::parsing::source::SourceType::Volatile,
804            &ResourceLimits::default(),
805        )
806        .expect("spec must parse")
807        .into_flattened_specs();
808        let consumer = specs
809            .iter()
810            .find(|s| s.name == "consumer")
811            .expect("consumer spec");
812        let rows = measure_unit_constraints(consumer);
813        assert_eq!(rows.len(), 1);
814        let (name, unit_arg) = unit_name_and_arg(&rows[0]);
815        assert_eq!(name, "gbp");
816        assert_eq!(
817            unit_arg,
818            UnitArg::Reference(Reference {
819                segments: vec!["rs".to_string()],
820                name: "rate".to_string(),
821            })
822        );
823    }
824
825    #[test]
826    fn parse_unit_colon_reference_display_roundtrips() {
827        let local = UnitArg::Reference(Reference::local("rate".to_string()));
828        assert_eq!(format!("{local}"), "rate");
829        let dotted = UnitArg::Reference(Reference {
830            segments: vec!["rs".to_string()],
831            name: "rate".to_string(),
832        });
833        assert_eq!(format!("{dotted}"), "rs.rate");
834    }
835
836    #[test]
837    fn parse_unit_colon_compound_still_parses_after_reference_support() {
838        // A bare identifier followed by `/` is a compound unit expression, not a
839        // reference. This guards against the reference branch swallowing the
840        // first factor of a compound expression.
841        let spec = parse_single_spec(
842            r#"spec rates
843uses lemma units
844
845data money: measure
846  -> unit eur_per_hour: eur/hour
847"#,
848        );
849        let rows = measure_unit_constraints(&spec);
850        assert_eq!(rows.len(), 1);
851        let (name, unit_arg) = unit_name_and_arg(&rows[0]);
852        assert_eq!(name, "eur_per_hour");
853        assert_eq!(
854            unit_arg,
855            UnitArg::Expr(
856                rust_decimal::Decimal::ONE,
857                vec![
858                    UnitFactor {
859                        measure_ref: "eur".to_string(),
860                        exp: 1,
861                    },
862                    UnitFactor {
863                        measure_ref: "hour".to_string(),
864                        exp: -1,
865                    }
866                ],
867            )
868        );
869    }
870
871    // ─── Parse: deprecated space-separated unit form ───────────────────────
872
873    #[test]
874    fn parse_unit_deprecated_space_factor_payload() {
875        let spec = parse_single_spec(
876            r#"spec money_spec
877data money: measure
878  -> unit eur 1.00
879"#,
880        );
881        let rows = measure_unit_constraints(&spec);
882        assert_eq!(rows.len(), 1);
883        let (name, unit_arg) = unit_name_and_arg(&rows[0]);
884        assert_eq!(name, "eur");
885        assert_eq!(unit_arg, UnitArg::Factor(decimal("1.00")));
886        assert!(rows[0].deprecated_without_colon);
887    }
888
889    #[test]
890    fn parse_unit_deprecated_space_compound_only_payload() {
891        let spec = parse_single_spec(
892            r#"spec rates
893uses lemma units
894
895data money: measure
896  -> unit eur 1.00
897
898data rate: measure
899  -> unit eur_per_hour eur/hour
900"#,
901        );
902        let rows = measure_unit_constraints(&spec);
903        assert_eq!(rows.len(), 2);
904        let (name, unit_arg) = unit_name_and_arg(&rows[1]);
905        assert_eq!(name, "eur_per_hour");
906        assert_eq!(
907            unit_arg,
908            UnitArg::Expr(
909                rust_decimal::Decimal::ONE,
910                vec![
911                    UnitFactor {
912                        measure_ref: "eur".to_string(),
913                        exp: 1,
914                    },
915                    UnitFactor {
916                        measure_ref: "hour".to_string(),
917                        exp: -1,
918                    }
919                ],
920            )
921        );
922        assert!(rows[1].deprecated_without_colon);
923    }
924
925    #[test]
926    fn parse_unit_deprecated_space_numeric_prefix_and_compound_payload() {
927        let spec = parse_single_spec(
928            r#"spec speed
929uses lemma units
930
931data velocity: measure
932  -> unit kmh 3.6 meter/second
933"#,
934        );
935        let rows = measure_unit_constraints(&spec);
936        assert_eq!(rows.len(), 1);
937        let (name, unit_arg) = unit_name_and_arg(&rows[0]);
938        assert_eq!(name, "kmh");
939        assert_eq!(
940            unit_arg,
941            UnitArg::Expr(
942                decimal("3.6"),
943                vec![
944                    UnitFactor {
945                        measure_ref: "meter".to_string(),
946                        exp: 1,
947                    },
948                    UnitFactor {
949                        measure_ref: "second".to_string(),
950                        exp: -1,
951                    }
952                ],
953            )
954        );
955        assert!(rows[0].deprecated_without_colon);
956    }
957
958    #[test]
959    fn parse_unit_deprecated_space_matches_colon_payload() {
960        let canonical = parse_single_spec(
961            r#"spec rates
962uses lemma units
963
964data money: measure
965  -> unit eur: 1.00
966
967data rate: measure
968  -> unit eur_per_hour: eur/hour
969"#,
970        );
971        let deprecated = parse_single_spec(
972            r#"spec rates
973uses lemma units
974
975data money: measure
976  -> unit eur 1.00
977
978data rate: measure
979  -> unit eur_per_hour eur/hour
980"#,
981        );
982        let canonical_args: Vec<UnitArg> = measure_unit_constraints(&canonical)
983            .iter()
984            .map(|row| unit_name_and_arg(row).1)
985            .collect();
986        let deprecated_args: Vec<UnitArg> = measure_unit_constraints(&deprecated)
987            .iter()
988            .map(|row| unit_name_and_arg(row).1)
989            .collect();
990        assert_eq!(canonical_args, deprecated_args);
991    }
992
993    #[test]
994    fn parse_unit_colon_without_payload_errors() {
995        let result = parse(
996            r#"spec s
997data money: measure
998  -> unit eur:
999"#,
1000            crate::parsing::source::SourceType::Volatile,
1001            &ResourceLimits::default(),
1002        );
1003        assert!(
1004            result.is_err(),
1005            "empty unit payload after colon must not parse"
1006        );
1007    }
1008
1009    fn rule_expression(source: &str, rule_name: &str) -> Expression {
1010        let parsed = parse(
1011            source,
1012            crate::parsing::source::SourceType::Volatile,
1013            &ResourceLimits::default(),
1014        )
1015        .expect("expected parse");
1016        let spec = parsed
1017            .flatten_specs()
1018            .into_iter()
1019            .next()
1020            .expect("expected one spec");
1021        spec.rules
1022            .iter()
1023            .find(|rule| rule.name == rule_name)
1024            .unwrap_or_else(|| panic!("rule '{rule_name}' not found"))
1025            .expression
1026            .clone()
1027    }
1028
1029    fn assert_multiply_range_side(
1030        root: ExprRef<'_, ExpressionKind>,
1031        range_on_left: bool,
1032        label: &str,
1033    ) {
1034        let Some((left, ArithmeticComputation::Multiply, right)) = binary_arithmetic(root) else {
1035            panic!("{label}: expected Multiply, got {:?}", root.kind());
1036        };
1037        let (range, other) = if range_on_left {
1038            (left, right)
1039        } else {
1040            (right, left)
1041        };
1042        assert!(
1043            matches!(range.kind(), ExpressionKind::RangeLiteral(..)),
1044            "{label}: expected RangeLiteral on {} of *, got {:?}",
1045            if range_on_left { "left" } else { "right" },
1046            range.kind()
1047        );
1048        assert!(
1049            !matches!(other.kind(), ExpressionKind::RangeLiteral(..)),
1050            "{label}: expected non-range on other side of *"
1051        );
1052    }
1053
1054    #[test]
1055    fn parse_range_binds_tighter_than_multiply() {
1056        let base = r#"spec test
1057uses lemma units
1058data rate: measure -> unit eur: 1
1059data period_start: 2026-01-01
1060data period_end: 2026-01-02
1061"#;
1062        let rhs = rule_expression(
1063            &format!("{base}rule rhs: rate * period_start...period_end"),
1064            "rhs",
1065        );
1066        assert_multiply_range_side(rhs.root(), false, "rate * period_start...period_end");
1067        let lhs = rule_expression(
1068            &format!("{base}rule lhs: period_start...period_end * rate"),
1069            "lhs",
1070        );
1071        assert_multiply_range_side(lhs.root(), true, "period_start...period_end * rate");
1072    }
1073
1074    #[test]
1075    fn parse_range_additive_right_endpoint_binds_inside_literal() {
1076        let expression = rule_expression(
1077            r#"spec test
1078uses lemma units
1079data start: date
1080data length: units.duration
1081rule valid: now in start...start + length"#,
1082            "valid",
1083        );
1084        let root = expression.root();
1085        let ExpressionKind::RangeContainment(value, range) = root.kind() else {
1086            panic!("expected RangeContainment, got {:?}", root.kind());
1087        };
1088        let value = root.child(*value);
1089        assert!(
1090            matches!(value.kind(), ExpressionKind::Now),
1091            "expected now as containment value, got {:?}",
1092            value.kind()
1093        );
1094        let range = root.child(*range);
1095        let ExpressionKind::RangeLiteral(left, right) = range.kind() else {
1096            panic!(
1097                "expected RangeLiteral as containment range, got {:?}",
1098                range.kind()
1099            );
1100        };
1101        let left = range.child(*left);
1102        assert!(
1103            matches!(left.kind(), ExpressionKind::Reference(..)),
1104            "expected start reference as left endpoint, got {:?}",
1105            left.kind()
1106        );
1107        let right = range.child(*right);
1108        let Some((add_left, ArithmeticComputation::Add, add_right)) = binary_arithmetic(right)
1109        else {
1110            panic!(
1111                "expected start + length as right endpoint, got {:?}",
1112                right.kind()
1113            );
1114        };
1115        assert!(
1116            matches!(add_left.kind(), ExpressionKind::Reference(..)),
1117            "expected start reference in right endpoint add, got {:?}",
1118            add_left.kind()
1119        );
1120        assert!(
1121            matches!(add_right.kind(), ExpressionKind::Reference(..)),
1122            "expected length reference in right endpoint add, got {:?}",
1123            add_right.kind()
1124        );
1125    }
1126
1127    #[test]
1128    fn parse_range_multiply_with_conversion_without_inner_parens() {
1129        let expression = rule_expression(
1130            r#"spec test
1131uses lemma units
1132data money: measure -> unit eur: 1
1133data rate: measure -> unit eur_per_hour: eur/hour
1134data hourly_rate: 50 eur_per_hour
1135data period_start: 2026-01-01
1136data period_end: 2026-01-02
1137rule pay: (hourly_rate * period_start...period_end) as eur"#,
1138            "pay",
1139        );
1140        let ExpressionKind::UnitConversion(inner, _) = expression.root_kind() else {
1141            panic!("expected UnitConversion, got {:?}", expression.root_kind());
1142        };
1143        assert_multiply_range_side(expression.at(*inner), false, "pay");
1144    }
1145
1146    #[test]
1147    fn parse_error_includes_attribute_and_parse_error_spec_name() {
1148        let result = parse(
1149            r#"
1150spec test
1151data name: "Unclosed string
1152data age: 25
1153"#,
1154            crate::parsing::source::SourceType::Volatile,
1155            &ResourceLimits::default(),
1156        );
1157
1158        match result {
1159            Err(Error::Parsing(details)) => {
1160                let src = details
1161                    .source
1162                    .as_ref()
1163                    .expect("BUG: parsing errors always have source");
1164                assert_eq!(
1165                    src.source_type,
1166                    crate::parsing::source::SourceType::Volatile
1167                );
1168            }
1169            Err(e) => panic!("Expected Parse error, got: {e:?}"),
1170            Ok(_) => panic!("Expected parse error for unclosed string"),
1171        }
1172    }
1173
1174    #[test]
1175    fn parse_single_spec_file() {
1176        let input = r#"spec somespec
1177data name: "Alice""#;
1178        let parsed = parse(
1179            input,
1180            crate::parsing::source::SourceType::Volatile,
1181            &ResourceLimits::default(),
1182        )
1183        .unwrap();
1184        let specs = parsed.flatten_specs();
1185        assert_eq!(specs.len(), 1);
1186        assert_eq!(specs[0].name, "somespec");
1187    }
1188
1189    #[test]
1190    fn parse_uses_registry_spec_explicit_alias() {
1191        let input = r#"spec example
1192uses external: @user/workspace somespec"#;
1193        let specs = parse(
1194            input,
1195            crate::parsing::source::SourceType::Volatile,
1196            &ResourceLimits::default(),
1197        )
1198        .unwrap()
1199        .into_flattened_specs();
1200        assert_eq!(specs.len(), 1);
1201        assert_eq!(specs[0].data.len(), 1);
1202        match &specs[0].data[0].value {
1203            crate::parsing::ast::DataValue::Import { spec_ref, .. } => {
1204                assert_eq!(spec_ref.name, "somespec");
1205                let repository_hdr = spec_ref
1206                    .repository
1207                    .as_ref()
1208                    .expect("expected repository qualifier");
1209                assert_eq!(repository_hdr.name, "@user/workspace");
1210            }
1211            other => panic!("Expected Import, got: {:?}", other),
1212        }
1213    }
1214
1215    #[test]
1216    fn parse_multiple_specs_cross_reference_in_file() {
1217        let input = r#"spec spec_a
1218data x: 10
1219
1220spec spec_b
1221data y: 20
1222uses a: spec_a"#;
1223        let parsed = parse(
1224            input,
1225            crate::parsing::source::SourceType::Volatile,
1226            &ResourceLimits::default(),
1227        )
1228        .unwrap();
1229        let specs = parsed.flatten_specs();
1230        assert_eq!(specs.len(), 2);
1231        assert_eq!(specs[0].name, "spec_a");
1232        assert_eq!(specs[1].name, "spec_b");
1233    }
1234
1235    #[test]
1236    fn parse_uses_registry_spec_default_alias() {
1237        let input = "spec example\nuses @owner/repo somespec";
1238        let specs = parse(
1239            input,
1240            crate::parsing::source::SourceType::Volatile,
1241            &ResourceLimits::default(),
1242        )
1243        .unwrap()
1244        .into_flattened_specs();
1245        match &specs[0].data[0].value {
1246            crate::parsing::ast::DataValue::Import { spec_ref, .. } => {
1247                assert_eq!(spec_ref.name, "somespec");
1248                let repository_hdr = spec_ref
1249                    .repository
1250                    .as_ref()
1251                    .expect("expected repository qualifier");
1252                assert_eq!(repository_hdr.name, "@owner/repo");
1253            }
1254            other => panic!("Expected Import, got: {:?}", other),
1255        }
1256    }
1257
1258    #[test]
1259    fn parse_uses_local_spec_default_alias() {
1260        let input = "spec example\nuses myspec";
1261        let specs = parse(
1262            input,
1263            crate::parsing::source::SourceType::Volatile,
1264            &ResourceLimits::default(),
1265        )
1266        .unwrap()
1267        .into_flattened_specs();
1268        match &specs[0].data[0].value {
1269            crate::parsing::ast::DataValue::Import { spec_ref, .. } => {
1270                assert_eq!(spec_ref.name, "myspec");
1271                assert!(
1272                    spec_ref.repository.is_none(),
1273                    "same-repository reference must omit repository qualifier"
1274                );
1275            }
1276            other => panic!("Expected Import, got: {:?}", other),
1277        }
1278    }
1279
1280    #[test]
1281    fn parse_spec_name_with_trailing_dot_is_opaque_name() {
1282        let input = "spec myspec.\ndata x: 1";
1283        let result = parse(
1284            input,
1285            crate::parsing::source::SourceType::Volatile,
1286            &ResourceLimits::default(),
1287        )
1288        .unwrap()
1289        .into_flattened_specs();
1290        assert_eq!(result[0].name, "myspec.");
1291    }
1292
1293    #[test]
1294    fn parse_multiple_specs_in_same_file() {
1295        let input = "spec myspec_a\nrule x: 1\n\nspec myspec_b\nrule x: 2";
1296        let result = parse(
1297            input,
1298            crate::parsing::source::SourceType::Volatile,
1299            &ResourceLimits::default(),
1300        )
1301        .unwrap()
1302        .into_flattened_specs();
1303        assert_eq!(result.len(), 2);
1304        assert_eq!(result[0].name, "myspec_a");
1305        assert_eq!(result[1].name, "myspec_b");
1306    }
1307
1308    #[test]
1309    fn parse_uses_accepts_name_only() {
1310        let input = "spec consumer\nuses other";
1311        let result = parse(
1312            input,
1313            crate::parsing::source::SourceType::Volatile,
1314            &ResourceLimits::default(),
1315        );
1316        assert!(result.is_ok(), "uses name should parse");
1317        let specs = result.unwrap().into_flattened_specs();
1318        let spec_ref = match &specs[0].data[0].value {
1319            crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1320            _ => panic!("expected Import"),
1321        };
1322        assert_eq!(spec_ref.name, "other");
1323    }
1324
1325    #[test]
1326    fn parse_uses_bare_year_effective() {
1327        let input = "spec consumer\nuses other 2026";
1328        let result = parse(
1329            input,
1330            crate::parsing::source::SourceType::Volatile,
1331            &ResourceLimits::default(),
1332        )
1333        .unwrap();
1334        let specs = result.into_flattened_specs();
1335        let spec_ref = match &specs[0].data[0].value {
1336            crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1337            _ => panic!("expected Import"),
1338        };
1339        assert_eq!(spec_ref.name, "other");
1340        let eff = spec_ref.effective.as_ref().expect("effective");
1341        assert_eq!(eff.year, 2026);
1342        assert_eq!(eff.month, 1);
1343        assert_eq!(eff.day, 1);
1344    }
1345
1346    #[test]
1347    fn parse_uses_registry_spec_ref_records_repository_and_target_spans() {
1348        let input = "spec consumer\nuses @iso/countries alpha2 2026";
1349        let result = parse(
1350            input,
1351            crate::parsing::source::SourceType::Volatile,
1352            &ResourceLimits::default(),
1353        )
1354        .unwrap();
1355        let spec = &result.flatten_specs()[0];
1356        let sr = match &spec.data[0].value {
1357            crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1358            _ => panic!("expected Import"),
1359        };
1360        let rs = sr
1361            .repository_span
1362            .as_ref()
1363            .expect("repository_span should be set for @-qualified uses");
1364        let ts = sr
1365            .target_span
1366            .as_ref()
1367            .expect("target_span should cover spec name and effective");
1368        assert_eq!(&input[rs.start..rs.end], "@iso/countries");
1369        assert_eq!(&input[ts.start..ts.end], "alpha2 2026");
1370    }
1371
1372    #[test]
1373    fn parse_uses_alias_no_comma_continuation() {
1374        let input = "spec consumer\nuses alias: pricing retail\ndata x: 1";
1375        let result = parse(
1376            input,
1377            crate::parsing::source::SourceType::Volatile,
1378            &ResourceLimits::default(),
1379        )
1380        .unwrap();
1381        let data = &result.flatten_specs()[0].data;
1382        assert_eq!(data.len(), 2);
1383        assert_eq!(data[0].reference.name, "alias");
1384        let sr = match &data[0].value {
1385            crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1386            _ => panic!("expected Import"),
1387        };
1388        assert_eq!(sr.name, "retail");
1389        let repository_hdr = sr
1390            .repository
1391            .as_ref()
1392            .expect("expected repository qualifier");
1393        assert_eq!(repository_hdr.name, "pricing");
1394    }
1395
1396    #[test]
1397    fn parse_data_qualified_type_with_effective_and_repository_on_uses() {
1398        let input = "spec consumer\nuses @iso/countries alpha2 2026-06-01\ndata country: alpha2.code -> option \"NL\"";
1399        let result = parse(
1400            input,
1401            crate::parsing::source::SourceType::Volatile,
1402            &ResourceLimits::default(),
1403        )
1404        .unwrap()
1405        .into_flattened_specs();
1406        let spec_ref = match &result[0].data[0].value {
1407            crate::parsing::ast::DataValue::Import { spec_ref: sr, .. } => sr,
1408            other => panic!("expected Import on uses row, got: {:?}", other),
1409        };
1410        assert_eq!(spec_ref.name, "alpha2");
1411
1412        let eff = spec_ref
1413            .effective
1414            .as_ref()
1415            .expect("expected effective datetime");
1416        assert_eq!(eff.year, 2026);
1417        assert_eq!(eff.month, 6);
1418
1419        let qualifier = spec_ref
1420            .repository
1421            .as_ref()
1422            .expect("expected repository qualifier");
1423        assert_eq!(qualifier.name, "@iso/countries");
1424
1425        match &result[0].data[1].value {
1426            crate::parsing::ast::DataValue::Definition {
1427                base,
1428                constraints,
1429                value,
1430                list: _,
1431            } => {
1432                assert!(value.is_none());
1433                assert_eq!(
1434                    base.as_ref().expect("expected base"),
1435                    &crate::parsing::ast::ParentType::Qualified {
1436                        spec_alias: "alpha2".into(),
1437                        inner: Box::new(crate::parsing::ast::ParentType::custom("code")),
1438                    }
1439                );
1440
1441                let cs = constraints
1442                    .as_ref()
1443                    .expect("expected trailing constraint chain");
1444                assert_eq!(cs.len(), 1);
1445            }
1446            other => panic!("expected Definition, got: {:?}", other),
1447        }
1448    }
1449
1450    #[test]
1451    fn parse_qualified_type_with_slashed_opaque_spec_name() {
1452        let input = r#"
1453spec logistics/terms
1454data incoterm_type: text
1455
1456spec logistics/shipping
1457uses logistics/terms
1458data incoterm: logistics/terms.incoterm_type
1459"#;
1460        let result = parse(
1461            input,
1462            crate::parsing::source::SourceType::Volatile,
1463            &ResourceLimits::default(),
1464        )
1465        .unwrap()
1466        .into_flattened_specs();
1467        assert_eq!(result.len(), 2);
1468        assert_eq!(result[0].name, "logistics/terms");
1469        assert_eq!(result[1].name, "logistics/shipping");
1470        assert_eq!(result[1].data[0].reference.name, "logistics/terms");
1471        let import = match &result[1].data[0].value {
1472            crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1473            other => panic!("expected Import, got: {other:?}"),
1474        };
1475        assert_eq!(import.name, "logistics/terms");
1476        assert!(import.repository.is_none());
1477        match &result[1].data[1].value {
1478            crate::parsing::ast::DataValue::Definition {
1479                base: Some(base), ..
1480            } => {
1481                assert_eq!(
1482                    base,
1483                    &crate::parsing::ast::ParentType::Qualified {
1484                        spec_alias: "logistics/terms".into(),
1485                        inner: Box::new(crate::parsing::ast::ParentType::custom("incoterm_type",)),
1486                    }
1487                );
1488            }
1489            other => panic!("expected Definition with qualified base, got: {other:?}"),
1490        }
1491    }
1492
1493    #[test]
1494    fn parse_uses_slashed_name_alias_is_full_opaque_name() {
1495        let input = "spec consumer\nuses finance/units\ndata x: 1";
1496        let result = parse(
1497            input,
1498            crate::parsing::source::SourceType::Volatile,
1499            &ResourceLimits::default(),
1500        )
1501        .unwrap()
1502        .into_flattened_specs();
1503        assert_eq!(result[0].data[0].reference.name, "finance/units");
1504        let sr = match &result[0].data[0].value {
1505            crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1506            other => panic!("expected Import, got: {other:?}"),
1507        };
1508        assert_eq!(sr.name, "finance/units");
1509        assert!(sr.repository.is_none());
1510    }
1511
1512    #[test]
1513    fn parse_uses_terms_is_spec_named_terms_not_sibling_of_logistics() {
1514        let input = "spec logistics/shipping\nuses terms\ndata x: 1";
1515        let result = parse(
1516            input,
1517            crate::parsing::source::SourceType::Volatile,
1518            &ResourceLimits::default(),
1519        )
1520        .unwrap()
1521        .into_flattened_specs();
1522        assert_eq!(result[0].data[0].reference.name, "terms");
1523        let sr = match &result[0].data[0].value {
1524            crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1525            other => panic!("expected Import, got: {other:?}"),
1526        };
1527        assert_eq!(sr.name, "terms");
1528        assert!(sr.repository.is_none());
1529    }
1530
1531    #[test]
1532    fn parse_error_is_returned_for_garbage_input() {
1533        let result = parse(
1534            r#"
1535spec test
1536this is not valid lemma syntax @#$%
1537"#,
1538            crate::parsing::source::SourceType::Volatile,
1539            &ResourceLimits::default(),
1540        );
1541
1542        assert!(result.is_err(), "Should fail on malformed input");
1543        match result {
1544            Err(Error::Parsing { .. }) => {
1545                // Expected
1546            }
1547            Err(e) => panic!("Expected Parse error, got: {e:?}"),
1548            Ok(_) => panic!("Expected parse error"),
1549        }
1550    }
1551
1552    // ─── Parser-level pins for DataValue variants ────────────────────
1553
1554    #[test]
1555    fn parse_local_with_literal_rejected() {
1556        let err = parse(
1557            r#"spec s
1558with x: 42"#,
1559            crate::parsing::source::SourceType::Volatile,
1560            &ResourceLimits::default(),
1561        )
1562        .unwrap_err();
1563        let msg = err.to_string();
1564        assert!(
1565            msg.contains("Standalone") || msg.contains("uses"),
1566            "expected standalone with rejection, got: {msg}"
1567        );
1568    }
1569
1570    #[test]
1571    fn parse_local_with_import_reference_rejected() {
1572        let err = parse(
1573            r#"spec s
1574uses i: inner
1575with copy: i.v"#,
1576            crate::parsing::source::SourceType::Volatile,
1577            &ResourceLimits::default(),
1578        )
1579        .unwrap_err();
1580        let msg = err.to_string();
1581        assert!(
1582            msg.contains("Standalone") || msg.contains("uses"),
1583            "expected standalone with rejection, got: {msg}"
1584        );
1585    }
1586
1587    #[test]
1588    fn parse_local_with_dotted_rhs_rejected() {
1589        let err = parse(
1590            r#"spec s
1591with x: a.something"#,
1592            crate::parsing::source::SourceType::Volatile,
1593            &ResourceLimits::default(),
1594        )
1595        .unwrap_err();
1596        let msg = err.to_string();
1597        assert!(
1598            msg.contains("Standalone") || msg.contains("uses"),
1599            "expected standalone with rejection, got: {msg}"
1600        );
1601    }
1602
1603    #[test]
1604    fn parse_local_with_multi_segment_rhs_rejected() {
1605        let err = parse(
1606            r#"spec s
1607with x: alpha.beta.gamma.delta"#,
1608            crate::parsing::source::SourceType::Volatile,
1609            &ResourceLimits::default(),
1610        )
1611        .unwrap_err();
1612        let msg = err.to_string();
1613        assert!(
1614            msg.contains("Standalone") || msg.contains("uses"),
1615            "expected standalone with rejection, got: {msg}"
1616        );
1617    }
1618
1619    /// `data x: notdotted` (local LHS, non-dotted RHS) MUST stay a
1620    /// Definition with an explicit custom parent — not silently reinterpreted as a Reference.
1621    #[test]
1622    fn parse_local_non_dotted_rhs_stays_definition_with_custom_base() {
1623        let input = r#"spec s
1624data x: myothertype"#;
1625        let result = parse(
1626            input,
1627            crate::parsing::source::SourceType::Volatile,
1628            &ResourceLimits::default(),
1629        )
1630        .unwrap()
1631        .into_flattened_specs();
1632        let value = &result[0].data[0].value;
1633        assert!(
1634            matches!(
1635                value,
1636                crate::parsing::ast::DataValue::Definition {
1637                    base: Some(crate::parsing::ast::ParentType::Custom { .. }),
1638                    ..
1639                }
1640            ),
1641            "non-dotted local RHS must stay Definition with custom base, got: {:?}",
1642            value
1643        );
1644    }
1645
1646    /// `uses` block `-> with path: reference` stores reference in binding rhs.
1647    #[test]
1648    fn parse_uses_binding_non_dotted_rhs_is_reference() {
1649        let input = r#"spec s
1650uses child: other
1651  -> with slot: somename"#;
1652        let result = parse(
1653            input,
1654            crate::parsing::source::SourceType::Volatile,
1655            &ResourceLimits::default(),
1656        )
1657        .unwrap()
1658        .into_flattened_specs();
1659        let bindings = match &result[0].data[0].value {
1660            crate::parsing::ast::DataValue::Import { bindings, .. } => bindings,
1661            other => panic!("expected Import, got: {:?}", other),
1662        };
1663        assert_eq!(bindings.len(), 1);
1664        assert!(
1665            matches!(
1666                &bindings[0].rhs,
1667                crate::parsing::ast::UsesBindingRhs::References { targets }
1668                    if targets.len() == 1
1669            ),
1670            "non-dotted RHS must yield Reference; got: {:?}",
1671            bindings[0].rhs
1672        );
1673    }
1674
1675    #[test]
1676    fn parse_uses_binding_keeps_ordered_aliases_in_one_binding() {
1677        let input = r#"spec s
1678uses tree: node
1679  -> with children: left, right"#;
1680        let result = parse(
1681            input,
1682            crate::parsing::source::SourceType::Volatile,
1683            &ResourceLimits::default(),
1684        )
1685        .unwrap()
1686        .into_flattened_specs();
1687        let bindings = match &result[0].data[0].value {
1688            crate::parsing::ast::DataValue::Import { bindings, .. } => bindings,
1689            other => panic!("expected Import, got: {other:?}"),
1690        };
1691        assert_eq!(bindings.len(), 1);
1692        assert_eq!(bindings[0].path.name, "children");
1693        let crate::parsing::ast::UsesBindingRhs::References { targets } = &bindings[0].rhs else {
1694            panic!("expected ordered references, got: {:?}", bindings[0].rhs);
1695        };
1696        assert_eq!(
1697            targets.iter().map(ToString::to_string).collect::<Vec<_>>(),
1698            ["left", "right"]
1699        );
1700    }
1701
1702    #[test]
1703    fn parse_uses_alias_sequence_can_precede_another_field_pair() {
1704        let input = r#"spec s
1705uses tree: node
1706  -> with children: left, right, weight: 2"#;
1707        let result = parse(
1708            input,
1709            crate::parsing::source::SourceType::Volatile,
1710            &ResourceLimits::default(),
1711        )
1712        .unwrap()
1713        .into_flattened_specs();
1714        let bindings = match &result[0].data[0].value {
1715            crate::parsing::ast::DataValue::Import { bindings, .. } => bindings,
1716            other => panic!("expected Import, got: {other:?}"),
1717        };
1718        assert_eq!(bindings.len(), 2);
1719        assert_eq!(bindings[0].path.name, "children");
1720        assert_eq!(bindings[1].path.name, "weight");
1721    }
1722
1723    #[test]
1724    fn parse_uses_alias_sequence_rejects_literal_member() {
1725        let err = parse(
1726            "spec s\nuses tree: node\n  -> with children: left, 2",
1727            crate::parsing::source::SourceType::Volatile,
1728            &ResourceLimits::default(),
1729        )
1730        .expect_err("alias sequence containing a literal must fail");
1731        assert!(
1732            err.to_string().contains("cannot contain literals"),
1733            "unexpected error: {err}"
1734        );
1735    }
1736
1737    /// `data x: spec …` is invalid; spec imports use `uses`.
1738    #[test]
1739    fn parse_data_colon_spec_rhs_is_rejected() {
1740        let result = parse(
1741            r#"
1742spec s
1743data x: spec other
1744"#,
1745            crate::parsing::source::SourceType::Volatile,
1746            &ResourceLimits::default(),
1747        );
1748        match result {
1749            Ok(_) => panic!("`data x: spec other` must fail to parse"),
1750            Err(err) => {
1751                let msg = err.to_string();
1752                assert!(
1753                    msg.contains("uses") && msg.contains("spec"),
1754                    "error must direct to `uses` for spec import, got: {msg}"
1755                );
1756            }
1757        }
1758    }
1759
1760    /// `uses` block dotted path and dotted reference rhs preserved.
1761    #[test]
1762    fn parse_uses_binding_with_dotted_rhs_preserves_both_sides() {
1763        let input = r#"spec s
1764uses outer: other
1765  -> with inner: target.field"#;
1766        let result = parse(
1767            input,
1768            crate::parsing::source::SourceType::Volatile,
1769            &ResourceLimits::default(),
1770        )
1771        .unwrap()
1772        .into_flattened_specs();
1773        let bindings = match &result[0].data[0].value {
1774            crate::parsing::ast::DataValue::Import { bindings, .. } => bindings,
1775            other => panic!("expected Import, got: {:?}", other),
1776        };
1777        assert_eq!(bindings.len(), 1);
1778        assert_eq!(bindings[0].path.segments, vec![] as Vec<String>);
1779        assert_eq!(bindings[0].path.name, "inner");
1780        match &bindings[0].rhs {
1781            crate::parsing::ast::UsesBindingRhs::References { targets } => {
1782                let [target] = targets.as_slice() else {
1783                    panic!("expected one reference, got: {targets:?}");
1784                };
1785                assert_eq!(target.segments, vec!["target"]);
1786                assert_eq!(target.name, "field");
1787            }
1788            other => panic!("expected Reference rhs, got: {:?}", other),
1789        }
1790    }
1791
1792    #[test]
1793    fn parse_deprecated_standalone_with_is_error() {
1794        let input = r#"spec inner
1795data x: number
1796
1797spec outer
1798uses i: inner
1799with i.x: 42
1800rule r: i.x"#;
1801        let result = parse(
1802            input,
1803            crate::parsing::source::SourceType::Volatile,
1804            &ResourceLimits::default(),
1805        );
1806        match result {
1807            Ok(_) => panic!("standalone with after uses must not parse"),
1808            Err(err) => {
1809                let msg = err.to_string();
1810                assert!(
1811                    msg.contains("with"),
1812                    "error should mention with; got: {msg}"
1813                );
1814            }
1815        }
1816    }
1817
1818    #[test]
1819    fn parse_deprecated_standalone_with_before_uses_is_error() {
1820        let input = r#"spec inner
1821data slot: number
1822
1823spec outer
1824with i.slot: 99
1825uses i: inner
1826rule r: i.slot"#;
1827        let result = parse(
1828            input,
1829            crate::parsing::source::SourceType::Volatile,
1830            &ResourceLimits::default(),
1831        );
1832        match result {
1833            Ok(_) => panic!("standalone with before uses must not parse"),
1834            Err(err) => {
1835                let msg = err.to_string();
1836                assert!(
1837                    msg.contains("with"),
1838                    "error should mention with; got: {msg}"
1839                );
1840            }
1841        }
1842    }
1843
1844    #[test]
1845    fn parse_standalone_with_without_matching_uses_errors() {
1846        let result = parse(
1847            r#"spec s
1848with outer.inner: 1"#,
1849            crate::parsing::source::SourceType::Volatile,
1850            &ResourceLimits::default(),
1851        );
1852        match result {
1853            Ok(_) => panic!("standalone with without uses must not parse"),
1854            Err(err) => {
1855                let msg = err.to_string();
1856                assert!(
1857                    msg.contains("with"),
1858                    "error should mention with; got: {msg}"
1859                );
1860            }
1861        }
1862    }
1863
1864    #[test]
1865    fn parse_data_on_binding_path_is_rejected_with_with_hint() {
1866        let result = parse(
1867            r#"spec s
1868data outer.inner: 1"#,
1869            crate::parsing::source::SourceType::Volatile,
1870            &ResourceLimits::default(),
1871        );
1872        match result {
1873            Ok(_) => panic!("data with binding path must not parse"),
1874            Err(err) => {
1875                let msg = err.to_string();
1876                assert!(
1877                    msg.contains("with"),
1878                    "error should steer authors toward with; got: {msg}"
1879                );
1880            }
1881        }
1882    }
1883
1884    #[test]
1885    fn parse_bare_file_yields_single_anonymous_repository_group() {
1886        let input = "spec a\ndata x: 1\nspec b\ndata y: 2";
1887        let parsed = parse(
1888            input,
1889            crate::parsing::source::SourceType::Volatile,
1890            &ResourceLimits::default(),
1891        )
1892        .unwrap();
1893        assert_eq!(parsed.repositories.len(), 1);
1894        let (repo, specs) = parsed.repositories.iter().next().unwrap();
1895        assert!(repo.name.is_none());
1896        assert_eq!(specs.len(), 2);
1897        assert_eq!(specs[0].name, "a");
1898        assert_eq!(specs[1].name, "b");
1899    }
1900
1901    #[test]
1902    fn parse_repo_sections_preserve_order_and_names() {
1903        let input = r#"repo r1
1904
1905spec a
1906data x: 1
1907
1908repo r2
1909
1910spec b
1911data y: 2"#;
1912        let parsed = parse(
1913            input,
1914            crate::parsing::source::SourceType::Volatile,
1915            &ResourceLimits::default(),
1916        )
1917        .unwrap();
1918        assert_eq!(parsed.repositories.len(), 2);
1919        let keys: Vec<_> = parsed.repositories.keys().collect();
1920        assert_eq!(keys[0].name.as_deref(), Some("r1"));
1921        assert_eq!(keys[1].name.as_deref(), Some("r2"));
1922    }
1923
1924    #[test]
1925    fn parse_duplicate_repo_name_merges_spec_lists() {
1926        let input = r#"repo dup
1927
1928spec a
1929data x: 1
1930
1931repo dup
1932
1933spec b
1934data y: 2"#;
1935        let parsed = parse(
1936            input,
1937            crate::parsing::source::SourceType::Volatile,
1938            &ResourceLimits::default(),
1939        )
1940        .unwrap();
1941        assert_eq!(parsed.repositories.len(), 1);
1942        assert_eq!(parsed.flatten_specs().len(), 2);
1943    }
1944
1945    #[test]
1946    fn parse_repo_with_no_specs_then_eof_yields_empty_spec_vec_for_that_repo() {
1947        let input = "repo empty";
1948        let parsed = parse(
1949            input,
1950            crate::parsing::source::SourceType::Volatile,
1951            &ResourceLimits::default(),
1952        )
1953        .unwrap();
1954        assert_eq!(parsed.repositories.len(), 1);
1955        let (_, specs) = parsed.repositories.iter().next().unwrap();
1956        assert_eq!(specs.len(), 0);
1957    }
1958
1959    #[test]
1960    fn parse_repo_followed_by_repo_without_specs_first_repo_empty_second_has_spec() {
1961        let input = "repo a\n\nrepo b\n\nspec s\ndata x: 1";
1962        let parsed = parse(
1963            input,
1964            crate::parsing::source::SourceType::Volatile,
1965            &ResourceLimits::default(),
1966        )
1967        .unwrap();
1968        assert_eq!(parsed.repositories.len(), 2);
1969        let names: Vec<_> = parsed
1970            .repositories
1971            .keys()
1972            .map(|r| r.name.as_deref())
1973            .collect();
1974        assert_eq!(names, vec![Some("a"), Some("b")]);
1975        assert!(parsed.repositories.values().next().unwrap().is_empty());
1976        assert_eq!(parsed.repositories.values().nth(1).unwrap().len(), 1);
1977    }
1978
1979    #[test]
1980    fn parse_spec_named_repo_keyword_should_be_rejected() {
1981        assert!(
1982            parse(
1983                "spec repo\ndata x: 1",
1984                crate::parsing::source::SourceType::Volatile,
1985                &ResourceLimits::default(),
1986            )
1987            .is_err(),
1988            "spec must not be allowed to use reserved keyword `repo` as its name"
1989        );
1990    }
1991
1992    #[test]
1993    fn parse_repo_declaration_cannot_use_spec_keyword_as_repository_name() {
1994        assert!(
1995            parse(
1996                "repo spec\n\nspec z\ndata q: 1\nrule r: q",
1997                crate::parsing::source::SourceType::Volatile,
1998                &ResourceLimits::default(),
1999            )
2000            .is_err(),
2001            "repository name cannot be the token `spec`"
2002        );
2003    }
2004
2005    #[test]
2006    fn parse_repo_declaration_cannot_use_data_keyword_as_repository_name() {
2007        assert!(
2008            parse(
2009                "repo data\n\nspec z\ndata q: 1\nrule r: q",
2010                crate::parsing::source::SourceType::Volatile,
2011                &ResourceLimits::default(),
2012            )
2013            .is_err(),
2014            "repository name cannot be the token `data`"
2015        );
2016    }
2017
2018    #[test]
2019    fn parse_repo_declaration_cannot_use_rule_keyword_as_repository_name() {
2020        assert!(
2021            parse(
2022                "repo rule\n\nspec z\ndata q: 1\nrule r: q",
2023                crate::parsing::source::SourceType::Volatile,
2024                &ResourceLimits::default(),
2025            )
2026            .is_err(),
2027            "repository name cannot be the token `rule`"
2028        );
2029    }
2030
2031    #[test]
2032    fn parse_data_named_repo_keyword_is_rejected() {
2033        let err = parse(
2034            "spec s\ndata repo: 1",
2035            crate::parsing::source::SourceType::Volatile,
2036            &ResourceLimits::default(),
2037        )
2038        .unwrap_err();
2039        assert!(
2040            err.to_string().contains("repo"),
2041            "data named repo should not parse: {}",
2042            err
2043        );
2044    }
2045
2046    #[test]
2047    fn parse_rule_named_repo_keyword_is_rejected() {
2048        let err = parse(
2049            "spec s\ndata x: 1\nrule repo: x",
2050            crate::parsing::source::SourceType::Volatile,
2051            &ResourceLimits::default(),
2052        )
2053        .unwrap_err();
2054        let msg = err.to_string();
2055        assert!(
2056            msg.contains("repo") || msg.contains("reserved"),
2057            "rule named repo should not parse: {msg}"
2058        );
2059    }
2060
2061    #[test]
2062    fn parse_repo_declaration_accepts_non_keyword_repository_identifier() {
2063        let parsed = parse(
2064            "repo warehouse\n\nspec z\ndata q: 1\nrule r: q",
2065            crate::parsing::source::SourceType::Volatile,
2066            &ResourceLimits::default(),
2067        )
2068        .unwrap();
2069        assert_eq!(parsed.repositories.len(), 1);
2070        assert_eq!(
2071            parsed.repositories.keys().next().unwrap().name.as_deref(),
2072            Some("warehouse")
2073        );
2074    }
2075
2076    #[test]
2077    fn parse_repo_name_case_insensitive_same_repository_merged() {
2078        let input = "repo Foo\n\nspec a\ndata x: 1\n\nrepo foo\n\nspec b\ndata y: 2";
2079        let parsed = parse(
2080            input,
2081            crate::parsing::source::SourceType::Volatile,
2082            &ResourceLimits::default(),
2083        )
2084        .unwrap();
2085        assert_eq!(
2086            parsed.repositories.len(),
2087            1,
2088            "Foo and foo are the same repository after canonicalization"
2089        );
2090        let specs: Vec<_> = parsed.repositories.values().next().unwrap().clone();
2091        assert_eq!(specs.len(), 2);
2092        assert_eq!(specs[0].name, "a");
2093        assert_eq!(specs[1].name, "b");
2094    }
2095
2096    #[test]
2097    fn parse_repo_empty_name_errors() {
2098        let err = parse(
2099            "repo \nspec a\ndata x: 1",
2100            crate::parsing::source::SourceType::Volatile,
2101            &ResourceLimits::default(),
2102        )
2103        .unwrap_err();
2104        assert!(
2105            !err.to_string().is_empty(),
2106            "empty repo name should not parse quietly: {err}"
2107        );
2108    }
2109
2110    #[test]
2111    fn parse_repo_numeric_name_behavior() {
2112        let input = "repo 123\n\nspec a\ndata x: 1";
2113        let result = parse(
2114            input,
2115            crate::parsing::source::SourceType::Volatile,
2116            &ResourceLimits::default(),
2117        );
2118        match result {
2119            Ok(parsed) => {
2120                assert_eq!(
2121                    parsed.repositories.keys().next().unwrap().name.as_deref(),
2122                    Some("123"),
2123                    "if numeric repo names parse, identity must be stable"
2124                );
2125            }
2126            Err(e) => {
2127                assert!(
2128                    !e.to_string().is_empty(),
2129                    "rejecting numeric repo name is ok if explicit: {e}"
2130                );
2131            }
2132        }
2133    }
2134
2135    #[test]
2136    fn parse_duplicate_repo_three_sections_preserves_spec_order_abc() {
2137        let input = r#"repo dup
2138
2139spec a
2140data x: 1
2141
2142repo dup
2143
2144spec b
2145data y: 2
2146
2147repo dup
2148
2149spec c
2150data z: 3"#;
2151        let parsed = parse(
2152            input,
2153            crate::parsing::source::SourceType::Volatile,
2154            &ResourceLimits::default(),
2155        )
2156        .unwrap();
2157        assert_eq!(parsed.repositories.len(), 1);
2158        let specs = parsed.repositories.values().next().unwrap();
2159        assert_eq!(
2160            specs.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
2161            vec!["a", "b", "c"]
2162        );
2163    }
2164
2165    #[test]
2166    fn parse_repo_single_section_roundtrips_through_formatter() {
2167        let input = "repo r\n\nspec a\ndata x: 1";
2168        let parsed = parse(
2169            input,
2170            crate::parsing::source::SourceType::Volatile,
2171            &ResourceLimits::default(),
2172        )
2173        .unwrap();
2174        let formatted = format_parse_result(&parsed);
2175        let again = parse(
2176            &formatted,
2177            crate::parsing::source::SourceType::Volatile,
2178            &ResourceLimits::default(),
2179        )
2180        .unwrap();
2181        assert_eq!(again.repositories.len(), parsed.repositories.len());
2182        assert_eq!(again.flatten_specs().len(), parsed.flatten_specs().len());
2183        assert_eq!(
2184            again.flatten_specs()[0].name,
2185            parsed.flatten_specs()[0].name
2186        );
2187    }
2188
2189    #[test]
2190    fn parse_repo_two_sections_roundtrips_through_formatter() {
2191        let input = "repo r1\n\nspec a\ndata x: 1\n\nrepo r2\n\nspec b\ndata y: 2";
2192        let parsed = parse(
2193            input,
2194            crate::parsing::source::SourceType::Volatile,
2195            &ResourceLimits::default(),
2196        )
2197        .unwrap();
2198        let formatted = format_parse_result(&parsed);
2199        let again = parse(
2200            &formatted,
2201            crate::parsing::source::SourceType::Volatile,
2202            &ResourceLimits::default(),
2203        )
2204        .unwrap();
2205        assert_eq!(again.repositories.len(), 2);
2206        assert_eq!(again.flatten_specs().len(), 2);
2207    }
2208
2209    #[test]
2210    fn parse_repo_duplicate_merge_formatter_emits_single_repo_block_or_equivalent_parse() {
2211        let input = r#"repo dup
2212
2213spec a
2214data x: 1
2215
2216repo dup
2217
2218spec b
2219data y: 2"#;
2220        let parsed = parse(
2221            input,
2222            crate::parsing::source::SourceType::Volatile,
2223            &ResourceLimits::default(),
2224        )
2225        .unwrap();
2226        let formatted = format_parse_result(&parsed);
2227        let again = parse(
2228            &formatted,
2229            crate::parsing::source::SourceType::Volatile,
2230            &ResourceLimits::default(),
2231        )
2232        .unwrap();
2233        assert_eq!(
2234            again.repositories.len(),
2235            1,
2236            "formatted duplicate-repo file must still merge to one logical repo"
2237        );
2238        assert_eq!(again.flatten_specs().len(), 2);
2239    }
2240
2241    #[test]
2242    fn parse_rejects_data_named_that_in_any_case() {
2243        for name in ["that", "That", "THAT"] {
2244            let result = parse(
2245                &format!("spec s\ndata {name}: 1"),
2246                crate::parsing::source::SourceType::Volatile,
2247                &ResourceLimits::default(),
2248            );
2249            let err = result.expect_err("that is reserved");
2250            assert!(
2251                err.to_string().to_ascii_lowercase().contains("that"),
2252                "{name}: expected reserved-keyword error, got: {err}"
2253            );
2254        }
2255    }
2256
2257    #[test]
2258    fn parse_rejects_data_named_measure() {
2259        let result = parse(
2260            "spec s\ndata measure: 1",
2261            crate::parsing::source::SourceType::Volatile,
2262            &ResourceLimits::default(),
2263        );
2264        assert!(
2265            result.is_err(),
2266            "data named measure (type keyword) must be rejected"
2267        );
2268    }
2269
2270    #[test]
2271    fn parse_rejects_data_named_number() {
2272        let result = parse(
2273            "spec s\ndata number: 42",
2274            crate::parsing::source::SourceType::Volatile,
2275            &ResourceLimits::default(),
2276        );
2277        assert!(
2278            result.is_err(),
2279            "data named number (type keyword) must be rejected"
2280        );
2281    }
2282
2283    #[test]
2284    fn parse_rejects_data_named_text() {
2285        let result = parse(
2286            "spec s\ndata text: \"hello\"",
2287            crate::parsing::source::SourceType::Volatile,
2288            &ResourceLimits::default(),
2289        );
2290        assert!(
2291            result.is_err(),
2292            "data named text (type keyword) must be rejected"
2293        );
2294    }
2295
2296    #[test]
2297    fn parse_rejects_data_named_date() {
2298        let result = parse(
2299            "spec s\ndata date: 2024-01-01",
2300            crate::parsing::source::SourceType::Volatile,
2301            &ResourceLimits::default(),
2302        );
2303        assert!(
2304            result.is_err(),
2305            "data named date (type keyword) must be rejected"
2306        );
2307    }
2308
2309    #[test]
2310    fn parse_rejects_data_named_boolean() {
2311        let result = parse(
2312            "spec s\ndata boolean: true",
2313            crate::parsing::source::SourceType::Volatile,
2314            &ResourceLimits::default(),
2315        );
2316        assert!(
2317            result.is_err(),
2318            "data named boolean (type keyword) must be rejected"
2319        );
2320    }
2321
2322    #[test]
2323    fn parse_rejects_data_named_ratio() {
2324        let result = parse(
2325            "spec s\ndata ratio: 5%",
2326            crate::parsing::source::SourceType::Volatile,
2327            &ResourceLimits::default(),
2328        );
2329        assert!(
2330            result.is_err(),
2331            "data named ratio (type keyword) must be rejected"
2332        );
2333    }
2334
2335    #[test]
2336    fn parse_rejects_rule_named_measure() {
2337        let result = parse(
2338            "spec s\ndata x: 1\nrule measure: x",
2339            crate::parsing::source::SourceType::Volatile,
2340            &ResourceLimits::default(),
2341        );
2342        assert!(
2343            result.is_err(),
2344            "rule named measure (type keyword) must be rejected"
2345        );
2346    }
2347
2348    #[test]
2349    fn parse_rejects_rule_named_number() {
2350        let result = parse(
2351            "spec s\ndata x: 1\nrule number: x",
2352            crate::parsing::source::SourceType::Volatile,
2353            &ResourceLimits::default(),
2354        );
2355        assert!(
2356            result.is_err(),
2357            "rule named number (type keyword) must be rejected"
2358        );
2359    }
2360
2361    #[test]
2362    fn parse_rejects_rule_named_text() {
2363        let result = parse(
2364            "spec s\ndata x: 1\nrule text: x",
2365            crate::parsing::source::SourceType::Volatile,
2366            &ResourceLimits::default(),
2367        );
2368        assert!(
2369            result.is_err(),
2370            "rule named text (type keyword) must be rejected"
2371        );
2372    }
2373
2374    #[test]
2375    fn parse_rejects_rule_named_date() {
2376        let result = parse(
2377            "spec s\ndata x: 1\nrule date: x",
2378            crate::parsing::source::SourceType::Volatile,
2379            &ResourceLimits::default(),
2380        );
2381        assert!(
2382            result.is_err(),
2383            "rule named date (type keyword) must be rejected"
2384        );
2385    }
2386
2387    #[test]
2388    fn parse_rejects_rule_named_boolean() {
2389        let result = parse(
2390            "spec s\ndata x: 1\nrule boolean: x",
2391            crate::parsing::source::SourceType::Volatile,
2392            &ResourceLimits::default(),
2393        );
2394        assert!(
2395            result.is_err(),
2396            "rule named boolean (type keyword) must be rejected"
2397        );
2398    }
2399
2400    #[test]
2401    fn parse_rejects_rule_named_ratio() {
2402        let result = parse(
2403            "spec s\ndata x: 1\nrule ratio: x",
2404            crate::parsing::source::SourceType::Volatile,
2405            &ResourceLimits::default(),
2406        );
2407        assert!(
2408            result.is_err(),
2409            "rule named ratio (type keyword) must be rejected"
2410        );
2411    }
2412
2413    fn assert_reserved_name(source: &str) {
2414        let result = parse(
2415            source,
2416            crate::parsing::source::SourceType::Volatile,
2417            &ResourceLimits::default(),
2418        );
2419        let message = result
2420            .expect_err("reserved name must not parse")
2421            .to_string();
2422        assert!(
2423            message.contains("reserved keyword"),
2424            "expected reserved-keyword error, got: {message}"
2425        );
2426    }
2427
2428    #[test]
2429    fn parse_rejects_lower_and_upper_as_names() {
2430        assert_reserved_name("spec s\ndata lower: 1");
2431        assert_reserved_name("spec s\ndata upper: 1");
2432        assert_reserved_name("spec s\nrule lower: 1");
2433        assert_reserved_name("spec s\nrule upper: 1");
2434    }
2435
2436    #[test]
2437    fn parse_constraint_lower_and_upper_still_parse() {
2438        let parsed = parse(
2439            r#"spec window
2440data period: date range
2441  -> lower 2020-01-01
2442  -> upper 2030-12-31
2443"#,
2444            crate::parsing::source::SourceType::Volatile,
2445            &ResourceLimits::default(),
2446        )
2447        .expect("-> lower / -> upper are constraint commands");
2448        let spec = parsed.flatten_specs().into_iter().next().expect("one spec");
2449        let period = spec
2450            .data
2451            .iter()
2452            .find(|data| data.reference.name == "period")
2453            .expect("period");
2454        let DataValue::Definition {
2455            constraints: Some(rows),
2456            ..
2457        } = &period.value
2458        else {
2459            panic!("expected constraints, got {:?}", period.value);
2460        };
2461        let commands: Vec<_> = rows.iter().map(|row| row.command).collect();
2462        assert_eq!(
2463            commands,
2464            vec![TypeConstraintCommand::Lower, TypeConstraintCommand::Upper]
2465        );
2466    }
2467
2468    fn prefix_expression(source: &str) -> Expression {
2469        rule_expression(source, "out")
2470    }
2471
2472    fn prefix_parse_error(source: &str) -> String {
2473        match parse(
2474            source,
2475            crate::parsing::source::SourceType::Volatile,
2476            &ResourceLimits::default(),
2477        ) {
2478            Err(Error::Parsing(details)) => details.message,
2479            other => panic!("expected parse Error, got {other:?}"),
2480        }
2481    }
2482
2483    #[test]
2484    fn parse_prefix_operand_is_call_or_one_operand() {
2485        for (body, operator) in [
2486            ("abs x + 1", "+"),
2487            ("abs x as gram", "as"),
2488            ("sqrt x ^ 2", "^"),
2489            ("lower p + 1", "+"),
2490            ("not ok and ok", "and"),
2491            ("not x > 1", ">"),
2492        ] {
2493            let message = prefix_parse_error(&format!(
2494                "spec test\nuses lemma units\ndata x: 1\ndata p: number range\ndata ok: boolean\nrule out: {body}"
2495            ));
2496            assert!(
2497                message.contains(&format!("found '{operator}' after it")),
2498                "{body}: {message}"
2499            );
2500        }
2501        let message = prefix_parse_error("spec test\nrule out: abs -1");
2502        assert!(message.contains("`abs(...)`"), "{message}");
2503
2504        let abs_plus = prefix_expression(
2505            r#"spec test
2506data x: 1
2507rule out: abs(x) + 1"#,
2508        );
2509        let Some((left, ArithmeticComputation::Add, _)) = binary_arithmetic(abs_plus.root()) else {
2510            panic!("abs(x) + 1 must be add, got {abs_plus}");
2511        };
2512        assert!(matches!(
2513            left.kind(),
2514            ExpressionKind::MathematicalComputation(MathematicalComputation::Abs, _)
2515        ));
2516
2517        let abs_sum = prefix_expression(
2518            r#"spec test
2519data x: 1
2520rule out: abs (x + 1)"#,
2521        );
2522        let ExpressionKind::MathematicalComputation(MathematicalComputation::Abs, operand) =
2523            abs_sum.root_kind()
2524        else {
2525            panic!("abs (x + 1) must be abs of a sum, got {abs_sum}");
2526        };
2527        assert!(matches!(
2528            abs_sum.at(*operand).kind(),
2529            ExpressionKind::Arithmetic { .. }
2530        ));
2531
2532        let abs_sqrt = prefix_expression(
2533            r#"spec test
2534data x: 1
2535rule out: abs sqrt x"#,
2536        );
2537        let ExpressionKind::MathematicalComputation(MathematicalComputation::Abs, operand) =
2538            abs_sqrt.root_kind()
2539        else {
2540            panic!("abs sqrt x must be abs of sqrt, got {abs_sqrt}");
2541        };
2542        assert!(matches!(
2543            abs_sqrt.at(*operand).kind(),
2544            ExpressionKind::MathematicalComputation(MathematicalComputation::Sqrt, _)
2545        ));
2546
2547        let lower_range = prefix_expression(
2548            r#"spec test
2549rule out: lower 5...10"#,
2550        );
2551        let ExpressionKind::RangeBound(RangeBound::Lower, operand) = lower_range.root_kind() else {
2552            panic!("lower 5...10 must be lower of a range, got {lower_range}");
2553        };
2554        assert!(matches!(
2555            lower_range.at(*operand).kind(),
2556            ExpressionKind::RangeLiteral(..)
2557        ));
2558        assert_eq!(
2559            rule_expression(
2560                r#"spec test
2561rule out: lower 5...10"#,
2562                "out"
2563            )
2564            .to_string(),
2565            "lower 5...10"
2566        );
2567
2568        let lower_extended = prefix_expression(
2569            r#"spec test
2570data a: 1
2571data b: 2
2572rule out: lower a...b + 1"#,
2573        );
2574        let ExpressionKind::RangeBound(RangeBound::Lower, operand) = lower_extended.root_kind()
2575        else {
2576            panic!("expected lower of a range, got {lower_extended}");
2577        };
2578        let operand = lower_extended.at(*operand);
2579        let ExpressionKind::RangeLiteral(_, right) = operand.kind() else {
2580            panic!("+ must stay inside the range, got {:?}", operand.kind());
2581        };
2582        assert!(matches!(
2583            binary_arithmetic(operand.child(*right)),
2584            Some((_, ArithmeticComputation::Add, _))
2585        ));
2586
2587        let not_comparison = prefix_expression(
2588            r#"spec test
2589data x: 1
2590rule out: not (x > 1)"#,
2591        );
2592        let ExpressionKind::LogicalNegation(operand, _) = not_comparison.root_kind() else {
2593            panic!("not (x > 1) must negate the comparison, got {not_comparison}");
2594        };
2595        assert!(matches!(
2596            not_comparison.at(*operand).kind(),
2597            ExpressionKind::Comparison(..)
2598        ));
2599    }
2600
2601    fn arrow_expression(body: &str) -> Expression {
2602        rule_expression(&format!("spec test\nrule out: {body}"), "out")
2603    }
2604
2605    #[test]
2606    fn arrow_takes_the_whole_arithmetic_operand() {
2607        let sum = arrow_expression("a + b -> sum x");
2608        let ExpressionKind::ListCommand { list, .. } = sum.root_kind() else {
2609            panic!("a + b -> sum x must be a command on a + b, got {sum}");
2610        };
2611        assert!(matches!(
2612            binary_arithmetic(sum.at(*list)),
2613            Some((_, ArithmeticComputation::Add, _))
2614        ));
2615
2616        let quotient = arrow_expression("total / weight -> round 0.001 eur_per_kg");
2617        let ExpressionKind::StepRounding {
2618            operand,
2619            mode: RoundingMode::Round,
2620            step,
2621        } = quotient.root_kind()
2622        else {
2623            panic!("total / weight -> round step must round the quotient, got {quotient}");
2624        };
2625        assert!(matches!(
2626            binary_arithmetic(quotient.at(*operand)),
2627            Some((_, ArithmeticComputation::Divide, _))
2628        ));
2629        let Some(step) = step else {
2630            panic!("total / weight -> round step must carry a step");
2631        };
2632        assert!(matches!(
2633            quotient.at(*step).kind(),
2634            ExpressionKind::Literal(_)
2635        ));
2636
2637        let prefix = arrow_expression("abs saldo -> floor 1 eur");
2638        let ExpressionKind::StepRounding {
2639            operand,
2640            mode: RoundingMode::Floor,
2641            ..
2642        } = prefix.root_kind()
2643        else {
2644            panic!("abs saldo -> floor step must floor abs saldo, got {prefix}");
2645        };
2646        assert!(matches!(
2647            prefix.at(*operand).kind(),
2648            ExpressionKind::MathematicalComputation(MathematicalComputation::Abs, _)
2649        ));
2650
2651        let call = arrow_expression("eind - start -> ceil(15 minute)");
2652        assert!(matches!(
2653            call.root_kind(),
2654            ExpressionKind::StepRounding {
2655                mode: RoundingMode::Ceil,
2656                ..
2657            }
2658        ));
2659
2660        let bare = arrow_expression("x -> round");
2661        assert!(matches!(
2662            bare.root_kind(),
2663            ExpressionKind::StepRounding {
2664                mode: RoundingMode::Round,
2665                step: None,
2666                ..
2667            }
2668        ));
2669    }
2670
2671    #[test]
2672    fn arrow_binds_tighter_than_comparison_and_logic() {
2673        let count = arrow_expression("items -> count is 0");
2674        let ExpressionKind::Comparison(left, _, _) = count.root_kind() else {
2675            panic!("items -> count is 0 must compare the count, got {count}");
2676        };
2677        assert!(matches!(
2678            count.at(*left).kind(),
2679            ExpressionKind::ListCommand { .. }
2680        ));
2681
2682        let right = arrow_expression("limit > items -> sum cost");
2683        let ExpressionKind::Comparison(_, _, right_operand) = right.root_kind() else {
2684            panic!("limit > items -> sum cost must compare against the sum, got {right}");
2685        };
2686        assert!(matches!(
2687            right.at(*right_operand).kind(),
2688            ExpressionKind::ListCommand { .. }
2689        ));
2690
2691        let logic = arrow_expression("ok and items -> count > 0");
2692        let ExpressionKind::Logical { steps, .. } = logic.root_kind() else {
2693            panic!("ok and items -> count > 0 must be a conjunction, got {logic}");
2694        };
2695        let ExpressionKind::Comparison(left, _, _) = logic.at(steps[0].operand).kind() else {
2696            panic!("second operand must be a comparison, got {logic}");
2697        };
2698        assert!(matches!(
2699            logic.at(*left).kind(),
2700            ExpressionKind::ListCommand { .. }
2701        ));
2702
2703        let chain = arrow_expression("staff -> where paid -> sum bonus");
2704        let ExpressionKind::ListCommand { list, .. } = chain.root_kind() else {
2705            panic!("chained commands must nest left to right, got {chain}");
2706        };
2707        assert!(matches!(
2708            chain.at(*list).kind(),
2709            ExpressionKind::ListCommand { .. }
2710        ));
2711    }
2712
2713    #[test]
2714    fn operator_after_a_command_is_a_parse_error() {
2715        for (body, found, command) in [
2716            ("lines -> sum(qty) * 2", "*", "sum(qty)"),
2717            ("fares -> sum - 1", "-", "sum"),
2718            ("speeds -> max as mps", "as", "max"),
2719            ("fares -> count + 1", "+", "count"),
2720            ("x -> round(0.01) ^ 2", "^", "round(0.01)"),
2721        ] {
2722            let message = prefix_parse_error(&format!("spec test\nrule out: {body}"));
2723            assert!(
2724                message.contains(&format!("found '{found}' after `-> {command}`")),
2725                "{body}: {message}"
2726            );
2727            assert!(message.contains("parentheses"), "{body}: {message}");
2728        }
2729    }
2730
2731    #[test]
2732    fn arrow_after_bare_not_is_a_parse_error() {
2733        let message = prefix_parse_error("spec test\nrule out: not items -> any paid");
2734        assert!(
2735            message.contains("`not (items -> ...)`"),
2736            "not followed by -> must suggest parentheses: {message}"
2737        );
2738    }
2739
2740    #[test]
2741    fn arrow_after_in_range_suggests_parentheses_around_the_command() {
2742        let message = prefix_parse_error("spec test\nrule out: day in periods -> first");
2743        assert!(
2744            message.contains(
2745                "found '->' after `day in periods`; `in` takes one range operand, \
2746                 so put parentheses around the command: `day in (periods -> ...)`"
2747            ),
2748            "{message}"
2749        );
2750    }
2751
2752    #[test]
2753    fn arrow_after_in_calendar_suggests_parentheses_around_the_check() {
2754        let message = prefix_parse_error("spec test\nrule out: day in past calendar year -> first");
2755        assert!(
2756            message.contains("found '->' after `day in past calendar year`"),
2757            "{message}"
2758        );
2759        assert!(
2760            message.contains("`(day in past calendar year) -> ...`"),
2761            "{message}"
2762        );
2763    }
2764
2765    #[test]
2766    fn prefix_rounding_suggests_the_arrow_form() {
2767        for (body, keyword, rewrite) in [
2768            ("round(x)", "round", "`x -> round`"),
2769            ("floor x", "floor", "`x -> floor`"),
2770            ("ceil (a / b)", "ceil", "`a / b -> ceil`"),
2771        ] {
2772            let message = prefix_parse_error(&format!(
2773                "spec test\ndata x: 1\ndata a: 1\ndata b: 1\nrule out: {body}"
2774            ));
2775            assert!(
2776                message.contains(&format!("`{keyword}` is written after `->`")),
2777                "{body}: {message}"
2778            );
2779            assert!(message.contains(rewrite), "{body}: {message}");
2780        }
2781    }
2782
2783    #[test]
2784    fn format_prefix_operand_round_trips() {
2785        let source = r#"spec test
2786data x: 1.00
2787rule tight: abs(x) + 1
2788rule wide: abs (x + 1)
2789rule ends: lower 5...10
2790rule edge: (lower x)...(upper x)
2791rule round_bare: x -> round
2792rule round_step: x -> round(0.05) > 1
2793"#;
2794        let formatted =
2795            crate::formatting::format_source(source, crate::parsing::source::SourceType::Volatile)
2796                .expect("format");
2797        let again = crate::formatting::format_source(
2798            &formatted,
2799            crate::parsing::source::SourceType::Volatile,
2800        )
2801        .expect("reformat");
2802        assert_eq!(formatted, again, "format must be stable");
2803
2804        for rule in ["tight", "wide", "ends", "edge", "round_bare", "round_step"] {
2805            let original = rule_expression(source, rule);
2806            let reparsed = rule_expression(&formatted, rule);
2807            assert_eq!(
2808                original.to_string(),
2809                reparsed.to_string(),
2810                "{rule} changed meaning across format/parse"
2811            );
2812        }
2813
2814        let tight = rule_expression(&formatted, "tight");
2815        let Some((left, ArithmeticComputation::Add, _)) = binary_arithmetic(tight.root()) else {
2816            panic!("abs(x) + 1 must stay an addition, got {}", tight);
2817        };
2818        assert!(
2819            matches!(
2820                left.kind(),
2821                ExpressionKind::MathematicalComputation(MathematicalComputation::Abs, _)
2822            ),
2823            "abs(x) + 1 must stay abs-then-add, got {tight}"
2824        );
2825    }
2826}