Skip to main content

lemma/parsing/
mod.rs

1use crate::error::Error;
2use crate::limits::ResourceLimits;
3use pest::iterators::Pair;
4use pest::Parser;
5use pest_derive::Parser;
6use std::sync::Arc;
7
8pub mod ast;
9pub mod expressions;
10pub mod facts;
11pub mod literals;
12pub mod meta;
13pub mod rules;
14pub mod source;
15pub mod types;
16
17pub use ast::{DepthTracker, Span};
18pub use source::Source;
19
20pub use ast::*;
21
22#[derive(Parser)]
23#[grammar = "src/parsing/lemma.pest"]
24pub struct LemmaParser;
25
26pub fn parse(
27    content: &str,
28    attribute: &str,
29    limits: &ResourceLimits,
30) -> Result<Vec<LemmaSpec>, Error> {
31    if content.len() > limits.max_file_size_bytes {
32        return Err(Error::ResourceLimitExceeded {
33            limit_name: "max_file_size_bytes".to_string(),
34            limit_value: format!(
35                "{} bytes ({} MB)",
36                limits.max_file_size_bytes,
37                limits.max_file_size_bytes / (1024 * 1024)
38            ),
39            actual_value: format!(
40                "{} bytes ({:.2} MB)",
41                content.len(),
42                content.len() as f64 / (1024.0 * 1024.0)
43            ),
44            suggestion: "Reduce file size or split into multiple specs".to_string(),
45            spec_context: None,
46        });
47    }
48
49    let mut depth_tracker = DepthTracker::with_max_depth(limits.max_expression_depth);
50
51    let source_text: Arc<str> = Arc::from(content);
52
53    match LemmaParser::parse(Rule::lemma_file, content) {
54        Ok(mut pairs) => {
55            let mut specs = Vec::new();
56            if let Some(lemma_file_pair) = pairs.next() {
57                for inner_pair in lemma_file_pair.into_inner() {
58                    if inner_pair.as_rule() == Rule::spec {
59                        specs.push(parse_spec(
60                            inner_pair,
61                            attribute,
62                            &mut depth_tracker,
63                            source_text.clone(),
64                        )?);
65                    }
66                }
67            }
68            Ok(specs)
69        }
70        Err(e) => {
71            let (line, col) = match e.line_col {
72                pest::error::LineColLocation::Pos((l, c)) => (l, c),
73                pest::error::LineColLocation::Span((l, c), _) => (l, c),
74            };
75            let (start_byte, end_byte) = line_col_to_byte_range(content, line, col);
76            let pest_span = Span {
77                start: start_byte,
78                end: end_byte,
79                line,
80                col,
81            };
82
83            let message = humanize_pest_error(&e.variant);
84
85            Err(Error::parsing(
86                message,
87                Some(crate::parsing::source::Source::new(
88                    attribute,
89                    pest_span,
90                    "",
91                    source_text,
92                )),
93                None::<String>,
94            ))
95        }
96    }
97}
98
99/// Convert a 1-based line/col to a byte range covering the rest of that line.
100fn line_col_to_byte_range(text: &str, line: usize, col: usize) -> (usize, usize) {
101    let mut current_line = 1usize;
102    let mut line_start = 0usize;
103    for (i, b) in text.bytes().enumerate() {
104        if current_line == line {
105            let start = line_start + col.saturating_sub(1);
106            let end = text[i..].find('\n').map(|n| i + n).unwrap_or(text.len());
107            return (start.min(text.len()), end.max(start));
108        }
109        if b == b'\n' {
110            current_line += 1;
111            line_start = i + 1;
112        }
113    }
114    if current_line == line {
115        let start = line_start + col.saturating_sub(1);
116        return (start.min(text.len()), text.len());
117    }
118    (0, text.len().min(1))
119}
120
121fn humanize_pest_error(variant: &pest::error::ErrorVariant<Rule>) -> String {
122    match variant {
123        pest::error::ErrorVariant::ParsingError {
124            positives,
125            negatives,
126        } => {
127            let readable: Vec<&str> = positives.iter().filter_map(|r| rule_to_human(*r)).collect();
128            if readable.is_empty() {
129                if !negatives.is_empty() {
130                    "Unexpected token".to_string()
131                } else {
132                    "Syntax error".to_string()
133                }
134            } else if readable.len() == 1 {
135                format!("Expected {}", readable[0])
136            } else {
137                format!("Expected one of: {}", readable.join(", "))
138            }
139        }
140        pest::error::ErrorVariant::CustomError { message } => message.clone(),
141    }
142}
143
144fn rule_to_human(rule: Rule) -> Option<&'static str> {
145    match rule {
146        Rule::lemma_file => Some("a spec declaration (e.g. 'spec my_spec')"),
147        Rule::spec => Some("a spec declaration"),
148        Rule::spec_declaration => Some("a spec declaration (e.g. 'spec name')"),
149        Rule::spec_name => Some("a spec name"),
150        Rule::spec_body => Some("spec body (facts, rules, or types)"),
151        Rule::fact_definition => Some("a fact definition (e.g. 'fact x: 10')"),
152        Rule::rule_definition => Some("a rule definition (e.g. 'rule y: x + 1')"),
153        Rule::type_definition => Some("a type definition (e.g. 'type money: scale')"),
154        Rule::type_import => Some("a type import (e.g. 'type money from other_spec')"),
155        Rule::meta_definition => Some("a meta field (e.g. 'meta key: value')"),
156        Rule::expression => Some("an expression"),
157        Rule::unless_statement => Some("an unless clause"),
158        Rule::commentary_block => Some("a commentary block (triple-quoted string)"),
159        Rule::literal => Some("a value (number, text, boolean, date, etc.)"),
160        Rule::number_literal => Some("a number"),
161        Rule::text_literal => Some("a string (double-quoted)"),
162        Rule::boolean_literal => Some("a boolean (true/false/yes/no)"),
163        Rule::label => Some("an identifier"),
164        Rule::assignment_symbol => Some("':'"),
165        Rule::EOI => Some("end of file"),
166        _ => None,
167    }
168}
169
170fn parse_spec(
171    pair: Pair<Rule>,
172    attribute: &str,
173    depth_tracker: &mut DepthTracker,
174    source_text: Arc<str>,
175) -> Result<LemmaSpec, Error> {
176    let spec_start_line = pair.as_span().start_pos().line_col().0;
177
178    let mut spec_name: Option<String> = None;
179    let mut effective_from: Option<crate::parsing::ast::DateTimeValue> = None;
180    let mut commentary: Option<String> = None;
181    let mut facts = Vec::new();
182    let mut rules = Vec::new();
183    let mut types = Vec::new();
184    let mut meta_fields = Vec::new();
185
186    // First, extract spec header to get commentary and spec_declaration
187    for header_item in pair.clone().into_inner() {
188        match header_item.as_rule() {
189            Rule::commentary_block => {
190                for block_inner in header_item.into_inner() {
191                    if block_inner.as_rule() == Rule::commentary {
192                        commentary = Some(block_inner.as_str().trim().to_string());
193                        break;
194                    }
195                }
196            }
197            Rule::spec_declaration => {
198                for decl_inner in header_item.into_inner() {
199                    match decl_inner.as_rule() {
200                        Rule::spec_name => {
201                            spec_name = Some(decl_inner.as_str().to_string());
202                        }
203                        Rule::spec_effective_from => {
204                            let raw = decl_inner.as_str();
205                            effective_from =
206                                Some(crate::parsing::ast::DateTimeValue::parse(raw).ok_or_else(
207                                    || {
208                                        Error::parsing(
209                                            format!(
210                                                "Invalid date/time in spec declaration: '{}'",
211                                                raw
212                                            ),
213                                            None,
214                                            None::<String>,
215                                        )
216                                    },
217                                )?);
218                        }
219                        _ => {}
220                    }
221                }
222            }
223            _ => {}
224        }
225    }
226
227    let name = spec_name.expect("BUG: grammar guarantees spec has spec_declaration");
228
229    // First pass: collect all named type definitions from spec_body
230    for inner_pair in pair.clone().into_inner() {
231        if inner_pair.as_rule() == Rule::spec_body {
232            for body_item in inner_pair.into_inner() {
233                match body_item.as_rule() {
234                    Rule::type_definition => {
235                        let type_def = crate::parsing::types::parse_type_definition(
236                            body_item,
237                            attribute,
238                            &name,
239                            source_text.clone(),
240                        )?;
241                        types.push(type_def);
242                    }
243                    Rule::type_import => {
244                        let type_def = crate::parsing::types::parse_type_import(
245                            body_item,
246                            attribute,
247                            &name,
248                            source_text.clone(),
249                        )?;
250                        types.push(type_def);
251                    }
252                    _ => {}
253                }
254            }
255        }
256    }
257
258    // Second pass: parse facts and rules from spec_body
259    for inner_pair in pair.into_inner() {
260        if inner_pair.as_rule() == Rule::spec_body {
261            for body_item in inner_pair.into_inner() {
262                match body_item.as_rule() {
263                    Rule::fact_definition => {
264                        let fact = crate::parsing::facts::parse_fact_definition(
265                            body_item,
266                            attribute,
267                            &name,
268                            source_text.clone(),
269                        )?;
270                        facts.push(fact);
271                    }
272                    Rule::fact_binding => {
273                        let fact = crate::parsing::facts::parse_fact_binding(
274                            body_item,
275                            attribute,
276                            &name,
277                            source_text.clone(),
278                        )?;
279                        facts.push(fact);
280                    }
281                    Rule::rule_definition => {
282                        let rule = crate::parsing::rules::parse_rule_definition(
283                            body_item,
284                            depth_tracker,
285                            attribute,
286                            &name,
287                            source_text.clone(),
288                        )?;
289                        rules.push(rule);
290                    }
291                    Rule::meta_definition => {
292                        let meta = crate::parsing::meta::parse_meta_definition(
293                            body_item,
294                            attribute,
295                            &name,
296                            source_text.clone(),
297                        )?;
298                        meta_fields.push(meta);
299                    }
300                    _ => {}
301                }
302            }
303        }
304    }
305    let mut spec = LemmaSpec::new(name)
306        .with_attribute(attribute.to_string())
307        .with_start_line(spec_start_line);
308    spec.effective_from = effective_from;
309
310    if let Some(commentary_text) = commentary {
311        spec = spec.set_commentary(commentary_text);
312    }
313
314    for fact in facts {
315        spec = spec.add_fact(fact);
316    }
317    for rule in rules {
318        spec = spec.add_rule(rule);
319    }
320    for type_def in types {
321        spec = spec.add_type(type_def);
322    }
323    for meta in meta_fields {
324        spec = spec.add_meta_field(meta);
325    }
326
327    Ok(spec)
328}
329
330// ============================================================================
331// Tests
332// ============================================================================
333
334#[cfg(test)]
335mod tests {
336    use super::parse;
337    use crate::Error;
338    use crate::ResourceLimits;
339
340    #[test]
341    fn parse_empty_input_returns_no_specs() {
342        let result = parse("", "test.lemma", &ResourceLimits::default()).unwrap();
343        assert_eq!(result.len(), 0);
344    }
345
346    #[test]
347    fn parse_workspace_file_yields_expected_spec_facts_and_rules() {
348        let input = r#"spec person
349fact name: "John Doe"
350rule adult: true"#;
351        let result = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
352        assert_eq!(result.len(), 1);
353        assert_eq!(result[0].name, "person");
354        assert_eq!(result[0].facts.len(), 1);
355        assert_eq!(result[0].rules.len(), 1);
356        assert_eq!(result[0].rules[0].name, "adult");
357    }
358
359    #[test]
360    fn mixing_facts_and_rules_is_collected_into_spec() {
361        let input = r#"spec test
362fact name: "John"
363rule is_adult: age >= 18
364fact age: 25
365rule can_drink: age >= 21
366fact status: "active"
367rule is_eligible: is_adult and status == "active""#;
368
369        let result = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
370        assert_eq!(result.len(), 1);
371        assert_eq!(result[0].facts.len(), 3);
372        assert_eq!(result[0].rules.len(), 3);
373    }
374
375    #[test]
376    fn parse_simple_spec_collects_facts() {
377        let input = r#"spec person
378fact name: "John"
379fact age: 25"#;
380        let result = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
381        assert_eq!(result.len(), 1);
382        assert_eq!(result[0].name, "person");
383        assert_eq!(result[0].facts.len(), 2);
384    }
385
386    #[test]
387    fn parse_spec_name_with_slashes_is_preserved() {
388        let input = r#"spec contracts/employment/jack
389fact name: "Jack""#;
390        let result = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
391        assert_eq!(result.len(), 1);
392        assert_eq!(result[0].name, "contracts/employment/jack");
393    }
394
395    #[test]
396    fn parse_spec_name_no_version_tag() {
397        let input = "spec myspec\nrule x: 1";
398        let result = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
399        assert_eq!(result.len(), 1);
400        assert_eq!(result[0].name, "myspec");
401        assert_eq!(result[0].effective_from(), None);
402    }
403
404    #[test]
405    fn parse_commentary_block_is_attached_to_spec() {
406        let input = r#"spec person
407"""
408This is a markdown comment
409with **bold** text
410"""
411fact name: "John""#;
412        let result = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
413        assert_eq!(result.len(), 1);
414        assert!(result[0].commentary.is_some());
415        assert!(result[0].commentary.as_ref().unwrap().contains("**bold**"));
416    }
417
418    #[test]
419    fn parse_spec_with_rule_collects_rule() {
420        let input = r#"spec person
421rule is_adult: age >= 18"#;
422        let result = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
423        assert_eq!(result.len(), 1);
424        assert_eq!(result[0].rules.len(), 1);
425        assert_eq!(result[0].rules[0].name, "is_adult");
426    }
427
428    #[test]
429    fn parse_multiple_specs_returns_all_specs() {
430        let input = r#"spec person
431fact name: "John"
432
433spec company
434fact name: "Acme Corp""#;
435        let result = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
436        assert_eq!(result.len(), 2);
437        assert_eq!(result[0].name, "person");
438        assert_eq!(result[1].name, "company");
439    }
440
441    #[test]
442    fn parse_allows_duplicate_fact_names() {
443        // Duplicate fact names are rejected during planning/validation, not parsing.
444        let input = r#"spec person
445fact name: "John"
446fact name: "Jane""#;
447        let result = parse(input, "test.lemma", &ResourceLimits::default());
448        assert!(
449            result.is_ok(),
450            "Parser should succeed even with duplicate facts"
451        );
452    }
453
454    #[test]
455    fn parse_allows_duplicate_rule_names() {
456        // Duplicate rule names are rejected during planning/validation, not parsing.
457        let input = r#"spec person
458rule is_adult: age >= 18
459rule is_adult: age >= 21"#;
460        let result = parse(input, "test.lemma", &ResourceLimits::default());
461        assert!(
462            result.is_ok(),
463            "Parser should succeed even with duplicate rules"
464        );
465    }
466
467    #[test]
468    fn parse_rejects_malformed_input() {
469        let input = "invalid syntax here";
470        let result = parse(input, "test.lemma", &ResourceLimits::default());
471        assert!(result.is_err());
472    }
473
474    #[test]
475    fn parse_handles_whitespace_variants_in_expressions() {
476        let test_cases = vec![
477            ("spec test\nrule test: 2+3", "no spaces in arithmetic"),
478            ("spec test\nrule test: age>=18", "no spaces in comparison"),
479            (
480                "spec test\nrule test: age >= 18 and salary>50000",
481                "spaces around and keyword",
482            ),
483            (
484                "spec test\nrule test: age  >=  18  and  salary  >  50000",
485                "extra spaces",
486            ),
487            (
488                "spec test\nrule test: \n  age >= 18 \n  and \n  salary > 50000",
489                "newlines in expression",
490            ),
491        ];
492
493        for (input, description) in test_cases {
494            let result = parse(input, "test.lemma", &ResourceLimits::default());
495            assert!(
496                result.is_ok(),
497                "Failed to parse {} ({}): {:?}",
498                input,
499                description,
500                result.err()
501            );
502        }
503    }
504
505    #[test]
506    fn parse_error_cases_are_rejected() {
507        let error_cases = vec![
508            (
509                "spec test\nfact name: \"unclosed string",
510                "unclosed string literal",
511            ),
512            ("spec test\nrule test: 2 + + 3", "double operator"),
513            ("spec test\nrule test: (2 + 3", "unclosed parenthesis"),
514            ("spec test\nrule test: 2 + 3)", "extra closing paren"),
515            // Note: "invalid unit" now parses as a user-defined unit (validated during planning)
516            ("spec test\nfact spec: 123", "reserved keyword as fact name"),
517            (
518                "spec test\nrule rule: true",
519                "reserved keyword as rule name",
520            ),
521        ];
522
523        for (input, description) in error_cases {
524            let result = parse(input, "test.lemma", &ResourceLimits::default());
525            assert!(
526                result.is_err(),
527                "Expected error for {} but got success",
528                description
529            );
530        }
531    }
532
533    #[test]
534    fn parse_duration_literals_in_rules() {
535        let test_cases = vec![
536            ("2 years", "years"),
537            ("6 months", "months"),
538            ("52 weeks", "weeks"),
539            ("365 days", "days"),
540            ("24 hours", "hours"),
541            ("60 minutes", "minutes"),
542            ("3600 seconds", "seconds"),
543            ("1000 milliseconds", "milliseconds"),
544            ("500000 microseconds", "microseconds"),
545            ("50 percent", "percent"),
546        ];
547
548        for (expr, description) in test_cases {
549            let input = format!("spec test\nrule test: {}", expr);
550            let result = parse(&input, "test.lemma", &ResourceLimits::default());
551            assert!(
552                result.is_ok(),
553                "Failed to parse literal {} ({}): {:?}",
554                expr,
555                description,
556                result.err()
557            );
558        }
559    }
560
561    #[test]
562    fn parse_comparisons_with_duration_unit_conversions() {
563        let test_cases = vec![
564            (
565                "(duration in hours) > 2",
566                "duration conversion in comparison with parens",
567            ),
568            (
569                "(meeting_time in minutes) >= 30",
570                "duration conversion with gte",
571            ),
572            (
573                "(project_length in days) < 100",
574                "duration conversion with lt",
575            ),
576            (
577                "(delay in seconds) == 60",
578                "duration conversion with equality",
579            ),
580            (
581                "(1 hours) > (30 minutes)",
582                "duration conversions on both sides",
583            ),
584            (
585                "duration in hours > 2",
586                "duration conversion without parens",
587            ),
588            (
589                "meeting_time in seconds > 3600",
590                "variable duration conversion in comparison",
591            ),
592            (
593                "project_length in days > deadline_days",
594                "two variables with duration conversion",
595            ),
596            (
597                "duration in hours >= 1 and duration in hours <= 8",
598                "multiple duration comparisons",
599            ),
600        ];
601
602        for (expr, description) in test_cases {
603            let input = format!("spec test\nrule test: {}", expr);
604            let result = parse(&input, "test.lemma", &ResourceLimits::default());
605            assert!(
606                result.is_ok(),
607                "Failed to parse {} ({}): {:?}",
608                expr,
609                description,
610                result.err()
611            );
612        }
613    }
614
615    #[test]
616    fn parse_error_includes_attribute_and_parse_error_spec_name() {
617        let result = parse(
618            r#"
619spec test
620fact name: "Unclosed string
621fact age: 25
622"#,
623            "test.lemma",
624            &ResourceLimits::default(),
625        );
626
627        match result {
628            Err(Error::Parsing(details)) => {
629                let src = details.source.as_ref().expect("should have source");
630                assert_eq!(src.attribute, "test.lemma");
631                assert_eq!(src.spec_name, "");
632            }
633            Err(e) => panic!("Expected Parse error, got: {e:?}"),
634            Ok(_) => panic!("Expected parse error for unclosed string"),
635        }
636    }
637
638    #[test]
639    fn parse_registry_style_spec_name() {
640        let input = r#"spec user/workspace/somespec
641fact name: "Alice""#;
642        let result = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
643        assert_eq!(result.len(), 1);
644        assert_eq!(result[0].name, "user/workspace/somespec");
645    }
646
647    #[test]
648    fn parse_fact_spec_reference_with_at_prefix() {
649        let input = r#"spec example
650fact external: spec @user/workspace/somespec"#;
651        let result = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
652        assert_eq!(result.len(), 1);
653        assert_eq!(result[0].facts.len(), 1);
654        match &result[0].facts[0].value {
655            crate::parsing::ast::FactValue::SpecReference(spec_ref) => {
656                assert_eq!(spec_ref.name, "@user/workspace/somespec");
657                assert!(spec_ref.is_registry, "expected registry reference");
658            }
659            other => panic!("Expected SpecReference, got: {:?}", other),
660        }
661    }
662
663    #[test]
664    fn parse_type_import_with_at_prefix() {
665        let input = r#"spec example
666type money from @lemma/std/finance
667fact price: [money]"#;
668        let result = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
669        assert_eq!(result.len(), 1);
670        assert_eq!(result[0].types.len(), 1);
671        match &result[0].types[0] {
672            crate::parsing::ast::TypeDef::Import { from, name, .. } => {
673                assert_eq!(from.name, "@lemma/std/finance");
674                assert!(from.is_registry, "expected registry reference");
675                assert_eq!(name, "money");
676            }
677            other => panic!("Expected Import type, got: {:?}", other),
678        }
679    }
680
681    #[test]
682    fn parse_multiple_registry_specs_in_same_file() {
683        let input = r#"spec user/workspace/spec_a
684fact x: 10
685
686spec user/workspace/spec_b
687fact y: 20
688fact a: spec @user/workspace/spec_a"#;
689        let result = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
690        assert_eq!(result.len(), 2);
691        assert_eq!(result[0].name, "user/workspace/spec_a");
692        assert_eq!(result[1].name, "user/workspace/spec_b");
693    }
694
695    // =====================================================================
696    // Versioned spec identifier tests (section 6.1 of plan)
697    // =====================================================================
698
699    #[test]
700    fn parse_registry_spec_ref_name_only() {
701        let input = "spec example\nfact x: spec @owner/repo/somespec";
702        let result = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
703        match &result[0].facts[0].value {
704            crate::parsing::ast::FactValue::SpecReference(spec_ref) => {
705                assert_eq!(spec_ref.name, "@owner/repo/somespec");
706                assert_eq!(spec_ref.hash_pin, None);
707                assert!(spec_ref.is_registry);
708            }
709            other => panic!("Expected SpecReference, got: {:?}", other),
710        }
711    }
712
713    #[test]
714    fn parse_registry_spec_ref_name_with_dots_is_whole_name() {
715        let input = "spec example\nfact x: spec @owner/repo/somespec";
716        let result = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
717        match &result[0].facts[0].value {
718            crate::parsing::ast::FactValue::SpecReference(spec_ref) => {
719                assert_eq!(spec_ref.name, "@owner/repo/somespec");
720                assert!(spec_ref.is_registry);
721            }
722            other => panic!("Expected SpecReference, got: {:?}", other),
723        }
724    }
725
726    #[test]
727    fn parse_local_spec_ref_name_only() {
728        let input = "spec example\nfact x: spec myspec";
729        let result = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
730        match &result[0].facts[0].value {
731            crate::parsing::ast::FactValue::SpecReference(spec_ref) => {
732                assert_eq!(spec_ref.name, "myspec");
733                assert_eq!(spec_ref.hash_pin, None);
734                assert!(!spec_ref.is_registry);
735            }
736            other => panic!("Expected SpecReference, got: {:?}", other),
737        }
738    }
739
740    #[test]
741    fn parse_spec_name_with_trailing_dot_is_error() {
742        let input = "spec myspec.\nfact x: 1";
743        let result = parse(input, "test.lemma", &ResourceLimits::default());
744        assert!(
745            result.is_err(),
746            "Trailing dot after spec name should be a parse error"
747        );
748    }
749
750    #[test]
751    fn parse_type_import_from_registry() {
752        let input = "spec example\ntype money from @lemma/std/finance\nfact price: [money]";
753        let result = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
754        match &result[0].types[0] {
755            crate::parsing::ast::TypeDef::Import { from, name, .. } => {
756                assert_eq!(from.name, "@lemma/std/finance");
757                assert!(from.is_registry);
758                assert_eq!(name, "money");
759            }
760            other => panic!("Expected Import type, got: {:?}", other),
761        }
762    }
763
764    #[test]
765    fn parse_spec_declaration_no_version() {
766        let input = "spec myspec\nrule x: 1";
767        let result = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
768        assert_eq!(result[0].name, "myspec");
769        assert_eq!(result[0].effective_from(), None);
770    }
771
772    #[test]
773    fn parse_multiple_specs_in_same_file() {
774        let input = "spec myspec_a\nrule x: 1\n\nspec myspec_b\nrule x: 2";
775        let result = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
776        assert_eq!(result.len(), 2);
777        assert_eq!(result[0].name, "myspec_a");
778        assert_eq!(result[1].name, "myspec_b");
779    }
780
781    #[test]
782    fn parse_spec_reference_grammar_accepts_name_only() {
783        let input = "spec consumer\nfact m: spec other";
784        let result = parse(input, "test.lemma", &ResourceLimits::default());
785        assert!(result.is_ok(), "spec name without hash should parse");
786        let spec_ref = match &result.as_ref().unwrap()[0].facts[0].value {
787            crate::parsing::ast::FactValue::SpecReference(r) => r,
788            _ => panic!("expected SpecReference"),
789        };
790        assert_eq!(spec_ref.name, "other");
791        assert_eq!(spec_ref.hash_pin, None);
792    }
793
794    #[test]
795    fn parse_spec_reference_with_hash() {
796        let input = "spec consumer\nfact cfg: spec config~a1b2c3d4";
797        let result = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
798        let spec_ref = match &result[0].facts[0].value {
799            crate::parsing::ast::FactValue::SpecReference(r) => r,
800            other => panic!("expected SpecReference, got: {:?}", other),
801        };
802        assert_eq!(spec_ref.name, "config");
803        assert_eq!(spec_ref.hash_pin.as_deref(), Some("a1b2c3d4"));
804    }
805
806    #[test]
807    fn parse_spec_reference_registry_with_hash() {
808        let input = "spec consumer\nfact ext: spec @user/workspace/cfg~ab12cd34";
809        let result = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
810        let spec_ref = match &result[0].facts[0].value {
811            crate::parsing::ast::FactValue::SpecReference(r) => r,
812            other => panic!("expected SpecReference, got: {:?}", other),
813        };
814        assert_eq!(spec_ref.name, "@user/workspace/cfg");
815        assert!(spec_ref.is_registry);
816        assert_eq!(spec_ref.hash_pin.as_deref(), Some("ab12cd34"));
817    }
818
819    #[test]
820    fn parse_type_import_with_hash() {
821        let input = "spec consumer\ntype money from finance a1b2c3d4\nfact p: [money]";
822        let result = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
823        match &result[0].types[0] {
824            crate::parsing::ast::TypeDef::Import { from, name, .. } => {
825                assert_eq!(name, "money");
826                assert_eq!(from.name, "finance");
827                assert_eq!(from.hash_pin.as_deref(), Some("a1b2c3d4"));
828            }
829            other => panic!("expected Import, got: {:?}", other),
830        }
831    }
832
833    #[test]
834    fn parse_type_import_registry_with_hash() {
835        let input = "spec consumer\ntype money from @lemma/std/finance ab12cd34\nfact p: [money]";
836        let result = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
837        match &result[0].types[0] {
838            crate::parsing::ast::TypeDef::Import { from, name, .. } => {
839                assert_eq!(name, "money");
840                assert_eq!(from.name, "@lemma/std/finance");
841                assert!(from.is_registry);
842                assert_eq!(from.hash_pin.as_deref(), Some("ab12cd34"));
843            }
844            other => panic!("expected Import, got: {:?}", other),
845        }
846    }
847
848    #[test]
849    fn parse_inline_type_from_with_hash() {
850        let input = "spec consumer\nfact price: [money from finance a1b2c3d4 -> minimum 0]";
851        let result = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
852        match &result[0].facts[0].value {
853            crate::parsing::ast::FactValue::TypeDeclaration {
854                base,
855                from,
856                constraints,
857                ..
858            } => {
859                assert_eq!(base, "money");
860                let spec_ref = from.as_ref().expect("expected from spec ref");
861                assert_eq!(spec_ref.name, "finance");
862                assert_eq!(spec_ref.hash_pin.as_deref(), Some("a1b2c3d4"));
863                assert!(constraints.is_some());
864            }
865            other => panic!("expected TypeDeclaration, got: {:?}", other),
866        }
867    }
868
869    #[test]
870    fn parse_type_import_spec_name_with_slashes() {
871        let input = "spec consumer\ntype money from @lemma/std/finance\nfact p: [money]";
872        let result = parse(input, "test.lemma", &ResourceLimits::default());
873        assert!(result.is_ok(), "type import from registry should parse");
874        match &result.unwrap()[0].types[0] {
875            crate::parsing::ast::TypeDef::Import { from, .. } => {
876                assert_eq!(from.name, "@lemma/std/finance")
877            }
878            _ => panic!("expected Import"),
879        }
880    }
881
882    #[test]
883    fn parse_error_is_returned_for_garbage_input() {
884        let result = parse(
885            r#"
886spec test
887this is not valid lemma syntax @#$%
888"#,
889            "test.lemma",
890            &ResourceLimits::default(),
891        );
892
893        assert!(result.is_err(), "Should fail on malformed input");
894        match result {
895            Err(Error::Parsing { .. }) => {
896                // Expected
897            }
898            Err(e) => panic!("Expected Parse error, got: {e:?}"),
899            Ok(_) => panic!("Expected parse error"),
900        }
901    }
902}