Skip to main content

lemma/parsing/
mod.rs

1use crate::error::Error;
2use crate::limits::ResourceLimits;
3
4pub mod ast;
5pub mod lexer;
6pub mod literals;
7pub mod parser;
8pub mod source;
9
10pub use ast::{DepthTracker, Span};
11pub use source::Source;
12
13pub use ast::*;
14pub use parser::ParseResult;
15
16pub fn parse(
17    content: &str,
18    attribute: &str,
19    limits: &ResourceLimits,
20) -> Result<ParseResult, Error> {
21    parser::parse(content, attribute, limits)
22}
23
24// ============================================================================
25// Tests
26// ============================================================================
27
28#[cfg(test)]
29mod tests {
30    use super::parse;
31    use crate::Error;
32    use crate::ResourceLimits;
33
34    #[test]
35    fn parse_empty_input_returns_no_specs() {
36        let result = parse("", "test.lemma", &ResourceLimits::default())
37            .unwrap()
38            .specs;
39        assert_eq!(result.len(), 0);
40    }
41
42    #[test]
43    fn parse_workspace_file_yields_expected_spec_facts_and_rules() {
44        let input = r#"spec person
45fact name: "John Doe"
46rule adult: true"#;
47        let result = parse(input, "test.lemma", &ResourceLimits::default())
48            .unwrap()
49            .specs;
50        assert_eq!(result.len(), 1);
51        assert_eq!(result[0].name, "person");
52        assert_eq!(result[0].facts.len(), 1);
53        assert_eq!(result[0].rules.len(), 1);
54        assert_eq!(result[0].rules[0].name, "adult");
55    }
56
57    #[test]
58    fn mixing_facts_and_rules_is_collected_into_spec() {
59        let input = r#"spec test
60fact name: "John"
61rule is_adult: age >= 18
62fact age: 25
63rule can_drink: age >= 21
64fact status: "active"
65rule is_eligible: is_adult and status == "active""#;
66
67        let result = parse(input, "test.lemma", &ResourceLimits::default())
68            .unwrap()
69            .specs;
70        assert_eq!(result.len(), 1);
71        assert_eq!(result[0].facts.len(), 3);
72        assert_eq!(result[0].rules.len(), 3);
73    }
74
75    #[test]
76    fn parse_simple_spec_collects_facts() {
77        let input = r#"spec person
78fact name: "John"
79fact age: 25"#;
80        let result = parse(input, "test.lemma", &ResourceLimits::default())
81            .unwrap()
82            .specs;
83        assert_eq!(result.len(), 1);
84        assert_eq!(result[0].name, "person");
85        assert_eq!(result[0].facts.len(), 2);
86    }
87
88    #[test]
89    fn parse_spec_name_with_slashes_is_preserved() {
90        let input = r#"spec contracts/employment/jack
91fact name: "Jack""#;
92        let result = parse(input, "test.lemma", &ResourceLimits::default())
93            .unwrap()
94            .specs;
95        assert_eq!(result.len(), 1);
96        assert_eq!(result[0].name, "contracts/employment/jack");
97    }
98
99    #[test]
100    fn parse_spec_name_no_version_tag() {
101        let input = "spec myspec\nrule x: 1";
102        let result = parse(input, "test.lemma", &ResourceLimits::default())
103            .unwrap()
104            .specs;
105        assert_eq!(result.len(), 1);
106        assert_eq!(result[0].name, "myspec");
107        assert_eq!(result[0].effective_from(), None);
108    }
109
110    #[test]
111    fn parse_commentary_block_is_attached_to_spec() {
112        let input = r#"spec person
113"""
114This is a markdown comment
115with **bold** text
116"""
117fact name: "John""#;
118        let result = parse(input, "test.lemma", &ResourceLimits::default())
119            .unwrap()
120            .specs;
121        assert_eq!(result.len(), 1);
122        assert!(result[0].commentary.is_some());
123        assert!(result[0].commentary.as_ref().unwrap().contains("**bold**"));
124    }
125
126    #[test]
127    fn parse_spec_with_rule_collects_rule() {
128        let input = r#"spec person
129rule is_adult: age >= 18"#;
130        let result = parse(input, "test.lemma", &ResourceLimits::default())
131            .unwrap()
132            .specs;
133        assert_eq!(result.len(), 1);
134        assert_eq!(result[0].rules.len(), 1);
135        assert_eq!(result[0].rules[0].name, "is_adult");
136    }
137
138    #[test]
139    fn parse_multiple_specs_returns_all_specs() {
140        let input = r#"spec person
141fact name: "John"
142
143spec company
144fact name: "Acme Corp""#;
145        let result = parse(input, "test.lemma", &ResourceLimits::default())
146            .unwrap()
147            .specs;
148        assert_eq!(result.len(), 2);
149        assert_eq!(result[0].name, "person");
150        assert_eq!(result[1].name, "company");
151    }
152
153    #[test]
154    fn parse_allows_duplicate_fact_names() {
155        let input = r#"spec person
156fact name: "John"
157fact name: "Jane""#;
158        let result = parse(input, "test.lemma", &ResourceLimits::default());
159        assert!(
160            result.is_ok(),
161            "Parser should succeed even with duplicate facts"
162        );
163    }
164
165    #[test]
166    fn parse_allows_duplicate_rule_names() {
167        let input = r#"spec person
168rule is_adult: age >= 18
169rule is_adult: age >= 21"#;
170        let result = parse(input, "test.lemma", &ResourceLimits::default());
171        assert!(
172            result.is_ok(),
173            "Parser should succeed even with duplicate rules"
174        );
175    }
176
177    #[test]
178    fn parse_rejects_malformed_input() {
179        let input = "invalid syntax here";
180        let result = parse(input, "test.lemma", &ResourceLimits::default());
181        assert!(result.is_err());
182    }
183
184    #[test]
185    fn parse_handles_whitespace_variants_in_expressions() {
186        let test_cases = vec![
187            ("spec test\nrule test: 2+3", "no spaces in arithmetic"),
188            ("spec test\nrule test: age>=18", "no spaces in comparison"),
189            (
190                "spec test\nrule test: age >= 18 and salary>50000",
191                "spaces around and keyword",
192            ),
193            (
194                "spec test\nrule test: age  >=  18  and  salary  >  50000",
195                "extra spaces",
196            ),
197            (
198                "spec test\nrule test: \n  age >= 18 \n  and \n  salary > 50000",
199                "newlines in expression",
200            ),
201        ];
202
203        for (input, description) in test_cases {
204            let result = parse(input, "test.lemma", &ResourceLimits::default());
205            assert!(
206                result.is_ok(),
207                "Failed to parse {} ({}): {:?}",
208                input,
209                description,
210                result.err()
211            );
212        }
213    }
214
215    #[test]
216    fn parse_error_cases_are_rejected() {
217        let error_cases = vec![
218            (
219                "spec test\nfact name: \"unclosed string",
220                "unclosed string literal",
221            ),
222            ("spec test\nrule test: (2 + 3", "unclosed parenthesis"),
223            ("spec test\nrule test: 2 + 3)", "extra closing paren"),
224            ("spec test\nfact spec: 123", "reserved keyword as fact name"),
225            (
226                "spec test\nrule rule: true",
227                "reserved keyword as rule name",
228            ),
229        ];
230
231        for (input, description) in error_cases {
232            let result = parse(input, "test.lemma", &ResourceLimits::default());
233            assert!(
234                result.is_err(),
235                "Expected error for {} but got success",
236                description
237            );
238        }
239    }
240
241    #[test]
242    fn parse_duration_literals_in_rules() {
243        let test_cases = vec![
244            ("2 years", "years"),
245            ("6 months", "months"),
246            ("52 weeks", "weeks"),
247            ("365 days", "days"),
248            ("24 hours", "hours"),
249            ("60 minutes", "minutes"),
250            ("3600 seconds", "seconds"),
251            ("1000 milliseconds", "milliseconds"),
252            ("500000 microseconds", "microseconds"),
253            ("50 percent", "percent"),
254        ];
255
256        for (expr, description) in test_cases {
257            let input = format!("spec test\nrule test: {}", expr);
258            let result = parse(&input, "test.lemma", &ResourceLimits::default());
259            assert!(
260                result.is_ok(),
261                "Failed to parse literal {} ({}): {:?}",
262                expr,
263                description,
264                result.err()
265            );
266        }
267    }
268
269    #[test]
270    fn parse_comparisons_with_duration_unit_conversions() {
271        let test_cases = vec![
272            (
273                "(duration in hours) > 2",
274                "duration conversion in comparison with parens",
275            ),
276            (
277                "(meeting_time in minutes) >= 30",
278                "duration conversion with gte",
279            ),
280            (
281                "(project_length in days) < 100",
282                "duration conversion with lt",
283            ),
284            (
285                "(delay in seconds) == 60",
286                "duration conversion with equality",
287            ),
288            (
289                "(1 hours) > (30 minutes)",
290                "duration conversions on both sides",
291            ),
292            (
293                "duration in hours > 2",
294                "duration conversion without parens",
295            ),
296            (
297                "meeting_time in seconds > 3600",
298                "variable duration conversion in comparison",
299            ),
300            (
301                "project_length in days > deadline_days",
302                "two variables with duration conversion",
303            ),
304            (
305                "duration in hours >= 1 and duration in hours <= 8",
306                "multiple duration comparisons",
307            ),
308        ];
309
310        for (expr, description) in test_cases {
311            let input = format!("spec test\nrule test: {}", expr);
312            let result = parse(&input, "test.lemma", &ResourceLimits::default());
313            assert!(
314                result.is_ok(),
315                "Failed to parse {} ({}): {:?}",
316                expr,
317                description,
318                result.err()
319            );
320        }
321    }
322
323    #[test]
324    fn parse_error_includes_attribute_and_parse_error_spec_name() {
325        let result = parse(
326            r#"
327spec test
328fact name: "Unclosed string
329fact age: 25
330"#,
331            "test.lemma",
332            &ResourceLimits::default(),
333        );
334
335        match result {
336            Err(Error::Parsing(details)) => {
337                let src = details
338                    .source
339                    .as_ref()
340                    .expect("BUG: parsing errors always have source");
341                assert_eq!(src.attribute, "test.lemma");
342            }
343            Err(e) => panic!("Expected Parse error, got: {e:?}"),
344            Ok(_) => panic!("Expected parse error for unclosed string"),
345        }
346    }
347
348    #[test]
349    fn parse_registry_style_spec_name() {
350        let input = r#"spec user/workspace/somespec
351fact name: "Alice""#;
352        let result = parse(input, "test.lemma", &ResourceLimits::default())
353            .unwrap()
354            .specs;
355        assert_eq!(result.len(), 1);
356        assert_eq!(result[0].name, "user/workspace/somespec");
357    }
358
359    #[test]
360    fn parse_fact_spec_reference_with_at_prefix() {
361        let input = r#"spec example
362fact external: spec @user/workspace/somespec"#;
363        let result = parse(input, "test.lemma", &ResourceLimits::default())
364            .unwrap()
365            .specs;
366        assert_eq!(result.len(), 1);
367        assert_eq!(result[0].facts.len(), 1);
368        match &result[0].facts[0].value {
369            crate::parsing::ast::FactValue::SpecReference(spec_ref) => {
370                assert_eq!(spec_ref.name, "@user/workspace/somespec");
371                assert!(spec_ref.is_registry, "expected registry reference");
372            }
373            other => panic!("Expected SpecReference, got: {:?}", other),
374        }
375    }
376
377    #[test]
378    fn parse_type_import_with_at_prefix() {
379        let input = r#"spec example
380type money from @lemma/std/finance
381fact price: [money]"#;
382        let result = parse(input, "test.lemma", &ResourceLimits::default())
383            .unwrap()
384            .specs;
385        assert_eq!(result.len(), 1);
386        assert_eq!(result[0].types.len(), 1);
387        match &result[0].types[0] {
388            crate::parsing::ast::TypeDef::Import { from, name, .. } => {
389                assert_eq!(from.name, "@lemma/std/finance");
390                assert!(from.is_registry, "expected registry reference");
391                assert_eq!(name, "money");
392            }
393            other => panic!("Expected Import type, got: {:?}", other),
394        }
395    }
396
397    #[test]
398    fn parse_multiple_registry_specs_in_same_file() {
399        let input = r#"spec user/workspace/spec_a
400fact x: 10
401
402spec user/workspace/spec_b
403fact y: 20
404fact a: spec @user/workspace/spec_a"#;
405        let result = parse(input, "test.lemma", &ResourceLimits::default())
406            .unwrap()
407            .specs;
408        assert_eq!(result.len(), 2);
409        assert_eq!(result[0].name, "user/workspace/spec_a");
410        assert_eq!(result[1].name, "user/workspace/spec_b");
411    }
412
413    #[test]
414    fn parse_registry_spec_ref_name_only() {
415        let input = "spec example\nfact x: spec @owner/repo/somespec";
416        let result = parse(input, "test.lemma", &ResourceLimits::default())
417            .unwrap()
418            .specs;
419        match &result[0].facts[0].value {
420            crate::parsing::ast::FactValue::SpecReference(spec_ref) => {
421                assert_eq!(spec_ref.name, "@owner/repo/somespec");
422                assert_eq!(spec_ref.hash_pin, None);
423                assert!(spec_ref.is_registry);
424            }
425            other => panic!("Expected SpecReference, got: {:?}", other),
426        }
427    }
428
429    #[test]
430    fn parse_registry_spec_ref_name_with_dots_is_whole_name() {
431        let input = "spec example\nfact x: spec @owner/repo/somespec";
432        let result = parse(input, "test.lemma", &ResourceLimits::default())
433            .unwrap()
434            .specs;
435        match &result[0].facts[0].value {
436            crate::parsing::ast::FactValue::SpecReference(spec_ref) => {
437                assert_eq!(spec_ref.name, "@owner/repo/somespec");
438                assert!(spec_ref.is_registry);
439            }
440            other => panic!("Expected SpecReference, got: {:?}", other),
441        }
442    }
443
444    #[test]
445    fn parse_local_spec_ref_name_only() {
446        let input = "spec example\nfact x: spec myspec";
447        let result = parse(input, "test.lemma", &ResourceLimits::default())
448            .unwrap()
449            .specs;
450        match &result[0].facts[0].value {
451            crate::parsing::ast::FactValue::SpecReference(spec_ref) => {
452                assert_eq!(spec_ref.name, "myspec");
453                assert_eq!(spec_ref.hash_pin, None);
454                assert!(!spec_ref.is_registry);
455            }
456            other => panic!("Expected SpecReference, got: {:?}", other),
457        }
458    }
459
460    #[test]
461    fn parse_spec_name_with_trailing_dot_is_error() {
462        let input = "spec myspec.\nfact x: 1";
463        let result = parse(input, "test.lemma", &ResourceLimits::default());
464        assert!(
465            result.is_err(),
466            "Trailing dot after spec name should be a parse error"
467        );
468    }
469
470    #[test]
471    fn parse_type_import_from_registry() {
472        let input = "spec example\ntype money from @lemma/std/finance\nfact price: [money]";
473        let result = parse(input, "test.lemma", &ResourceLimits::default())
474            .unwrap()
475            .specs;
476        match &result[0].types[0] {
477            crate::parsing::ast::TypeDef::Import { from, name, .. } => {
478                assert_eq!(from.name, "@lemma/std/finance");
479                assert!(from.is_registry);
480                assert_eq!(name, "money");
481            }
482            other => panic!("Expected Import type, got: {:?}", other),
483        }
484    }
485
486    #[test]
487    fn parse_spec_declaration_no_version() {
488        let input = "spec myspec\nrule x: 1";
489        let result = parse(input, "test.lemma", &ResourceLimits::default())
490            .unwrap()
491            .specs;
492        assert_eq!(result[0].name, "myspec");
493        assert_eq!(result[0].effective_from(), None);
494    }
495
496    #[test]
497    fn parse_multiple_specs_in_same_file() {
498        let input = "spec myspec_a\nrule x: 1\n\nspec myspec_b\nrule x: 2";
499        let result = parse(input, "test.lemma", &ResourceLimits::default())
500            .unwrap()
501            .specs;
502        assert_eq!(result.len(), 2);
503        assert_eq!(result[0].name, "myspec_a");
504        assert_eq!(result[1].name, "myspec_b");
505    }
506
507    #[test]
508    fn parse_spec_reference_grammar_accepts_name_only() {
509        let input = "spec consumer\nfact m: spec other";
510        let result = parse(input, "test.lemma", &ResourceLimits::default());
511        assert!(result.is_ok(), "spec name without hash should parse");
512        let spec_ref = match &result.as_ref().unwrap().specs[0].facts[0].value {
513            crate::parsing::ast::FactValue::SpecReference(r) => r,
514            _ => panic!("expected SpecReference"),
515        };
516        assert_eq!(spec_ref.name, "other");
517        assert_eq!(spec_ref.hash_pin, None);
518    }
519
520    #[test]
521    fn parse_spec_reference_with_hash() {
522        let input = "spec consumer\nfact cfg: spec config~a1b2c3d4";
523        let result = parse(input, "test.lemma", &ResourceLimits::default())
524            .unwrap()
525            .specs;
526        let spec_ref = match &result[0].facts[0].value {
527            crate::parsing::ast::FactValue::SpecReference(r) => r,
528            other => panic!("expected SpecReference, got: {:?}", other),
529        };
530        assert_eq!(spec_ref.name, "config");
531        assert_eq!(spec_ref.hash_pin.as_deref(), Some("a1b2c3d4"));
532    }
533
534    #[test]
535    fn parse_spec_reference_registry_with_hash() {
536        let input = "spec consumer\nfact ext: spec @user/workspace/cfg~ab12cd34";
537        let result = parse(input, "test.lemma", &ResourceLimits::default())
538            .unwrap()
539            .specs;
540        let spec_ref = match &result[0].facts[0].value {
541            crate::parsing::ast::FactValue::SpecReference(r) => r,
542            other => panic!("expected SpecReference, got: {:?}", other),
543        };
544        assert_eq!(spec_ref.name, "@user/workspace/cfg");
545        assert!(spec_ref.is_registry);
546        assert_eq!(spec_ref.hash_pin.as_deref(), Some("ab12cd34"));
547    }
548
549    #[test]
550    fn parse_type_import_with_hash() {
551        let input = "spec consumer\ntype money from finance a1b2c3d4\nfact p: [money]";
552        let result = parse(input, "test.lemma", &ResourceLimits::default())
553            .unwrap()
554            .specs;
555        match &result[0].types[0] {
556            crate::parsing::ast::TypeDef::Import { from, name, .. } => {
557                assert_eq!(name, "money");
558                assert_eq!(from.name, "finance");
559                assert_eq!(from.hash_pin.as_deref(), Some("a1b2c3d4"));
560            }
561            other => panic!("expected Import, got: {:?}", other),
562        }
563    }
564
565    #[test]
566    fn parse_type_import_registry_with_hash() {
567        let input = "spec consumer\ntype money from @lemma/std/finance ab12cd34\nfact p: [money]";
568        let result = parse(input, "test.lemma", &ResourceLimits::default())
569            .unwrap()
570            .specs;
571        match &result[0].types[0] {
572            crate::parsing::ast::TypeDef::Import { from, name, .. } => {
573                assert_eq!(name, "money");
574                assert_eq!(from.name, "@lemma/std/finance");
575                assert!(from.is_registry);
576                assert_eq!(from.hash_pin.as_deref(), Some("ab12cd34"));
577            }
578            other => panic!("expected Import, got: {:?}", other),
579        }
580    }
581
582    #[test]
583    fn parse_inline_type_from_with_hash() {
584        let input = "spec consumer\nfact price: [money from finance a1b2c3d4 -> minimum 0]";
585        let result = parse(input, "test.lemma", &ResourceLimits::default())
586            .unwrap()
587            .specs;
588        match &result[0].facts[0].value {
589            crate::parsing::ast::FactValue::TypeDeclaration {
590                base,
591                from,
592                constraints,
593                ..
594            } => {
595                assert_eq!(base, "money");
596                let spec_ref = from.as_ref().expect("expected from spec ref");
597                assert_eq!(spec_ref.name, "finance");
598                assert_eq!(spec_ref.hash_pin.as_deref(), Some("a1b2c3d4"));
599                assert!(constraints.is_some());
600            }
601            other => panic!("expected TypeDeclaration, got: {:?}", other),
602        }
603    }
604
605    #[test]
606    fn parse_type_import_spec_name_with_slashes() {
607        let input = "spec consumer\ntype money from @lemma/std/finance\nfact p: [money]";
608        let result = parse(input, "test.lemma", &ResourceLimits::default());
609        assert!(result.is_ok(), "type import from registry should parse");
610        match &result.unwrap().specs[0].types[0] {
611            crate::parsing::ast::TypeDef::Import { from, .. } => {
612                assert_eq!(from.name, "@lemma/std/finance")
613            }
614            _ => panic!("expected Import"),
615        }
616    }
617
618    #[test]
619    fn parse_error_is_returned_for_garbage_input() {
620        let result = parse(
621            r#"
622spec test
623this is not valid lemma syntax @#$%
624"#,
625            "test.lemma",
626            &ResourceLimits::default(),
627        );
628
629        assert!(result.is_err(), "Should fail on malformed input");
630        match result {
631            Err(Error::Parsing { .. }) => {
632                // Expected
633            }
634            Err(e) => panic!("Expected Parse error, got: {e:?}"),
635            Ok(_) => panic!("Expected parse error"),
636        }
637    }
638}