Skip to main content

intent_parser/
parser.rs

1//! Pest-based parser that converts `.intent` source text into a typed AST.
2//!
3//! The grammar is defined in `grammar/intent.pest`. This module wraps the
4//! generated pest parser and transforms pest `Pairs` into [`ast`] nodes.
5
6use pest::Parser;
7use pest_derive::Parser;
8
9use crate::ast::*;
10
11/// The pest-generated parser. Grammar is loaded at compile time from the
12/// workspace-relative path.
13#[derive(Parser)]
14#[grammar = "src/intent.pest"]
15pub struct IntentParser;
16
17/// Parse error with human-readable message and source location.
18#[derive(Debug, thiserror::Error, miette::Diagnostic, Clone)]
19#[error("{message}")]
20#[diagnostic(code(intent::parse::syntax_error))]
21pub struct ParseError {
22    pub message: String,
23    #[label("{label}")]
24    pub span: miette::SourceSpan,
25    pub label: String,
26    #[help]
27    pub help: Option<String>,
28}
29
30impl From<pest::error::Error<Rule>> for ParseError {
31    fn from(err: pest::error::Error<Rule>) -> Self {
32        humanize_pest_error(err)
33    }
34}
35
36/// Convert a pest error into a human-readable ParseError with helpful messages.
37fn humanize_pest_error(err: pest::error::Error<Rule>) -> ParseError {
38    let (offset, len) = match err.location {
39        pest::error::InputLocation::Pos(p) => (p, 1),
40        pest::error::InputLocation::Span((s, e)) => (s, e - s),
41    };
42    let span: miette::SourceSpan = (offset, len).into();
43
44    // Extract the expected rules from the pest error variant
45    let (message, label, help) = match &err.variant {
46        pest::error::ErrorVariant::ParsingError { positives, .. } => {
47            humanize_expected_rules(positives)
48        }
49        pest::error::ErrorVariant::CustomError { message } => {
50            (message.clone(), "here".to_string(), None)
51        }
52    };
53
54    ParseError {
55        message,
56        span,
57        label,
58        help,
59    }
60}
61
62/// Map pest rule names to human-readable error messages.
63fn humanize_expected_rules(rules: &[Rule]) -> (String, String, Option<String>) {
64    // Check for common patterns in what was expected
65    let rule_set: std::collections::HashSet<&Rule> = rules.iter().collect();
66
67    if rule_set.contains(&Rule::module_decl) {
68        return (
69            "missing module declaration".to_string(),
70            "expected `module ModuleName`".to_string(),
71            Some("every .intent file must start with `module ModuleName`".to_string()),
72        );
73    }
74
75    if rule_set.contains(&Rule::union_type) || rule_set.contains(&Rule::simple_type) {
76        return (
77            "invalid type".to_string(),
78            "expected a type".to_string(),
79            Some(
80                "types must start with an uppercase letter (e.g., String, UUID, MyEntity)"
81                    .to_string(),
82            ),
83        );
84    }
85
86    if rule_set.contains(&Rule::optional_marker) && rule_set.contains(&Rule::ident) {
87        return (
88            "unexpected end of block".to_string(),
89            "expected a field declaration or `}`".to_string(),
90            Some("check for unclosed braces or missing field declarations".to_string()),
91        );
92    }
93
94    if rule_set.contains(&Rule::field_decl) || rule_set.contains(&Rule::param_decl) {
95        return (
96            "expected a field or parameter declaration".to_string(),
97            "expected `name: Type`".to_string(),
98            Some("fields are declared as `name: Type` (e.g., `email: String`)".to_string()),
99        );
100    }
101
102    if rule_set.contains(&Rule::EOI) {
103        return (
104            "unexpected content after end of file".to_string(),
105            "unexpected token".to_string(),
106            Some("check for extra text or unclosed blocks".to_string()),
107        );
108    }
109
110    // Fallback: format the rule names
111    let names: Vec<String> = rules
112        .iter()
113        .filter(|r| !matches!(r, Rule::WHITESPACE | Rule::COMMENT | Rule::EOI))
114        .map(|r| format!("`{:?}`", r))
115        .collect();
116
117    let msg = if names.is_empty() {
118        "syntax error".to_string()
119    } else {
120        format!("expected {}", names.join(" or "))
121    };
122
123    ("syntax error".to_string(), msg, None)
124}
125
126/// Parse a complete `.intent` source string into an AST [`File`].
127pub fn parse_file(source: &str) -> Result<File, ParseError> {
128    let pairs = IntentParser::parse(Rule::file, source)?;
129    let pair = pairs.into_iter().next().unwrap();
130    Ok(build_file(pair))
131}
132
133// ── Builders ─────────────────────────────────────────────────
134// Each `build_*` function consumes a pest `Pair` and returns an AST node.
135
136fn span_of(pair: &pest::iterators::Pair<'_, Rule>) -> Span {
137    let s = pair.as_span();
138    Span {
139        start: s.start(),
140        end: s.end(),
141    }
142}
143
144fn build_file(pair: pest::iterators::Pair<'_, Rule>) -> File {
145    let span = span_of(&pair);
146    let mut inner = pair.into_inner();
147
148    let module = build_module_decl(inner.next().unwrap());
149
150    let mut doc = None;
151    let mut imports = Vec::new();
152    let mut items = Vec::new();
153
154    for p in inner {
155        match p.as_rule() {
156            Rule::doc_block => doc = Some(build_doc_block(p)),
157            Rule::use_decl => imports.push(build_use_decl(p)),
158            Rule::entity_decl => items.push(TopLevelItem::Entity(build_entity_decl(p))),
159            Rule::action_decl => items.push(TopLevelItem::Action(build_action_decl(p))),
160            Rule::invariant_decl => items.push(TopLevelItem::Invariant(build_invariant_decl(p))),
161            Rule::edge_cases_decl => items.push(TopLevelItem::EdgeCases(build_edge_cases_decl(p))),
162            Rule::EOI => {}
163            _ => {}
164        }
165    }
166
167    File {
168        module,
169        doc,
170        imports,
171        items,
172        span,
173    }
174}
175
176fn build_use_decl(pair: pest::iterators::Pair<'_, Rule>) -> UseDecl {
177    let span = span_of(&pair);
178    let mut inner = pair.into_inner();
179    let module_name = inner.next().unwrap().as_str().to_string();
180    let item = inner.next().map(|p| p.as_str().to_string());
181    UseDecl {
182        module_name,
183        item,
184        span,
185    }
186}
187
188fn build_module_decl(pair: pest::iterators::Pair<'_, Rule>) -> ModuleDecl {
189    let span = span_of(&pair);
190    let name = pair.into_inner().next().unwrap().as_str().to_string();
191    ModuleDecl { name, span }
192}
193
194fn build_doc_block(pair: pest::iterators::Pair<'_, Rule>) -> DocBlock {
195    let span = span_of(&pair);
196    let lines = pair
197        .into_inner()
198        .map(|p| {
199            let text = p.as_str();
200            let content = text
201                .strip_prefix("---")
202                .unwrap_or(text)
203                .trim_end_matches('\n');
204            content.strip_prefix(' ').unwrap_or(content).to_string()
205        })
206        .collect();
207    DocBlock { lines, span }
208}
209
210fn build_entity_decl(pair: pest::iterators::Pair<'_, Rule>) -> EntityDecl {
211    let span = span_of(&pair);
212    let mut doc = None;
213    let mut name = String::new();
214    let mut fields = Vec::new();
215
216    for p in pair.into_inner() {
217        match p.as_rule() {
218            Rule::doc_block => doc = Some(build_doc_block(p)),
219            Rule::type_ident => name = p.as_str().to_string(),
220            Rule::field_decl => fields.push(build_field_decl(p)),
221            _ => {}
222        }
223    }
224
225    EntityDecl {
226        doc,
227        name,
228        fields,
229        span,
230    }
231}
232
233fn build_field_decl(pair: pest::iterators::Pair<'_, Rule>) -> FieldDecl {
234    let span = span_of(&pair);
235    let mut inner = pair.into_inner();
236    let name = inner.next().unwrap().as_str().to_string();
237    let ty = build_type_expr(inner.next().unwrap());
238    FieldDecl { name, ty, span }
239}
240
241fn build_action_decl(pair: pest::iterators::Pair<'_, Rule>) -> ActionDecl {
242    let span = span_of(&pair);
243    let mut doc = None;
244    let mut name = String::new();
245    let mut params = Vec::new();
246    let mut requires = None;
247    let mut ensures = None;
248    let mut properties = None;
249
250    for p in pair.into_inner() {
251        match p.as_rule() {
252            Rule::doc_block => doc = Some(build_doc_block(p)),
253            Rule::type_ident => name = p.as_str().to_string(),
254            Rule::param_decl => params.push(build_field_decl(p)),
255            Rule::requires_block => requires = Some(build_requires_block(p)),
256            Rule::ensures_block => ensures = Some(build_ensures_block(p)),
257            Rule::properties_block => properties = Some(build_properties_block(p)),
258            _ => {}
259        }
260    }
261
262    ActionDecl {
263        doc,
264        name,
265        params,
266        requires,
267        ensures,
268        properties,
269        span,
270    }
271}
272
273fn build_requires_block(pair: pest::iterators::Pair<'_, Rule>) -> RequiresBlock {
274    let span = span_of(&pair);
275    let conditions = pair.into_inner().map(build_expr).collect();
276    RequiresBlock { conditions, span }
277}
278
279fn build_ensures_block(pair: pest::iterators::Pair<'_, Rule>) -> EnsuresBlock {
280    let span = span_of(&pair);
281    let items = pair
282        .into_inner()
283        .map(|p| match p.as_rule() {
284            Rule::when_clause => EnsuresItem::When(build_when_clause(p)),
285            _ => EnsuresItem::Expr(build_expr(p)),
286        })
287        .collect();
288    EnsuresBlock { items, span }
289}
290
291fn build_when_clause(pair: pest::iterators::Pair<'_, Rule>) -> WhenClause {
292    let span = span_of(&pair);
293    let mut inner = pair.into_inner();
294    let condition = build_or_expr(inner.next().unwrap());
295    let consequence = build_expr(inner.next().unwrap());
296    WhenClause {
297        condition,
298        consequence,
299        span,
300    }
301}
302
303fn build_properties_block(pair: pest::iterators::Pair<'_, Rule>) -> PropertiesBlock {
304    let span = span_of(&pair);
305    let entries = pair.into_inner().map(build_prop_entry).collect();
306    PropertiesBlock { entries, span }
307}
308
309fn build_prop_entry(pair: pest::iterators::Pair<'_, Rule>) -> PropEntry {
310    let span = span_of(&pair);
311    let mut inner = pair.into_inner();
312    let key = inner.next().unwrap().as_str().to_string();
313    let value = build_prop_value(inner.next().unwrap());
314    PropEntry { key, value, span }
315}
316
317fn build_prop_value(pair: pest::iterators::Pair<'_, Rule>) -> PropValue {
318    match pair.as_rule() {
319        Rule::obj_literal => {
320            let fields = pair
321                .into_inner()
322                .map(|f| {
323                    let mut inner = f.into_inner();
324                    let key = inner.next().unwrap().as_str().to_string();
325                    let value = build_prop_value(inner.next().unwrap());
326                    (key, value)
327                })
328                .collect();
329            PropValue::Object(fields)
330        }
331        Rule::list_literal => {
332            let items = pair.into_inner().map(build_prop_value).collect();
333            PropValue::List(items)
334        }
335        Rule::string_literal => {
336            let s = extract_string(pair);
337            PropValue::Literal(Literal::String(s))
338        }
339        Rule::number_literal => PropValue::Literal(parse_number_literal(pair.as_str())),
340        Rule::bool_literal => PropValue::Literal(Literal::Bool(pair.as_str() == "true")),
341        Rule::ident => PropValue::Ident(pair.as_str().to_string()),
342        // For expressions nested in prop value contexts, try to extract
343        Rule::expr | Rule::implies_expr => {
344            // Recurse into inner pairs
345            let inner = pair.into_inner().next().unwrap();
346            build_prop_value(inner)
347        }
348        _ => PropValue::Ident(pair.as_str().to_string()),
349    }
350}
351
352fn build_invariant_decl(pair: pest::iterators::Pair<'_, Rule>) -> InvariantDecl {
353    let span = span_of(&pair);
354    let mut doc = None;
355    let mut name = String::new();
356    let mut body = None;
357
358    for p in pair.into_inner() {
359        match p.as_rule() {
360            Rule::doc_block => doc = Some(build_doc_block(p)),
361            Rule::type_ident => name = p.as_str().to_string(),
362            Rule::expr => body = Some(build_expr(p)),
363            _ => {}
364        }
365    }
366
367    InvariantDecl {
368        doc,
369        name,
370        body: body.expect("invariant must have a body expression"),
371        span,
372    }
373}
374
375fn build_edge_cases_decl(pair: pest::iterators::Pair<'_, Rule>) -> EdgeCasesDecl {
376    let span = span_of(&pair);
377    let rules = pair.into_inner().map(build_edge_rule).collect();
378    EdgeCasesDecl { rules, span }
379}
380
381fn build_edge_rule(pair: pest::iterators::Pair<'_, Rule>) -> EdgeRule {
382    let span = span_of(&pair);
383    let mut inner = pair.into_inner();
384    let condition = build_or_expr(inner.next().unwrap());
385    let action = build_action_call(inner.next().unwrap());
386    EdgeRule {
387        condition,
388        action,
389        span,
390    }
391}
392
393fn build_action_call(pair: pest::iterators::Pair<'_, Rule>) -> ActionCall {
394    let span = span_of(&pair);
395    let mut inner = pair.into_inner();
396    let name = inner.next().unwrap().as_str().to_string();
397    let args = inner
398        .next()
399        .map(|p| p.into_inner().map(build_call_arg).collect())
400        .unwrap_or_default();
401    ActionCall { name, args, span }
402}
403
404// ── Type expression builders ─────────────────────────────────
405
406fn build_type_expr(pair: pest::iterators::Pair<'_, Rule>) -> TypeExpr {
407    let span = span_of(&pair);
408    let mut optional = false;
409    let mut ty_kind = None;
410
411    for p in pair.into_inner() {
412        match p.as_rule() {
413            Rule::union_type => ty_kind = Some(build_union_type(p)),
414            Rule::optional_marker => optional = true,
415            _ => {}
416        }
417    }
418
419    TypeExpr {
420        ty: ty_kind.unwrap(),
421        optional,
422        span,
423    }
424}
425
426fn build_union_type(pair: pest::iterators::Pair<'_, Rule>) -> TypeKind {
427    let variants: Vec<TypeKind> = pair.into_inner().map(build_base_type).collect();
428    if variants.len() == 1 {
429        variants.into_iter().next().unwrap()
430    } else {
431        TypeKind::Union(variants)
432    }
433}
434
435fn build_base_type(pair: pest::iterators::Pair<'_, Rule>) -> TypeKind {
436    match pair.as_rule() {
437        Rule::list_type => {
438            let inner = pair.into_inner().next().unwrap();
439            TypeKind::List(Box::new(build_type_expr(inner)))
440        }
441        Rule::set_type => {
442            let inner = pair.into_inner().next().unwrap();
443            TypeKind::Set(Box::new(build_type_expr(inner)))
444        }
445        Rule::map_type => {
446            let mut inner = pair.into_inner();
447            let key = build_type_expr(inner.next().unwrap());
448            let value = build_type_expr(inner.next().unwrap());
449            TypeKind::Map(Box::new(key), Box::new(value))
450        }
451        Rule::parameterized_type => {
452            let mut inner = pair.into_inner();
453            let name = inner.next().unwrap().as_str().to_string();
454            let params = inner.map(build_type_param).collect();
455            TypeKind::Parameterized { name, params }
456        }
457        Rule::simple_type => {
458            let name = pair.into_inner().next().unwrap().as_str().to_string();
459            TypeKind::Simple(name)
460        }
461        _ => TypeKind::Simple(pair.as_str().to_string()),
462    }
463}
464
465fn build_type_param(pair: pest::iterators::Pair<'_, Rule>) -> TypeParam {
466    let span = span_of(&pair);
467    let mut inner = pair.into_inner();
468    let name = inner.next().unwrap().as_str().to_string();
469    let value = parse_number_literal(inner.next().unwrap().as_str());
470    TypeParam { name, value, span }
471}
472
473// ── Expression builders ──────────────────────────────────────
474
475fn build_expr(pair: pest::iterators::Pair<'_, Rule>) -> Expr {
476    let span = span_of(&pair);
477    let inner = pair.into_inner().next().unwrap();
478    match inner.as_rule() {
479        Rule::implies_expr => build_implies_expr(inner),
480        _ => {
481            let kind = build_expr_kind(inner);
482            Expr { kind, span }
483        }
484    }
485}
486
487fn build_implies_expr(pair: pest::iterators::Pair<'_, Rule>) -> Expr {
488    let mut parts: Vec<pest::iterators::Pair<'_, Rule>> = Vec::new();
489
490    for p in pair.into_inner() {
491        match p.as_rule() {
492            Rule::implies_op => {}
493            _ => parts.push(p),
494        }
495    }
496
497    let mut result = build_or_expr(parts.remove(0));
498    for part in parts {
499        let right = build_or_expr(part);
500        let new_span = Span {
501            start: result.span.start,
502            end: right.span.end,
503        };
504        result = Expr {
505            kind: ExprKind::Implies(Box::new(result), Box::new(right)),
506            span: new_span,
507        };
508    }
509    result
510}
511
512fn build_or_expr(pair: pest::iterators::Pair<'_, Rule>) -> Expr {
513    let span = span_of(&pair);
514    let mut parts: Vec<pest::iterators::Pair<'_, Rule>> = Vec::new();
515
516    for p in pair.into_inner() {
517        match p.as_rule() {
518            Rule::or_op => {}
519            _ => parts.push(p),
520        }
521    }
522
523    if parts.is_empty() {
524        return Expr {
525            kind: ExprKind::Literal(Literal::Null),
526            span,
527        };
528    }
529
530    let mut result = build_and_expr(parts.remove(0));
531    for part in parts {
532        let right = build_and_expr(part);
533        let new_span = Span {
534            start: result.span.start,
535            end: right.span.end,
536        };
537        result = Expr {
538            kind: ExprKind::Or(Box::new(result), Box::new(right)),
539            span: new_span,
540        };
541    }
542    result
543}
544
545fn build_and_expr(pair: pest::iterators::Pair<'_, Rule>) -> Expr {
546    let span = span_of(&pair);
547    let mut parts: Vec<pest::iterators::Pair<'_, Rule>> = Vec::new();
548
549    for p in pair.into_inner() {
550        match p.as_rule() {
551            Rule::and_op => {}
552            _ => parts.push(p),
553        }
554    }
555
556    if parts.is_empty() {
557        return Expr {
558            kind: ExprKind::Literal(Literal::Null),
559            span,
560        };
561    }
562
563    let mut result = build_not_expr(parts.remove(0));
564    for part in parts {
565        let right = build_not_expr(part);
566        let new_span = Span {
567            start: result.span.start,
568            end: right.span.end,
569        };
570        result = Expr {
571            kind: ExprKind::And(Box::new(result), Box::new(right)),
572            span: new_span,
573        };
574    }
575    result
576}
577
578fn build_not_expr(pair: pest::iterators::Pair<'_, Rule>) -> Expr {
579    let span = span_of(&pair);
580    let mut inner = pair.into_inner();
581    let first = inner.next().unwrap();
582
583    match first.as_rule() {
584        Rule::not_op => {
585            let operand = build_not_expr(inner.next().unwrap());
586            Expr {
587                kind: ExprKind::Not(Box::new(operand)),
588                span,
589            }
590        }
591        Rule::cmp_expr => build_cmp_expr(first),
592        _ => {
593            let kind = build_expr_kind(first);
594            Expr { kind, span }
595        }
596    }
597}
598
599fn build_cmp_expr(pair: pest::iterators::Pair<'_, Rule>) -> Expr {
600    let span = span_of(&pair);
601    let mut inner = pair.into_inner();
602    let left = build_add_expr(inner.next().unwrap());
603
604    if let Some(op_pair) = inner.next() {
605        let op = match op_pair.as_str() {
606            "==" => CmpOp::Eq,
607            "!=" => CmpOp::Ne,
608            "<" => CmpOp::Lt,
609            ">" => CmpOp::Gt,
610            "<=" => CmpOp::Le,
611            ">=" => CmpOp::Ge,
612            _ => unreachable!("unknown cmp op: {}", op_pair.as_str()),
613        };
614        let right = build_add_expr(inner.next().unwrap());
615        Expr {
616            kind: ExprKind::Compare {
617                left: Box::new(left),
618                op,
619                right: Box::new(right),
620            },
621            span,
622        }
623    } else {
624        Expr {
625            kind: left.kind,
626            span,
627        }
628    }
629}
630
631fn build_add_expr(pair: pest::iterators::Pair<'_, Rule>) -> Expr {
632    let mut children: Vec<pest::iterators::Pair<'_, Rule>> = pair.into_inner().collect();
633
634    if children.len() == 1 {
635        return build_primary(children.remove(0));
636    }
637
638    // Interleaved: primary, op, primary, op, primary, ...
639    let mut iter = children.into_iter();
640    let mut result = build_primary(iter.next().unwrap());
641
642    while let Some(op_pair) = iter.next() {
643        let op = match op_pair.as_str() {
644            "+" => ArithOp::Add,
645            "-" => ArithOp::Sub,
646            _ => unreachable!("unknown add op"),
647        };
648        let right = build_primary(iter.next().unwrap());
649        let new_span = Span {
650            start: result.span.start,
651            end: right.span.end,
652        };
653        result = Expr {
654            kind: ExprKind::Arithmetic {
655                left: Box::new(result),
656                op,
657                right: Box::new(right),
658            },
659            span: new_span,
660        };
661    }
662
663    result
664}
665
666fn build_primary(pair: pest::iterators::Pair<'_, Rule>) -> Expr {
667    let span = span_of(&pair);
668    let mut inner: Vec<pest::iterators::Pair<'_, Rule>> = pair.into_inner().collect();
669
670    // First child is the atom, rest are `.ident` field accesses
671    let atom_pair = inner.remove(0);
672    let base = build_atom(atom_pair);
673
674    if inner.is_empty() {
675        return Expr {
676            kind: base.kind,
677            span,
678        };
679    }
680
681    let fields: Vec<String> = inner.into_iter().map(|p| p.as_str().to_string()).collect();
682
683    Expr {
684        kind: ExprKind::FieldAccess {
685            root: Box::new(base),
686            fields,
687        },
688        span,
689    }
690}
691
692fn build_atom(pair: pest::iterators::Pair<'_, Rule>) -> Expr {
693    let span = span_of(&pair);
694    match pair.as_rule() {
695        Rule::old_expr => {
696            let inner = pair.into_inner().next().unwrap();
697            let expr = build_expr(inner);
698            Expr {
699                kind: ExprKind::Old(Box::new(expr)),
700                span,
701            }
702        }
703        Rule::quantifier_expr => {
704            let mut inner = pair.into_inner();
705            let kw = inner.next().unwrap();
706            let kind = match kw.as_str() {
707                "forall" => QuantifierKind::Forall,
708                "exists" => QuantifierKind::Exists,
709                _ => unreachable!(),
710            };
711            let binding = inner.next().unwrap().as_str().to_string();
712            let ty = inner.next().unwrap().as_str().to_string();
713            let body = build_expr(inner.next().unwrap());
714            Expr {
715                kind: ExprKind::Quantifier {
716                    kind,
717                    binding,
718                    ty,
719                    body: Box::new(body),
720                },
721                span,
722            }
723        }
724        Rule::null_literal => Expr {
725            kind: ExprKind::Literal(Literal::Null),
726            span,
727        },
728        Rule::bool_literal => Expr {
729            kind: ExprKind::Literal(Literal::Bool(pair.as_str() == "true")),
730            span,
731        },
732        Rule::number_literal => Expr {
733            kind: ExprKind::Literal(parse_number_literal(pair.as_str())),
734            span,
735        },
736        Rule::string_literal => Expr {
737            kind: ExprKind::Literal(Literal::String(extract_string(pair))),
738            span,
739        },
740        Rule::list_literal => {
741            let items = pair.into_inner().map(build_expr).collect();
742            Expr {
743                kind: ExprKind::List(items),
744                span,
745            }
746        }
747        Rule::paren_expr => {
748            let inner = pair.into_inner().next().unwrap();
749            build_expr(inner)
750        }
751        Rule::call_or_ident => {
752            // When call_args is empty (e.g., `now()`), pest produces no inner
753            // pairs for the parens. Check the raw text for `(` to distinguish
754            // zero-arg calls from plain identifiers.
755            let text = pair.as_str();
756            let mut inner = pair.into_inner();
757            let name = inner.next().unwrap().as_str().to_string();
758            if text.contains('(') {
759                let args = inner
760                    .next()
761                    .map(|args_pair| args_pair.into_inner().map(build_call_arg).collect())
762                    .unwrap_or_default();
763                Expr {
764                    kind: ExprKind::Call { name, args },
765                    span,
766                }
767            } else {
768                Expr {
769                    kind: ExprKind::Ident(name),
770                    span,
771                }
772            }
773        }
774        _ => Expr {
775            kind: ExprKind::Ident(pair.as_str().to_string()),
776            span,
777        },
778    }
779}
780
781fn build_call_arg(pair: pest::iterators::Pair<'_, Rule>) -> CallArg {
782    let mut inner = pair.into_inner();
783    let first = inner.next().unwrap();
784
785    match first.as_rule() {
786        Rule::named_arg => {
787            let span = span_of(&first);
788            let mut named_inner = first.into_inner();
789            let key = named_inner.next().unwrap().as_str().to_string();
790            let value = build_expr(named_inner.next().unwrap());
791            CallArg::Named { key, value, span }
792        }
793        _ => CallArg::Positional(build_expr(first)),
794    }
795}
796
797fn build_expr_kind(pair: pest::iterators::Pair<'_, Rule>) -> ExprKind {
798    match pair.as_rule() {
799        Rule::implies_expr => build_implies_expr(pair).kind,
800        Rule::or_expr => build_or_expr(pair).kind,
801        Rule::and_expr => build_and_expr(pair).kind,
802        Rule::not_expr => build_not_expr(pair).kind,
803        Rule::cmp_expr => build_cmp_expr(pair).kind,
804        Rule::add_expr => build_add_expr(pair).kind,
805        Rule::primary => build_primary(pair).kind,
806        _ => build_atom(pair).kind,
807    }
808}
809
810// ── Helpers ──────────────────────────────────────────────────
811
812fn parse_number_literal(s: &str) -> Literal {
813    if s.contains('.') {
814        Literal::Decimal(s.to_string())
815    } else {
816        Literal::Int(s.parse().unwrap_or(0))
817    }
818}
819
820fn extract_string(pair: pest::iterators::Pair<'_, Rule>) -> String {
821    pair.into_inner()
822        .next()
823        .map(|p| p.as_str().to_string())
824        .unwrap_or_default()
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830
831    #[test]
832    fn parse_minimal_module() {
833        let src = "module Foo\n";
834        let file = parse_file(src).unwrap();
835        assert_eq!(file.module.name, "Foo");
836        assert!(file.items.is_empty());
837    }
838
839    #[test]
840    fn parse_entity() {
841        let src = r#"module Test
842
843entity Account {
844  id: UUID
845  balance: Decimal(precision: 2)
846  status: Active | Frozen | Closed
847  notes: String?
848}
849"#;
850        let file = parse_file(src).unwrap();
851        assert_eq!(file.items.len(), 1);
852        if let TopLevelItem::Entity(e) = &file.items[0] {
853            assert_eq!(e.name, "Account");
854            assert_eq!(e.fields.len(), 4);
855            assert_eq!(e.fields[0].name, "id");
856            assert!(e.fields[2].ty.optional == false);
857            assert!(e.fields[3].ty.optional == true);
858        } else {
859            panic!("expected entity");
860        }
861    }
862
863    #[test]
864    fn parse_action_with_requires_ensures() {
865        let src = r#"module Test
866
867action Transfer {
868  from: Account
869  amount: Decimal(precision: 2)
870
871  requires {
872    from.status == Active
873    amount > 0
874  }
875
876  ensures {
877    from.balance == old(from.balance) - amount
878  }
879}
880"#;
881        let file = parse_file(src).unwrap();
882        assert_eq!(file.items.len(), 1);
883        if let TopLevelItem::Action(a) = &file.items[0] {
884            assert_eq!(a.name, "Transfer");
885            assert_eq!(a.params.len(), 2);
886            assert_eq!(a.requires.as_ref().unwrap().conditions.len(), 2);
887            assert_eq!(a.ensures.as_ref().unwrap().items.len(), 1);
888        } else {
889            panic!("expected action");
890        }
891    }
892
893    #[test]
894    fn parse_invariant() {
895        let src = r#"module Test
896
897invariant NoNegativeBalances {
898  forall a: Account => a.balance >= 0
899}
900"#;
901        let file = parse_file(src).unwrap();
902        if let TopLevelItem::Invariant(inv) = &file.items[0] {
903            assert_eq!(inv.name, "NoNegativeBalances");
904            assert!(matches!(inv.body.kind, ExprKind::Quantifier { .. }));
905        } else {
906            panic!("expected invariant");
907        }
908    }
909
910    #[test]
911    fn parse_edge_cases() {
912        let src = r#"module Test
913
914edge_cases {
915  when amount > 10000.00 => require_approval(level: "manager")
916  when from == to => reject("Cannot transfer to same account")
917}
918"#;
919        let file = parse_file(src).unwrap();
920        if let TopLevelItem::EdgeCases(ec) = &file.items[0] {
921            assert_eq!(ec.rules.len(), 2);
922            assert_eq!(ec.rules[0].action.name, "require_approval");
923            assert_eq!(ec.rules[1].action.name, "reject");
924        } else {
925            panic!("expected edge_cases");
926        }
927    }
928
929    #[test]
930    fn parse_list_literal() {
931        let src = r#"module Test
932
933action SetTags {
934  item: Item
935
936  ensures {
937    item.tags == [1, 2, 3]
938  }
939}
940"#;
941        let file = parse_file(src).unwrap();
942        if let TopLevelItem::Action(a) = &file.items[0] {
943            let ensures = a.ensures.as_ref().unwrap();
944            // The ensures condition is: item.tags == [1, 2, 3]
945            if let EnsuresItem::Expr(expr) = &ensures.items[0] {
946                if let ExprKind::Compare { right, .. } = &expr.kind {
947                    if let ExprKind::List(items) = &right.kind {
948                        assert_eq!(items.len(), 3);
949                        assert!(matches!(items[0].kind, ExprKind::Literal(Literal::Int(1))));
950                        assert!(matches!(items[1].kind, ExprKind::Literal(Literal::Int(2))));
951                        assert!(matches!(items[2].kind, ExprKind::Literal(Literal::Int(3))));
952                    } else {
953                        panic!("expected list literal on right side");
954                    }
955                } else {
956                    panic!("expected compare expr");
957                }
958            } else {
959                panic!("expected ensures expr");
960            }
961        } else {
962            panic!("expected action");
963        }
964    }
965
966    #[test]
967    fn parse_empty_list_literal() {
968        let src = r#"module Test
969
970action Clear {
971  item: Item
972
973  ensures {
974    item.tags == []
975  }
976}
977"#;
978        let file = parse_file(src).unwrap();
979        if let TopLevelItem::Action(a) = &file.items[0] {
980            let ensures = a.ensures.as_ref().unwrap();
981            if let EnsuresItem::Expr(expr) = &ensures.items[0] {
982                if let ExprKind::Compare { right, .. } = &expr.kind {
983                    if let ExprKind::List(items) = &right.kind {
984                        assert!(items.is_empty());
985                    } else {
986                        panic!("expected empty list literal on right side");
987                    }
988                } else {
989                    panic!("expected compare expr");
990                }
991            } else {
992                panic!("expected ensures expr");
993            }
994        } else {
995            panic!("expected action");
996        }
997    }
998
999    #[test]
1000    fn parse_use_whole_module() {
1001        let src = "module Foo\n\nuse Bar\n";
1002        let file = parse_file(src).unwrap();
1003        assert_eq!(file.imports.len(), 1);
1004        assert_eq!(file.imports[0].module_name, "Bar");
1005        assert_eq!(file.imports[0].item, None);
1006    }
1007
1008    #[test]
1009    fn parse_use_specific_item() {
1010        let src = "module Foo\n\nuse Bar.Account\n";
1011        let file = parse_file(src).unwrap();
1012        assert_eq!(file.imports.len(), 1);
1013        assert_eq!(file.imports[0].module_name, "Bar");
1014        assert_eq!(file.imports[0].item.as_deref(), Some("Account"));
1015    }
1016
1017    #[test]
1018    fn parse_multiple_imports() {
1019        let src = "module Foo\n\nuse Bar\nuse Baz.Entity\nuse Qux.Action\n";
1020        let file = parse_file(src).unwrap();
1021        assert_eq!(file.imports.len(), 3);
1022        assert_eq!(file.imports[0].module_name, "Bar");
1023        assert_eq!(file.imports[0].item, None);
1024        assert_eq!(file.imports[1].module_name, "Baz");
1025        assert_eq!(file.imports[1].item.as_deref(), Some("Entity"));
1026        assert_eq!(file.imports[2].module_name, "Qux");
1027        assert_eq!(file.imports[2].item.as_deref(), Some("Action"));
1028    }
1029
1030    #[test]
1031    fn parse_imports_with_doc_block() {
1032        let src = "module Foo\n\n--- A module that imports things.\n\nuse Bar\n\nentity Thing {\n  id: UUID\n}\n";
1033        let file = parse_file(src).unwrap();
1034        assert!(file.doc.is_some());
1035        assert_eq!(file.imports.len(), 1);
1036        assert_eq!(file.items.len(), 1);
1037    }
1038
1039    #[test]
1040    fn parse_no_imports() {
1041        let src = "module Foo\n\nentity Bar {\n  id: UUID\n}\n";
1042        let file = parse_file(src).unwrap();
1043        assert!(file.imports.is_empty());
1044        assert_eq!(file.items.len(), 1);
1045    }
1046
1047    #[test]
1048    fn parse_transfer_example() {
1049        let src = include_str!("../../../examples/transfer.intent");
1050        let file = parse_file(src).unwrap();
1051        assert_eq!(file.module.name, "TransferFunds");
1052        // 2 entities + 2 actions + 2 invariants + 1 edge_cases = 7 items
1053        assert_eq!(file.items.len(), 7);
1054    }
1055
1056    #[test]
1057    fn parse_auth_example() {
1058        let src = include_str!("../../../examples/auth.intent");
1059        let file = parse_file(src).unwrap();
1060        assert_eq!(file.module.name, "Authentication");
1061        // 2 entities + 2 actions + 2 invariants + 1 edge_cases = 7 items
1062        assert_eq!(file.items.len(), 7);
1063    }
1064}