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, TypeConstraintCommand,
30        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: deprecated space-separated unit form ───────────────────────
772
773    #[test]
774    fn parse_unit_deprecated_space_factor_payload() {
775        let spec = parse_single_spec(
776            r#"spec money_spec
777data money: measure
778  -> unit eur 1.00
779"#,
780        );
781        let rows = measure_unit_constraints(&spec);
782        assert_eq!(rows.len(), 1);
783        let (name, unit_arg) = unit_name_and_arg(&rows[0]);
784        assert_eq!(name, "eur");
785        assert_eq!(unit_arg, UnitArg::Factor(decimal("1.00")));
786        assert!(rows[0].deprecated_without_colon);
787    }
788
789    #[test]
790    fn parse_unit_deprecated_space_compound_only_payload() {
791        let spec = parse_single_spec(
792            r#"spec rates
793uses lemma units
794
795data money: measure
796  -> unit eur 1.00
797
798data rate: measure
799  -> unit eur_per_hour eur/hour
800"#,
801        );
802        let rows = measure_unit_constraints(&spec);
803        assert_eq!(rows.len(), 2);
804        let (name, unit_arg) = unit_name_and_arg(&rows[1]);
805        assert_eq!(name, "eur_per_hour");
806        assert_eq!(
807            unit_arg,
808            UnitArg::Expr(
809                rust_decimal::Decimal::ONE,
810                vec![
811                    UnitFactor {
812                        measure_ref: "eur".to_string(),
813                        exp: 1,
814                    },
815                    UnitFactor {
816                        measure_ref: "hour".to_string(),
817                        exp: -1,
818                    }
819                ],
820            )
821        );
822        assert!(rows[1].deprecated_without_colon);
823    }
824
825    #[test]
826    fn parse_unit_deprecated_space_numeric_prefix_and_compound_payload() {
827        let spec = parse_single_spec(
828            r#"spec speed
829uses lemma units
830
831data velocity: measure
832  -> unit kmh 3.6 meter/second
833"#,
834        );
835        let rows = measure_unit_constraints(&spec);
836        assert_eq!(rows.len(), 1);
837        let (name, unit_arg) = unit_name_and_arg(&rows[0]);
838        assert_eq!(name, "kmh");
839        assert_eq!(
840            unit_arg,
841            UnitArg::Expr(
842                decimal("3.6"),
843                vec![
844                    UnitFactor {
845                        measure_ref: "meter".to_string(),
846                        exp: 1,
847                    },
848                    UnitFactor {
849                        measure_ref: "second".to_string(),
850                        exp: -1,
851                    }
852                ],
853            )
854        );
855        assert!(rows[0].deprecated_without_colon);
856    }
857
858    #[test]
859    fn parse_unit_deprecated_space_matches_colon_payload() {
860        let canonical = parse_single_spec(
861            r#"spec rates
862uses lemma units
863
864data money: measure
865  -> unit eur: 1.00
866
867data rate: measure
868  -> unit eur_per_hour: eur/hour
869"#,
870        );
871        let deprecated = parse_single_spec(
872            r#"spec rates
873uses lemma units
874
875data money: measure
876  -> unit eur 1.00
877
878data rate: measure
879  -> unit eur_per_hour eur/hour
880"#,
881        );
882        let canonical_args: Vec<UnitArg> = measure_unit_constraints(&canonical)
883            .iter()
884            .map(|row| unit_name_and_arg(row).1)
885            .collect();
886        let deprecated_args: Vec<UnitArg> = measure_unit_constraints(&deprecated)
887            .iter()
888            .map(|row| unit_name_and_arg(row).1)
889            .collect();
890        assert_eq!(canonical_args, deprecated_args);
891    }
892
893    #[test]
894    fn parse_unit_colon_without_payload_errors() {
895        let result = parse(
896            r#"spec s
897data money: measure
898  -> unit eur:
899"#,
900            crate::parsing::source::SourceType::Volatile,
901            &ResourceLimits::default(),
902        );
903        assert!(
904            result.is_err(),
905            "empty unit payload after colon must not parse"
906        );
907    }
908
909    fn rule_expression(source: &str, rule_name: &str) -> Expression {
910        let parsed = parse(
911            source,
912            crate::parsing::source::SourceType::Volatile,
913            &ResourceLimits::default(),
914        )
915        .expect("expected parse");
916        let spec = parsed
917            .flatten_specs()
918            .into_iter()
919            .next()
920            .expect("expected one spec");
921        spec.rules
922            .iter()
923            .find(|rule| rule.name == rule_name)
924            .unwrap_or_else(|| panic!("rule '{rule_name}' not found"))
925            .expression
926            .clone()
927    }
928
929    fn assert_multiply_range_side(
930        root: ExprRef<'_, ExpressionKind>,
931        range_on_left: bool,
932        label: &str,
933    ) {
934        let Some((left, ArithmeticComputation::Multiply, right)) = binary_arithmetic(root) else {
935            panic!("{label}: expected Multiply, got {:?}", root.kind());
936        };
937        let (range, other) = if range_on_left {
938            (left, right)
939        } else {
940            (right, left)
941        };
942        assert!(
943            matches!(range.kind(), ExpressionKind::RangeLiteral(..)),
944            "{label}: expected RangeLiteral on {} of *, got {:?}",
945            if range_on_left { "left" } else { "right" },
946            range.kind()
947        );
948        assert!(
949            !matches!(other.kind(), ExpressionKind::RangeLiteral(..)),
950            "{label}: expected non-range on other side of *"
951        );
952    }
953
954    #[test]
955    fn parse_range_binds_tighter_than_multiply() {
956        let base = r#"spec test
957uses lemma units
958data rate: measure -> unit eur: 1
959data period_start: 2026-01-01
960data period_end: 2026-01-02
961"#;
962        let rhs = rule_expression(
963            &format!("{base}rule rhs: rate * period_start...period_end"),
964            "rhs",
965        );
966        assert_multiply_range_side(rhs.root(), false, "rate * period_start...period_end");
967        let lhs = rule_expression(
968            &format!("{base}rule lhs: period_start...period_end * rate"),
969            "lhs",
970        );
971        assert_multiply_range_side(lhs.root(), true, "period_start...period_end * rate");
972    }
973
974    #[test]
975    fn parse_range_additive_right_endpoint_binds_inside_literal() {
976        let expression = rule_expression(
977            r#"spec test
978uses lemma units
979data start: date
980data length: units.duration
981rule valid: now in start...start + length"#,
982            "valid",
983        );
984        let root = expression.root();
985        let ExpressionKind::RangeContainment(value, range) = root.kind() else {
986            panic!("expected RangeContainment, got {:?}", root.kind());
987        };
988        let value = root.child(*value);
989        assert!(
990            matches!(value.kind(), ExpressionKind::Now),
991            "expected now as containment value, got {:?}",
992            value.kind()
993        );
994        let range = root.child(*range);
995        let ExpressionKind::RangeLiteral(left, right) = range.kind() else {
996            panic!(
997                "expected RangeLiteral as containment range, got {:?}",
998                range.kind()
999            );
1000        };
1001        let left = range.child(*left);
1002        assert!(
1003            matches!(left.kind(), ExpressionKind::Reference(..)),
1004            "expected start reference as left endpoint, got {:?}",
1005            left.kind()
1006        );
1007        let right = range.child(*right);
1008        let Some((add_left, ArithmeticComputation::Add, add_right)) = binary_arithmetic(right)
1009        else {
1010            panic!(
1011                "expected start + length as right endpoint, got {:?}",
1012                right.kind()
1013            );
1014        };
1015        assert!(
1016            matches!(add_left.kind(), ExpressionKind::Reference(..)),
1017            "expected start reference in right endpoint add, got {:?}",
1018            add_left.kind()
1019        );
1020        assert!(
1021            matches!(add_right.kind(), ExpressionKind::Reference(..)),
1022            "expected length reference in right endpoint add, got {:?}",
1023            add_right.kind()
1024        );
1025    }
1026
1027    #[test]
1028    fn parse_range_multiply_with_conversion_without_inner_parens() {
1029        let expression = rule_expression(
1030            r#"spec test
1031uses lemma units
1032data money: measure -> unit eur: 1
1033data rate: measure -> unit eur_per_hour: eur/hour
1034data hourly_rate: 50 eur_per_hour
1035data period_start: 2026-01-01
1036data period_end: 2026-01-02
1037rule pay: (hourly_rate * period_start...period_end) as eur"#,
1038            "pay",
1039        );
1040        let ExpressionKind::UnitConversion(inner, _) = expression.root_kind() else {
1041            panic!("expected UnitConversion, got {:?}", expression.root_kind());
1042        };
1043        assert_multiply_range_side(expression.at(*inner), false, "pay");
1044    }
1045
1046    #[test]
1047    fn parse_error_includes_attribute_and_parse_error_spec_name() {
1048        let result = parse(
1049            r#"
1050spec test
1051data name: "Unclosed string
1052data age: 25
1053"#,
1054            crate::parsing::source::SourceType::Volatile,
1055            &ResourceLimits::default(),
1056        );
1057
1058        match result {
1059            Err(Error::Parsing(details)) => {
1060                let src = details
1061                    .source
1062                    .as_ref()
1063                    .expect("BUG: parsing errors always have source");
1064                assert_eq!(
1065                    src.source_type,
1066                    crate::parsing::source::SourceType::Volatile
1067                );
1068            }
1069            Err(e) => panic!("Expected Parse error, got: {e:?}"),
1070            Ok(_) => panic!("Expected parse error for unclosed string"),
1071        }
1072    }
1073
1074    #[test]
1075    fn parse_single_spec_file() {
1076        let input = r#"spec somespec
1077data name: "Alice""#;
1078        let parsed = parse(
1079            input,
1080            crate::parsing::source::SourceType::Volatile,
1081            &ResourceLimits::default(),
1082        )
1083        .unwrap();
1084        let specs = parsed.flatten_specs();
1085        assert_eq!(specs.len(), 1);
1086        assert_eq!(specs[0].name, "somespec");
1087    }
1088
1089    #[test]
1090    fn parse_uses_registry_spec_explicit_alias() {
1091        let input = r#"spec example
1092uses external: @user/workspace somespec"#;
1093        let specs = parse(
1094            input,
1095            crate::parsing::source::SourceType::Volatile,
1096            &ResourceLimits::default(),
1097        )
1098        .unwrap()
1099        .into_flattened_specs();
1100        assert_eq!(specs.len(), 1);
1101        assert_eq!(specs[0].data.len(), 1);
1102        match &specs[0].data[0].value {
1103            crate::parsing::ast::DataValue::Import {
1104                spec_ref,
1105                bindings: _,
1106            } => {
1107                assert_eq!(spec_ref.name, "somespec");
1108                let repository_hdr = spec_ref
1109                    .repository
1110                    .as_ref()
1111                    .expect("expected repository qualifier");
1112                assert_eq!(repository_hdr.name, "@user/workspace");
1113            }
1114            other => panic!("Expected Import, got: {:?}", other),
1115        }
1116    }
1117
1118    #[test]
1119    fn parse_multiple_specs_cross_reference_in_file() {
1120        let input = r#"spec spec_a
1121data x: 10
1122
1123spec spec_b
1124data y: 20
1125uses a: spec_a"#;
1126        let parsed = parse(
1127            input,
1128            crate::parsing::source::SourceType::Volatile,
1129            &ResourceLimits::default(),
1130        )
1131        .unwrap();
1132        let specs = parsed.flatten_specs();
1133        assert_eq!(specs.len(), 2);
1134        assert_eq!(specs[0].name, "spec_a");
1135        assert_eq!(specs[1].name, "spec_b");
1136    }
1137
1138    #[test]
1139    fn parse_uses_registry_spec_default_alias() {
1140        let input = "spec example\nuses @owner/repo somespec";
1141        let specs = parse(
1142            input,
1143            crate::parsing::source::SourceType::Volatile,
1144            &ResourceLimits::default(),
1145        )
1146        .unwrap()
1147        .into_flattened_specs();
1148        match &specs[0].data[0].value {
1149            crate::parsing::ast::DataValue::Import {
1150                spec_ref,
1151                bindings: _,
1152            } => {
1153                assert_eq!(spec_ref.name, "somespec");
1154                let repository_hdr = spec_ref
1155                    .repository
1156                    .as_ref()
1157                    .expect("expected repository qualifier");
1158                assert_eq!(repository_hdr.name, "@owner/repo");
1159            }
1160            other => panic!("Expected Import, got: {:?}", other),
1161        }
1162    }
1163
1164    #[test]
1165    fn parse_uses_local_spec_default_alias() {
1166        let input = "spec example\nuses myspec";
1167        let specs = parse(
1168            input,
1169            crate::parsing::source::SourceType::Volatile,
1170            &ResourceLimits::default(),
1171        )
1172        .unwrap()
1173        .into_flattened_specs();
1174        match &specs[0].data[0].value {
1175            crate::parsing::ast::DataValue::Import {
1176                spec_ref,
1177                bindings: _,
1178            } => {
1179                assert_eq!(spec_ref.name, "myspec");
1180                assert!(
1181                    spec_ref.repository.is_none(),
1182                    "same-repository reference must omit repository qualifier"
1183                );
1184            }
1185            other => panic!("Expected Import, got: {:?}", other),
1186        }
1187    }
1188
1189    #[test]
1190    fn parse_spec_name_with_trailing_dot_is_opaque_name() {
1191        let input = "spec myspec.\ndata x: 1";
1192        let result = parse(
1193            input,
1194            crate::parsing::source::SourceType::Volatile,
1195            &ResourceLimits::default(),
1196        )
1197        .unwrap()
1198        .into_flattened_specs();
1199        assert_eq!(result[0].name, "myspec.");
1200    }
1201
1202    #[test]
1203    fn parse_multiple_specs_in_same_file() {
1204        let input = "spec myspec_a\nrule x: 1\n\nspec myspec_b\nrule x: 2";
1205        let result = parse(
1206            input,
1207            crate::parsing::source::SourceType::Volatile,
1208            &ResourceLimits::default(),
1209        )
1210        .unwrap()
1211        .into_flattened_specs();
1212        assert_eq!(result.len(), 2);
1213        assert_eq!(result[0].name, "myspec_a");
1214        assert_eq!(result[1].name, "myspec_b");
1215    }
1216
1217    #[test]
1218    fn parse_uses_accepts_name_only() {
1219        let input = "spec consumer\nuses other";
1220        let result = parse(
1221            input,
1222            crate::parsing::source::SourceType::Volatile,
1223            &ResourceLimits::default(),
1224        );
1225        assert!(result.is_ok(), "uses name should parse");
1226        let specs = result.unwrap().into_flattened_specs();
1227        let spec_ref = match &specs[0].data[0].value {
1228            crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1229            _ => panic!("expected Import"),
1230        };
1231        assert_eq!(spec_ref.name, "other");
1232    }
1233
1234    #[test]
1235    fn parse_uses_bare_year_effective() {
1236        let input = "spec consumer\nuses other 2026";
1237        let result = parse(
1238            input,
1239            crate::parsing::source::SourceType::Volatile,
1240            &ResourceLimits::default(),
1241        )
1242        .unwrap();
1243        let specs = result.into_flattened_specs();
1244        let spec_ref = match &specs[0].data[0].value {
1245            crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1246            _ => panic!("expected Import"),
1247        };
1248        assert_eq!(spec_ref.name, "other");
1249        let eff = spec_ref.effective.as_ref().expect("effective");
1250        assert_eq!(eff.year, 2026);
1251        assert_eq!(eff.month, 1);
1252        assert_eq!(eff.day, 1);
1253    }
1254
1255    #[test]
1256    fn parse_uses_registry_spec_ref_records_repository_and_target_spans() {
1257        let input = "spec consumer\nuses @iso/countries alpha2 2026";
1258        let result = parse(
1259            input,
1260            crate::parsing::source::SourceType::Volatile,
1261            &ResourceLimits::default(),
1262        )
1263        .unwrap();
1264        let spec = &result.flatten_specs()[0];
1265        let sr = match &spec.data[0].value {
1266            crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1267            _ => panic!("expected Import"),
1268        };
1269        let rs = sr
1270            .repository_span
1271            .as_ref()
1272            .expect("repository_span should be set for @-qualified uses");
1273        let ts = sr
1274            .target_span
1275            .as_ref()
1276            .expect("target_span should cover spec name and effective");
1277        assert_eq!(&input[rs.start..rs.end], "@iso/countries");
1278        assert_eq!(&input[ts.start..ts.end], "alpha2 2026");
1279    }
1280
1281    #[test]
1282    fn parse_uses_alias_no_comma_continuation() {
1283        let input = "spec consumer\nuses alias: pricing retail\ndata x: 1";
1284        let result = parse(
1285            input,
1286            crate::parsing::source::SourceType::Volatile,
1287            &ResourceLimits::default(),
1288        )
1289        .unwrap();
1290        let data = &result.flatten_specs()[0].data;
1291        assert_eq!(data.len(), 2);
1292        assert_eq!(data[0].reference.name, "alias");
1293        let sr = match &data[0].value {
1294            crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1295            _ => panic!("expected Import"),
1296        };
1297        assert_eq!(sr.name, "retail");
1298        let repository_hdr = sr
1299            .repository
1300            .as_ref()
1301            .expect("expected repository qualifier");
1302        assert_eq!(repository_hdr.name, "pricing");
1303    }
1304
1305    #[test]
1306    fn parse_data_qualified_type_with_effective_and_repository_on_uses() {
1307        let input = "spec consumer\nuses @iso/countries alpha2 2026-06-01\ndata country: alpha2.code -> option \"NL\"";
1308        let result = parse(
1309            input,
1310            crate::parsing::source::SourceType::Volatile,
1311            &ResourceLimits::default(),
1312        )
1313        .unwrap()
1314        .into_flattened_specs();
1315        let spec_ref = match &result[0].data[0].value {
1316            crate::parsing::ast::DataValue::Import { spec_ref: sr, .. } => sr,
1317            other => panic!("expected Import on uses row, got: {:?}", other),
1318        };
1319        assert_eq!(spec_ref.name, "alpha2");
1320
1321        let eff = spec_ref
1322            .effective
1323            .as_ref()
1324            .expect("expected effective datetime");
1325        assert_eq!(eff.year, 2026);
1326        assert_eq!(eff.month, 6);
1327
1328        let qualifier = spec_ref
1329            .repository
1330            .as_ref()
1331            .expect("expected repository qualifier");
1332        assert_eq!(qualifier.name, "@iso/countries");
1333
1334        match &result[0].data[1].value {
1335            crate::parsing::ast::DataValue::Definition {
1336                base,
1337                constraints,
1338                value,
1339            } => {
1340                assert!(value.is_none());
1341                assert_eq!(
1342                    base.as_ref().expect("expected base"),
1343                    &crate::parsing::ast::ParentType::Qualified {
1344                        spec_alias: "alpha2".into(),
1345                        inner: Box::new(crate::parsing::ast::ParentType::Custom {
1346                            name: "code".into(),
1347                        }),
1348                    }
1349                );
1350
1351                let cs = constraints
1352                    .as_ref()
1353                    .expect("expected trailing constraint chain");
1354                assert_eq!(cs.len(), 1);
1355            }
1356            other => panic!("expected Definition, got: {:?}", other),
1357        }
1358    }
1359
1360    #[test]
1361    fn parse_qualified_type_with_slashed_opaque_spec_name() {
1362        let input = r#"
1363spec logistics/terms
1364data incoterm_type: text
1365
1366spec logistics/shipping
1367uses logistics/terms
1368data incoterm: logistics/terms.incoterm_type
1369"#;
1370        let result = parse(
1371            input,
1372            crate::parsing::source::SourceType::Volatile,
1373            &ResourceLimits::default(),
1374        )
1375        .unwrap()
1376        .into_flattened_specs();
1377        assert_eq!(result.len(), 2);
1378        assert_eq!(result[0].name, "logistics/terms");
1379        assert_eq!(result[1].name, "logistics/shipping");
1380        assert_eq!(result[1].data[0].reference.name, "logistics/terms");
1381        let import = match &result[1].data[0].value {
1382            crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1383            other => panic!("expected Import, got: {other:?}"),
1384        };
1385        assert_eq!(import.name, "logistics/terms");
1386        assert!(import.repository.is_none());
1387        match &result[1].data[1].value {
1388            crate::parsing::ast::DataValue::Definition {
1389                base: Some(base), ..
1390            } => {
1391                assert_eq!(
1392                    base,
1393                    &crate::parsing::ast::ParentType::Qualified {
1394                        spec_alias: "logistics/terms".into(),
1395                        inner: Box::new(crate::parsing::ast::ParentType::Custom {
1396                            name: "incoterm_type".into(),
1397                        }),
1398                    }
1399                );
1400            }
1401            other => panic!("expected Definition with qualified base, got: {other:?}"),
1402        }
1403    }
1404
1405    #[test]
1406    fn parse_uses_slashed_name_alias_is_full_opaque_name() {
1407        let input = "spec consumer\nuses finance/units\ndata x: 1";
1408        let result = parse(
1409            input,
1410            crate::parsing::source::SourceType::Volatile,
1411            &ResourceLimits::default(),
1412        )
1413        .unwrap()
1414        .into_flattened_specs();
1415        assert_eq!(result[0].data[0].reference.name, "finance/units");
1416        let sr = match &result[0].data[0].value {
1417            crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1418            other => panic!("expected Import, got: {other:?}"),
1419        };
1420        assert_eq!(sr.name, "finance/units");
1421        assert!(sr.repository.is_none());
1422    }
1423
1424    #[test]
1425    fn parse_uses_terms_is_spec_named_terms_not_sibling_of_logistics() {
1426        let input = "spec logistics/shipping\nuses terms\ndata x: 1";
1427        let result = parse(
1428            input,
1429            crate::parsing::source::SourceType::Volatile,
1430            &ResourceLimits::default(),
1431        )
1432        .unwrap()
1433        .into_flattened_specs();
1434        assert_eq!(result[0].data[0].reference.name, "terms");
1435        let sr = match &result[0].data[0].value {
1436            crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1437            other => panic!("expected Import, got: {other:?}"),
1438        };
1439        assert_eq!(sr.name, "terms");
1440        assert!(sr.repository.is_none());
1441    }
1442
1443    #[test]
1444    fn parse_error_is_returned_for_garbage_input() {
1445        let result = parse(
1446            r#"
1447spec test
1448this is not valid lemma syntax @#$%
1449"#,
1450            crate::parsing::source::SourceType::Volatile,
1451            &ResourceLimits::default(),
1452        );
1453
1454        assert!(result.is_err(), "Should fail on malformed input");
1455        match result {
1456            Err(Error::Parsing { .. }) => {
1457                // Expected
1458            }
1459            Err(e) => panic!("Expected Parse error, got: {e:?}"),
1460            Ok(_) => panic!("Expected parse error"),
1461        }
1462    }
1463
1464    // ─── Parser-level pins for DataValue variants ────────────────────
1465
1466    #[test]
1467    fn parse_local_with_literal_rejected() {
1468        let err = parse(
1469            r#"spec s
1470with x: 42"#,
1471            crate::parsing::source::SourceType::Volatile,
1472            &ResourceLimits::default(),
1473        )
1474        .unwrap_err();
1475        let msg = err.to_string();
1476        assert!(
1477            msg.contains("Standalone") || msg.contains("uses"),
1478            "expected standalone with rejection, got: {msg}"
1479        );
1480    }
1481
1482    #[test]
1483    fn parse_local_with_import_reference_rejected() {
1484        let err = parse(
1485            r#"spec s
1486uses i: inner
1487with copy: i.v"#,
1488            crate::parsing::source::SourceType::Volatile,
1489            &ResourceLimits::default(),
1490        )
1491        .unwrap_err();
1492        let msg = err.to_string();
1493        assert!(
1494            msg.contains("Standalone") || msg.contains("uses"),
1495            "expected standalone with rejection, got: {msg}"
1496        );
1497    }
1498
1499    #[test]
1500    fn parse_local_with_dotted_rhs_rejected() {
1501        let err = parse(
1502            r#"spec s
1503with x: a.something"#,
1504            crate::parsing::source::SourceType::Volatile,
1505            &ResourceLimits::default(),
1506        )
1507        .unwrap_err();
1508        let msg = err.to_string();
1509        assert!(
1510            msg.contains("Standalone") || msg.contains("uses"),
1511            "expected standalone with rejection, got: {msg}"
1512        );
1513    }
1514
1515    #[test]
1516    fn parse_local_with_multi_segment_rhs_rejected() {
1517        let err = parse(
1518            r#"spec s
1519with x: alpha.beta.gamma.delta"#,
1520            crate::parsing::source::SourceType::Volatile,
1521            &ResourceLimits::default(),
1522        )
1523        .unwrap_err();
1524        let msg = err.to_string();
1525        assert!(
1526            msg.contains("Standalone") || msg.contains("uses"),
1527            "expected standalone with rejection, got: {msg}"
1528        );
1529    }
1530
1531    /// `data x: notdotted` (local LHS, non-dotted RHS) MUST stay a
1532    /// Definition with an explicit custom parent — not silently reinterpreted as a Reference.
1533    #[test]
1534    fn parse_local_non_dotted_rhs_stays_definition_with_custom_base() {
1535        let input = r#"spec s
1536data x: myothertype"#;
1537        let result = parse(
1538            input,
1539            crate::parsing::source::SourceType::Volatile,
1540            &ResourceLimits::default(),
1541        )
1542        .unwrap()
1543        .into_flattened_specs();
1544        let value = &result[0].data[0].value;
1545        assert!(
1546            matches!(
1547                value,
1548                crate::parsing::ast::DataValue::Definition {
1549                    base: Some(crate::parsing::ast::ParentType::Custom { .. }),
1550                    ..
1551                }
1552            ),
1553            "non-dotted local RHS must stay Definition with custom base, got: {:?}",
1554            value
1555        );
1556    }
1557
1558    /// `uses` block `-> with path: reference` stores reference in binding rhs.
1559    #[test]
1560    fn parse_uses_binding_non_dotted_rhs_is_reference() {
1561        let input = r#"spec s
1562uses child: other
1563  -> with slot: somename"#;
1564        let result = parse(
1565            input,
1566            crate::parsing::source::SourceType::Volatile,
1567            &ResourceLimits::default(),
1568        )
1569        .unwrap()
1570        .into_flattened_specs();
1571        let bindings = match &result[0].data[0].value {
1572            crate::parsing::ast::DataValue::Import { bindings, .. } => bindings,
1573            other => panic!("expected Import, got: {:?}", other),
1574        };
1575        assert_eq!(bindings.len(), 1);
1576        assert!(
1577            matches!(
1578                &bindings[0].rhs,
1579                crate::parsing::ast::WithRhs::Reference { .. }
1580            ),
1581            "non-dotted RHS must yield Reference; got: {:?}",
1582            bindings[0].rhs
1583        );
1584    }
1585
1586    /// `data x: spec …` is invalid; spec imports use `uses`.
1587    #[test]
1588    fn parse_data_colon_spec_rhs_is_rejected() {
1589        let result = parse(
1590            r#"
1591spec s
1592data x: spec other
1593"#,
1594            crate::parsing::source::SourceType::Volatile,
1595            &ResourceLimits::default(),
1596        );
1597        match result {
1598            Ok(_) => panic!("`data x: spec other` must fail to parse"),
1599            Err(err) => {
1600                let msg = err.to_string();
1601                assert!(
1602                    msg.contains("uses") && msg.contains("spec"),
1603                    "error must direct to `uses` for spec import, got: {msg}"
1604                );
1605            }
1606        }
1607    }
1608
1609    /// `uses` block dotted path and dotted reference rhs preserved.
1610    #[test]
1611    fn parse_uses_binding_with_dotted_rhs_preserves_both_sides() {
1612        let input = r#"spec s
1613uses outer: other
1614  -> with inner: target.field"#;
1615        let result = parse(
1616            input,
1617            crate::parsing::source::SourceType::Volatile,
1618            &ResourceLimits::default(),
1619        )
1620        .unwrap()
1621        .into_flattened_specs();
1622        let bindings = match &result[0].data[0].value {
1623            crate::parsing::ast::DataValue::Import { bindings, .. } => bindings,
1624            other => panic!("expected Import, got: {:?}", other),
1625        };
1626        assert_eq!(bindings.len(), 1);
1627        assert_eq!(bindings[0].path.segments, vec![] as Vec<String>);
1628        assert_eq!(bindings[0].path.name, "inner");
1629        match &bindings[0].rhs {
1630            crate::parsing::ast::WithRhs::Reference { target } => {
1631                assert_eq!(target.segments, vec!["target"]);
1632                assert_eq!(target.name, "field");
1633            }
1634            other => panic!("expected Reference rhs, got: {:?}", other),
1635        }
1636    }
1637
1638    #[test]
1639    fn parse_deprecated_standalone_with_is_error() {
1640        let input = r#"spec inner
1641data x: number
1642
1643spec outer
1644uses i: inner
1645with i.x: 42
1646rule r: i.x"#;
1647        let result = parse(
1648            input,
1649            crate::parsing::source::SourceType::Volatile,
1650            &ResourceLimits::default(),
1651        );
1652        match result {
1653            Ok(_) => panic!("standalone with after uses must not parse"),
1654            Err(err) => {
1655                let msg = err.to_string();
1656                assert!(
1657                    msg.contains("with"),
1658                    "error should mention with; got: {msg}"
1659                );
1660            }
1661        }
1662    }
1663
1664    #[test]
1665    fn parse_deprecated_standalone_with_before_uses_is_error() {
1666        let input = r#"spec inner
1667data slot: number
1668
1669spec outer
1670with i.slot: 99
1671uses i: inner
1672rule r: i.slot"#;
1673        let result = parse(
1674            input,
1675            crate::parsing::source::SourceType::Volatile,
1676            &ResourceLimits::default(),
1677        );
1678        match result {
1679            Ok(_) => panic!("standalone with before uses must not parse"),
1680            Err(err) => {
1681                let msg = err.to_string();
1682                assert!(
1683                    msg.contains("with"),
1684                    "error should mention with; got: {msg}"
1685                );
1686            }
1687        }
1688    }
1689
1690    #[test]
1691    fn parse_standalone_with_without_matching_uses_errors() {
1692        let result = parse(
1693            r#"spec s
1694with outer.inner: 1"#,
1695            crate::parsing::source::SourceType::Volatile,
1696            &ResourceLimits::default(),
1697        );
1698        match result {
1699            Ok(_) => panic!("standalone with without uses must not parse"),
1700            Err(err) => {
1701                let msg = err.to_string();
1702                assert!(
1703                    msg.contains("with"),
1704                    "error should mention with; got: {msg}"
1705                );
1706            }
1707        }
1708    }
1709
1710    #[test]
1711    fn parse_data_on_binding_path_is_rejected_with_with_hint() {
1712        let result = parse(
1713            r#"spec s
1714data outer.inner: 1"#,
1715            crate::parsing::source::SourceType::Volatile,
1716            &ResourceLimits::default(),
1717        );
1718        match result {
1719            Ok(_) => panic!("data with binding path must not parse"),
1720            Err(err) => {
1721                let msg = err.to_string();
1722                assert!(
1723                    msg.contains("with"),
1724                    "error should steer authors toward with; got: {msg}"
1725                );
1726            }
1727        }
1728    }
1729
1730    #[test]
1731    fn parse_bare_file_yields_single_anonymous_repository_group() {
1732        let input = "spec a\ndata x: 1\nspec b\ndata y: 2";
1733        let parsed = parse(
1734            input,
1735            crate::parsing::source::SourceType::Volatile,
1736            &ResourceLimits::default(),
1737        )
1738        .unwrap();
1739        assert_eq!(parsed.repositories.len(), 1);
1740        let (repo, specs) = parsed.repositories.iter().next().unwrap();
1741        assert!(repo.name.is_none());
1742        assert_eq!(specs.len(), 2);
1743        assert_eq!(specs[0].name, "a");
1744        assert_eq!(specs[1].name, "b");
1745    }
1746
1747    #[test]
1748    fn parse_repo_sections_preserve_order_and_names() {
1749        let input = r#"repo r1
1750
1751spec a
1752data x: 1
1753
1754repo r2
1755
1756spec b
1757data y: 2"#;
1758        let parsed = parse(
1759            input,
1760            crate::parsing::source::SourceType::Volatile,
1761            &ResourceLimits::default(),
1762        )
1763        .unwrap();
1764        assert_eq!(parsed.repositories.len(), 2);
1765        let keys: Vec<_> = parsed.repositories.keys().collect();
1766        assert_eq!(keys[0].name.as_deref(), Some("r1"));
1767        assert_eq!(keys[1].name.as_deref(), Some("r2"));
1768    }
1769
1770    #[test]
1771    fn parse_duplicate_repo_name_merges_spec_lists() {
1772        let input = r#"repo dup
1773
1774spec a
1775data x: 1
1776
1777repo dup
1778
1779spec b
1780data y: 2"#;
1781        let parsed = parse(
1782            input,
1783            crate::parsing::source::SourceType::Volatile,
1784            &ResourceLimits::default(),
1785        )
1786        .unwrap();
1787        assert_eq!(parsed.repositories.len(), 1);
1788        assert_eq!(parsed.flatten_specs().len(), 2);
1789    }
1790
1791    #[test]
1792    fn parse_repo_with_no_specs_then_eof_yields_empty_spec_vec_for_that_repo() {
1793        let input = "repo empty";
1794        let parsed = parse(
1795            input,
1796            crate::parsing::source::SourceType::Volatile,
1797            &ResourceLimits::default(),
1798        )
1799        .unwrap();
1800        assert_eq!(parsed.repositories.len(), 1);
1801        let (_, specs) = parsed.repositories.iter().next().unwrap();
1802        assert_eq!(specs.len(), 0);
1803    }
1804
1805    #[test]
1806    fn parse_repo_followed_by_repo_without_specs_first_repo_empty_second_has_spec() {
1807        let input = "repo a\n\nrepo b\n\nspec s\ndata x: 1";
1808        let parsed = parse(
1809            input,
1810            crate::parsing::source::SourceType::Volatile,
1811            &ResourceLimits::default(),
1812        )
1813        .unwrap();
1814        assert_eq!(parsed.repositories.len(), 2);
1815        let names: Vec<_> = parsed
1816            .repositories
1817            .keys()
1818            .map(|r| r.name.as_deref())
1819            .collect();
1820        assert_eq!(names, vec![Some("a"), Some("b")]);
1821        assert!(parsed.repositories.values().next().unwrap().is_empty());
1822        assert_eq!(parsed.repositories.values().nth(1).unwrap().len(), 1);
1823    }
1824
1825    #[test]
1826    fn parse_spec_named_repo_keyword_should_be_rejected() {
1827        assert!(
1828            parse(
1829                "spec repo\ndata x: 1",
1830                crate::parsing::source::SourceType::Volatile,
1831                &ResourceLimits::default(),
1832            )
1833            .is_err(),
1834            "spec must not be allowed to use reserved keyword `repo` as its name"
1835        );
1836    }
1837
1838    #[test]
1839    fn parse_repo_declaration_cannot_use_spec_keyword_as_repository_name() {
1840        assert!(
1841            parse(
1842                "repo spec\n\nspec z\ndata q: 1\nrule r: q",
1843                crate::parsing::source::SourceType::Volatile,
1844                &ResourceLimits::default(),
1845            )
1846            .is_err(),
1847            "repository name cannot be the token `spec`"
1848        );
1849    }
1850
1851    #[test]
1852    fn parse_repo_declaration_cannot_use_data_keyword_as_repository_name() {
1853        assert!(
1854            parse(
1855                "repo data\n\nspec z\ndata q: 1\nrule r: q",
1856                crate::parsing::source::SourceType::Volatile,
1857                &ResourceLimits::default(),
1858            )
1859            .is_err(),
1860            "repository name cannot be the token `data`"
1861        );
1862    }
1863
1864    #[test]
1865    fn parse_repo_declaration_cannot_use_rule_keyword_as_repository_name() {
1866        assert!(
1867            parse(
1868                "repo rule\n\nspec z\ndata q: 1\nrule r: q",
1869                crate::parsing::source::SourceType::Volatile,
1870                &ResourceLimits::default(),
1871            )
1872            .is_err(),
1873            "repository name cannot be the token `rule`"
1874        );
1875    }
1876
1877    #[test]
1878    fn parse_data_named_repo_keyword_is_rejected() {
1879        let err = parse(
1880            "spec s\ndata repo: 1",
1881            crate::parsing::source::SourceType::Volatile,
1882            &ResourceLimits::default(),
1883        )
1884        .unwrap_err();
1885        assert!(
1886            err.to_string().contains("repo"),
1887            "data named repo should not parse: {}",
1888            err
1889        );
1890    }
1891
1892    #[test]
1893    fn parse_rule_named_repo_keyword_is_rejected() {
1894        let err = parse(
1895            "spec s\ndata x: 1\nrule repo: x",
1896            crate::parsing::source::SourceType::Volatile,
1897            &ResourceLimits::default(),
1898        )
1899        .unwrap_err();
1900        let msg = err.to_string();
1901        assert!(
1902            msg.contains("repo") || msg.contains("reserved"),
1903            "rule named repo should not parse: {msg}"
1904        );
1905    }
1906
1907    #[test]
1908    fn parse_repo_declaration_accepts_non_keyword_repository_identifier() {
1909        let parsed = parse(
1910            "repo warehouse\n\nspec z\ndata q: 1\nrule r: q",
1911            crate::parsing::source::SourceType::Volatile,
1912            &ResourceLimits::default(),
1913        )
1914        .unwrap();
1915        assert_eq!(parsed.repositories.len(), 1);
1916        assert_eq!(
1917            parsed.repositories.keys().next().unwrap().name.as_deref(),
1918            Some("warehouse")
1919        );
1920    }
1921
1922    #[test]
1923    fn parse_repo_name_case_insensitive_same_repository_merged() {
1924        let input = "repo Foo\n\nspec a\ndata x: 1\n\nrepo foo\n\nspec b\ndata y: 2";
1925        let parsed = parse(
1926            input,
1927            crate::parsing::source::SourceType::Volatile,
1928            &ResourceLimits::default(),
1929        )
1930        .unwrap();
1931        assert_eq!(
1932            parsed.repositories.len(),
1933            1,
1934            "Foo and foo are the same repository after canonicalization"
1935        );
1936        let specs: Vec<_> = parsed.repositories.values().next().unwrap().clone();
1937        assert_eq!(specs.len(), 2);
1938        assert_eq!(specs[0].name, "a");
1939        assert_eq!(specs[1].name, "b");
1940    }
1941
1942    #[test]
1943    fn parse_repo_empty_name_errors() {
1944        let err = parse(
1945            "repo \nspec a\ndata x: 1",
1946            crate::parsing::source::SourceType::Volatile,
1947            &ResourceLimits::default(),
1948        )
1949        .unwrap_err();
1950        assert!(
1951            !err.to_string().is_empty(),
1952            "empty repo name should not parse quietly: {err}"
1953        );
1954    }
1955
1956    #[test]
1957    fn parse_repo_numeric_name_behavior() {
1958        let input = "repo 123\n\nspec a\ndata x: 1";
1959        let result = parse(
1960            input,
1961            crate::parsing::source::SourceType::Volatile,
1962            &ResourceLimits::default(),
1963        );
1964        match result {
1965            Ok(parsed) => {
1966                assert_eq!(
1967                    parsed.repositories.keys().next().unwrap().name.as_deref(),
1968                    Some("123"),
1969                    "if numeric repo names parse, identity must be stable"
1970                );
1971            }
1972            Err(e) => {
1973                assert!(
1974                    !e.to_string().is_empty(),
1975                    "rejecting numeric repo name is ok if explicit: {e}"
1976                );
1977            }
1978        }
1979    }
1980
1981    #[test]
1982    fn parse_duplicate_repo_three_sections_preserves_spec_order_abc() {
1983        let input = r#"repo dup
1984
1985spec a
1986data x: 1
1987
1988repo dup
1989
1990spec b
1991data y: 2
1992
1993repo dup
1994
1995spec c
1996data z: 3"#;
1997        let parsed = parse(
1998            input,
1999            crate::parsing::source::SourceType::Volatile,
2000            &ResourceLimits::default(),
2001        )
2002        .unwrap();
2003        assert_eq!(parsed.repositories.len(), 1);
2004        let specs = parsed.repositories.values().next().unwrap();
2005        assert_eq!(
2006            specs.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
2007            vec!["a", "b", "c"]
2008        );
2009    }
2010
2011    #[test]
2012    fn parse_repo_single_section_roundtrips_through_formatter() {
2013        let input = "repo r\n\nspec a\ndata x: 1";
2014        let parsed = parse(
2015            input,
2016            crate::parsing::source::SourceType::Volatile,
2017            &ResourceLimits::default(),
2018        )
2019        .unwrap();
2020        let formatted = format_parse_result(&parsed);
2021        let again = parse(
2022            &formatted,
2023            crate::parsing::source::SourceType::Volatile,
2024            &ResourceLimits::default(),
2025        )
2026        .unwrap();
2027        assert_eq!(again.repositories.len(), parsed.repositories.len());
2028        assert_eq!(again.flatten_specs().len(), parsed.flatten_specs().len());
2029        assert_eq!(
2030            again.flatten_specs()[0].name,
2031            parsed.flatten_specs()[0].name
2032        );
2033    }
2034
2035    #[test]
2036    fn parse_repo_two_sections_roundtrips_through_formatter() {
2037        let input = "repo r1\n\nspec a\ndata x: 1\n\nrepo r2\n\nspec b\ndata y: 2";
2038        let parsed = parse(
2039            input,
2040            crate::parsing::source::SourceType::Volatile,
2041            &ResourceLimits::default(),
2042        )
2043        .unwrap();
2044        let formatted = format_parse_result(&parsed);
2045        let again = parse(
2046            &formatted,
2047            crate::parsing::source::SourceType::Volatile,
2048            &ResourceLimits::default(),
2049        )
2050        .unwrap();
2051        assert_eq!(again.repositories.len(), 2);
2052        assert_eq!(again.flatten_specs().len(), 2);
2053    }
2054
2055    #[test]
2056    fn parse_repo_duplicate_merge_formatter_emits_single_repo_block_or_equivalent_parse() {
2057        let input = r#"repo dup
2058
2059spec a
2060data x: 1
2061
2062repo dup
2063
2064spec b
2065data y: 2"#;
2066        let parsed = parse(
2067            input,
2068            crate::parsing::source::SourceType::Volatile,
2069            &ResourceLimits::default(),
2070        )
2071        .unwrap();
2072        let formatted = format_parse_result(&parsed);
2073        let again = parse(
2074            &formatted,
2075            crate::parsing::source::SourceType::Volatile,
2076            &ResourceLimits::default(),
2077        )
2078        .unwrap();
2079        assert_eq!(
2080            again.repositories.len(),
2081            1,
2082            "formatted duplicate-repo file must still merge to one logical repo"
2083        );
2084        assert_eq!(again.flatten_specs().len(), 2);
2085    }
2086
2087    #[test]
2088    fn parse_rejects_data_named_measure() {
2089        let result = parse(
2090            "spec s\ndata measure: 1",
2091            crate::parsing::source::SourceType::Volatile,
2092            &ResourceLimits::default(),
2093        );
2094        assert!(
2095            result.is_err(),
2096            "data named measure (type keyword) must be rejected"
2097        );
2098    }
2099
2100    #[test]
2101    fn parse_rejects_data_named_number() {
2102        let result = parse(
2103            "spec s\ndata number: 42",
2104            crate::parsing::source::SourceType::Volatile,
2105            &ResourceLimits::default(),
2106        );
2107        assert!(
2108            result.is_err(),
2109            "data named number (type keyword) must be rejected"
2110        );
2111    }
2112
2113    #[test]
2114    fn parse_rejects_data_named_text() {
2115        let result = parse(
2116            "spec s\ndata text: \"hello\"",
2117            crate::parsing::source::SourceType::Volatile,
2118            &ResourceLimits::default(),
2119        );
2120        assert!(
2121            result.is_err(),
2122            "data named text (type keyword) must be rejected"
2123        );
2124    }
2125
2126    #[test]
2127    fn parse_rejects_data_named_date() {
2128        let result = parse(
2129            "spec s\ndata date: 2024-01-01",
2130            crate::parsing::source::SourceType::Volatile,
2131            &ResourceLimits::default(),
2132        );
2133        assert!(
2134            result.is_err(),
2135            "data named date (type keyword) must be rejected"
2136        );
2137    }
2138
2139    #[test]
2140    fn parse_rejects_data_named_boolean() {
2141        let result = parse(
2142            "spec s\ndata boolean: true",
2143            crate::parsing::source::SourceType::Volatile,
2144            &ResourceLimits::default(),
2145        );
2146        assert!(
2147            result.is_err(),
2148            "data named boolean (type keyword) must be rejected"
2149        );
2150    }
2151
2152    #[test]
2153    fn parse_rejects_data_named_ratio() {
2154        let result = parse(
2155            "spec s\ndata ratio: 5%",
2156            crate::parsing::source::SourceType::Volatile,
2157            &ResourceLimits::default(),
2158        );
2159        assert!(
2160            result.is_err(),
2161            "data named ratio (type keyword) must be rejected"
2162        );
2163    }
2164
2165    #[test]
2166    fn parse_rejects_rule_named_measure() {
2167        let result = parse(
2168            "spec s\ndata x: 1\nrule measure: x",
2169            crate::parsing::source::SourceType::Volatile,
2170            &ResourceLimits::default(),
2171        );
2172        assert!(
2173            result.is_err(),
2174            "rule named measure (type keyword) must be rejected"
2175        );
2176    }
2177
2178    #[test]
2179    fn parse_rejects_rule_named_number() {
2180        let result = parse(
2181            "spec s\ndata x: 1\nrule number: x",
2182            crate::parsing::source::SourceType::Volatile,
2183            &ResourceLimits::default(),
2184        );
2185        assert!(
2186            result.is_err(),
2187            "rule named number (type keyword) must be rejected"
2188        );
2189    }
2190
2191    #[test]
2192    fn parse_rejects_rule_named_text() {
2193        let result = parse(
2194            "spec s\ndata x: 1\nrule text: x",
2195            crate::parsing::source::SourceType::Volatile,
2196            &ResourceLimits::default(),
2197        );
2198        assert!(
2199            result.is_err(),
2200            "rule named text (type keyword) must be rejected"
2201        );
2202    }
2203
2204    #[test]
2205    fn parse_rejects_rule_named_date() {
2206        let result = parse(
2207            "spec s\ndata x: 1\nrule date: x",
2208            crate::parsing::source::SourceType::Volatile,
2209            &ResourceLimits::default(),
2210        );
2211        assert!(
2212            result.is_err(),
2213            "rule named date (type keyword) must be rejected"
2214        );
2215    }
2216
2217    #[test]
2218    fn parse_rejects_rule_named_boolean() {
2219        let result = parse(
2220            "spec s\ndata x: 1\nrule boolean: x",
2221            crate::parsing::source::SourceType::Volatile,
2222            &ResourceLimits::default(),
2223        );
2224        assert!(
2225            result.is_err(),
2226            "rule named boolean (type keyword) must be rejected"
2227        );
2228    }
2229
2230    #[test]
2231    fn parse_rejects_rule_named_ratio() {
2232        let result = parse(
2233            "spec s\ndata x: 1\nrule ratio: x",
2234            crate::parsing::source::SourceType::Volatile,
2235            &ResourceLimits::default(),
2236        );
2237        assert!(
2238            result.is_err(),
2239            "rule named ratio (type keyword) must be rejected"
2240        );
2241    }
2242
2243    fn assert_reserved_name(source: &str) {
2244        let result = parse(
2245            source,
2246            crate::parsing::source::SourceType::Volatile,
2247            &ResourceLimits::default(),
2248        );
2249        let message = result
2250            .expect_err("reserved name must not parse")
2251            .to_string();
2252        assert!(
2253            message.contains("reserved keyword"),
2254            "expected reserved-keyword error, got: {message}"
2255        );
2256    }
2257
2258    #[test]
2259    fn parse_rejects_lower_and_upper_as_names() {
2260        assert_reserved_name("spec s\ndata lower: 1");
2261        assert_reserved_name("spec s\ndata upper: 1");
2262        assert_reserved_name("spec s\nrule lower: 1");
2263        assert_reserved_name("spec s\nrule upper: 1");
2264    }
2265
2266    #[test]
2267    fn parse_constraint_lower_and_upper_still_parse() {
2268        let parsed = parse(
2269            r#"spec window
2270data period: date range
2271  -> lower 2020-01-01
2272  -> upper 2030-12-31
2273"#,
2274            crate::parsing::source::SourceType::Volatile,
2275            &ResourceLimits::default(),
2276        )
2277        .expect("-> lower / -> upper are constraint commands");
2278        let spec = parsed.flatten_specs().into_iter().next().expect("one spec");
2279        let period = spec
2280            .data
2281            .iter()
2282            .find(|data| data.reference.name == "period")
2283            .expect("period");
2284        let DataValue::Definition {
2285            constraints: Some(rows),
2286            ..
2287        } = &period.value
2288        else {
2289            panic!("expected constraints, got {:?}", period.value);
2290        };
2291        let commands: Vec<_> = rows.iter().map(|row| row.command).collect();
2292        assert_eq!(
2293            commands,
2294            vec![TypeConstraintCommand::Lower, TypeConstraintCommand::Upper]
2295        );
2296    }
2297
2298    fn prefix_expression(source: &str) -> Expression {
2299        rule_expression(source, "out")
2300    }
2301
2302    fn prefix_parse_error(source: &str) -> String {
2303        match parse(
2304            source,
2305            crate::parsing::source::SourceType::Volatile,
2306            &ResourceLimits::default(),
2307        ) {
2308            Err(Error::Parsing(details)) => details.message,
2309            other => panic!("expected parse Error, got {other:?}"),
2310        }
2311    }
2312
2313    #[test]
2314    fn parse_prefix_operand_is_call_or_one_operand() {
2315        for (body, operator) in [
2316            ("floor x + 1", "+"),
2317            ("floor x as gram", "as"),
2318            ("sqrt x ^ 2", "^"),
2319            ("lower p + 1", "+"),
2320            ("not ok and ok", "and"),
2321            ("not x > 1", ">"),
2322        ] {
2323            let message = prefix_parse_error(&format!(
2324                "spec test\nuses lemma units\ndata x: 1\ndata p: number range\ndata ok: boolean\nrule out: {body}"
2325            ));
2326            assert!(
2327                message.contains(&format!("found '{operator}' after it")),
2328                "{body}: {message}"
2329            );
2330        }
2331        let message = prefix_parse_error("spec test\nrule out: abs -1");
2332        assert!(message.contains("`abs(...)`"), "{message}");
2333
2334        let floor_plus = prefix_expression(
2335            r#"spec test
2336data x: 1
2337rule out: floor(x) + 1"#,
2338        );
2339        let Some((left, ArithmeticComputation::Add, _)) = binary_arithmetic(floor_plus.root())
2340        else {
2341            panic!("floor(x) + 1 must be add, got {floor_plus}");
2342        };
2343        assert!(matches!(
2344            left.kind(),
2345            ExpressionKind::MathematicalComputation(MathematicalComputation::Floor, _)
2346        ));
2347
2348        let floor_sum = prefix_expression(
2349            r#"spec test
2350data x: 1
2351rule out: floor (x + 1)"#,
2352        );
2353        let ExpressionKind::MathematicalComputation(MathematicalComputation::Floor, operand) =
2354            floor_sum.root_kind()
2355        else {
2356            panic!("floor (x + 1) must be floor of a sum, got {floor_sum}");
2357        };
2358        assert!(matches!(
2359            floor_sum.at(*operand).kind(),
2360            ExpressionKind::Arithmetic { .. }
2361        ));
2362
2363        let floor_abs = prefix_expression(
2364            r#"spec test
2365data x: 1
2366rule out: floor abs x"#,
2367        );
2368        let ExpressionKind::MathematicalComputation(MathematicalComputation::Floor, operand) =
2369            floor_abs.root_kind()
2370        else {
2371            panic!("floor abs x must be floor of abs, got {floor_abs}");
2372        };
2373        assert!(matches!(
2374            floor_abs.at(*operand).kind(),
2375            ExpressionKind::MathematicalComputation(MathematicalComputation::Abs, _)
2376        ));
2377
2378        let lower_range = prefix_expression(
2379            r#"spec test
2380rule out: lower 5...10"#,
2381        );
2382        let ExpressionKind::RangeBound(RangeBound::Lower, operand) = lower_range.root_kind() else {
2383            panic!("lower 5...10 must be lower of a range, got {lower_range}");
2384        };
2385        assert!(matches!(
2386            lower_range.at(*operand).kind(),
2387            ExpressionKind::RangeLiteral(..)
2388        ));
2389        assert_eq!(
2390            rule_expression(
2391                r#"spec test
2392rule out: lower 5...10"#,
2393                "out"
2394            )
2395            .to_string(),
2396            "lower 5...10"
2397        );
2398
2399        let lower_extended = prefix_expression(
2400            r#"spec test
2401data a: 1
2402data b: 2
2403rule out: lower a...b + 1"#,
2404        );
2405        let ExpressionKind::RangeBound(RangeBound::Lower, operand) = lower_extended.root_kind()
2406        else {
2407            panic!("expected lower of a range, got {lower_extended}");
2408        };
2409        let operand = lower_extended.at(*operand);
2410        let ExpressionKind::RangeLiteral(_, right) = operand.kind() else {
2411            panic!("+ must stay inside the range, got {:?}", operand.kind());
2412        };
2413        assert!(matches!(
2414            binary_arithmetic(operand.child(*right)),
2415            Some((_, ArithmeticComputation::Add, _))
2416        ));
2417
2418        let not_comparison = prefix_expression(
2419            r#"spec test
2420data x: 1
2421rule out: not (x > 1)"#,
2422        );
2423        let ExpressionKind::LogicalNegation(operand, _) = not_comparison.root_kind() else {
2424            panic!("not (x > 1) must negate the comparison, got {not_comparison}");
2425        };
2426        assert!(matches!(
2427            not_comparison.at(*operand).kind(),
2428            ExpressionKind::Comparison(..)
2429        ));
2430    }
2431
2432    #[test]
2433    fn format_prefix_operand_round_trips() {
2434        let source = r#"spec test
2435data x: 1.00
2436rule tight: floor(x) + 1
2437rule wide: floor (x + 1)
2438rule ends: lower 5...10
2439rule edge: (lower x)...(upper x)
2440"#;
2441        let formatted =
2442            crate::formatting::format_source(source, crate::parsing::source::SourceType::Volatile)
2443                .expect("format");
2444        let again = crate::formatting::format_source(
2445            &formatted,
2446            crate::parsing::source::SourceType::Volatile,
2447        )
2448        .expect("reformat");
2449        assert_eq!(formatted, again, "format must be stable");
2450
2451        for rule in ["tight", "wide", "ends", "edge"] {
2452            let original = rule_expression(source, rule);
2453            let reparsed = rule_expression(&formatted, rule);
2454            assert_eq!(
2455                original.to_string(),
2456                reparsed.to_string(),
2457                "{rule} changed meaning across format/parse"
2458            );
2459        }
2460
2461        let tight = rule_expression(&formatted, "tight");
2462        let Some((left, ArithmeticComputation::Add, _)) = binary_arithmetic(tight.root()) else {
2463            panic!("floor(x) + 1 must stay an addition, got {}", tight);
2464        };
2465        assert!(
2466            matches!(
2467                left.kind(),
2468                ExpressionKind::MathematicalComputation(MathematicalComputation::Floor, _)
2469            ),
2470            "floor(x) + 1 must stay floor-then-add, got {tight}"
2471        );
2472    }
2473}