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#[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 }
634 Err(e) => panic!("Expected Parse error, got: {e:?}"),
635 Ok(_) => panic!("Expected parse error"),
636 }
637 }
638}