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
99fn 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 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 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 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#[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 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 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 ("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 #[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 }
898 Err(e) => panic!("Expected Parse error, got: {e:?}"),
899 Ok(_) => panic!("Expected parse error"),
900 }
901 }
902}