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 items = Vec::new();
152
153    for p in inner {
154        match p.as_rule() {
155            Rule::doc_block => doc = Some(build_doc_block(p)),
156            Rule::entity_decl => items.push(TopLevelItem::Entity(build_entity_decl(p))),
157            Rule::action_decl => items.push(TopLevelItem::Action(build_action_decl(p))),
158            Rule::invariant_decl => items.push(TopLevelItem::Invariant(build_invariant_decl(p))),
159            Rule::edge_cases_decl => items.push(TopLevelItem::EdgeCases(build_edge_cases_decl(p))),
160            Rule::EOI => {}
161            _ => {}
162        }
163    }
164
165    File {
166        module,
167        doc,
168        items,
169        span,
170    }
171}
172
173fn build_module_decl(pair: pest::iterators::Pair<'_, Rule>) -> ModuleDecl {
174    let span = span_of(&pair);
175    let name = pair.into_inner().next().unwrap().as_str().to_string();
176    ModuleDecl { name, span }
177}
178
179fn build_doc_block(pair: pest::iterators::Pair<'_, Rule>) -> DocBlock {
180    let span = span_of(&pair);
181    let lines = pair
182        .into_inner()
183        .map(|p| {
184            let text = p.as_str();
185            let content = text
186                .strip_prefix("---")
187                .unwrap_or(text)
188                .trim_end_matches('\n');
189            content.strip_prefix(' ').unwrap_or(content).to_string()
190        })
191        .collect();
192    DocBlock { lines, span }
193}
194
195fn build_entity_decl(pair: pest::iterators::Pair<'_, Rule>) -> EntityDecl {
196    let span = span_of(&pair);
197    let mut doc = None;
198    let mut name = String::new();
199    let mut fields = Vec::new();
200
201    for p in pair.into_inner() {
202        match p.as_rule() {
203            Rule::doc_block => doc = Some(build_doc_block(p)),
204            Rule::type_ident => name = p.as_str().to_string(),
205            Rule::field_decl => fields.push(build_field_decl(p)),
206            _ => {}
207        }
208    }
209
210    EntityDecl {
211        doc,
212        name,
213        fields,
214        span,
215    }
216}
217
218fn build_field_decl(pair: pest::iterators::Pair<'_, Rule>) -> FieldDecl {
219    let span = span_of(&pair);
220    let mut inner = pair.into_inner();
221    let name = inner.next().unwrap().as_str().to_string();
222    let ty = build_type_expr(inner.next().unwrap());
223    FieldDecl { name, ty, span }
224}
225
226fn build_action_decl(pair: pest::iterators::Pair<'_, Rule>) -> ActionDecl {
227    let span = span_of(&pair);
228    let mut doc = None;
229    let mut name = String::new();
230    let mut params = Vec::new();
231    let mut requires = None;
232    let mut ensures = None;
233    let mut properties = None;
234
235    for p in pair.into_inner() {
236        match p.as_rule() {
237            Rule::doc_block => doc = Some(build_doc_block(p)),
238            Rule::type_ident => name = p.as_str().to_string(),
239            Rule::param_decl => params.push(build_field_decl(p)),
240            Rule::requires_block => requires = Some(build_requires_block(p)),
241            Rule::ensures_block => ensures = Some(build_ensures_block(p)),
242            Rule::properties_block => properties = Some(build_properties_block(p)),
243            _ => {}
244        }
245    }
246
247    ActionDecl {
248        doc,
249        name,
250        params,
251        requires,
252        ensures,
253        properties,
254        span,
255    }
256}
257
258fn build_requires_block(pair: pest::iterators::Pair<'_, Rule>) -> RequiresBlock {
259    let span = span_of(&pair);
260    let conditions = pair.into_inner().map(build_expr).collect();
261    RequiresBlock { conditions, span }
262}
263
264fn build_ensures_block(pair: pest::iterators::Pair<'_, Rule>) -> EnsuresBlock {
265    let span = span_of(&pair);
266    let items = pair
267        .into_inner()
268        .map(|p| match p.as_rule() {
269            Rule::when_clause => EnsuresItem::When(build_when_clause(p)),
270            _ => EnsuresItem::Expr(build_expr(p)),
271        })
272        .collect();
273    EnsuresBlock { items, span }
274}
275
276fn build_when_clause(pair: pest::iterators::Pair<'_, Rule>) -> WhenClause {
277    let span = span_of(&pair);
278    let mut inner = pair.into_inner();
279    let condition = build_or_expr(inner.next().unwrap());
280    let consequence = build_expr(inner.next().unwrap());
281    WhenClause {
282        condition,
283        consequence,
284        span,
285    }
286}
287
288fn build_properties_block(pair: pest::iterators::Pair<'_, Rule>) -> PropertiesBlock {
289    let span = span_of(&pair);
290    let entries = pair.into_inner().map(build_prop_entry).collect();
291    PropertiesBlock { entries, span }
292}
293
294fn build_prop_entry(pair: pest::iterators::Pair<'_, Rule>) -> PropEntry {
295    let span = span_of(&pair);
296    let mut inner = pair.into_inner();
297    let key = inner.next().unwrap().as_str().to_string();
298    let value = build_prop_value(inner.next().unwrap());
299    PropEntry { key, value, span }
300}
301
302fn build_prop_value(pair: pest::iterators::Pair<'_, Rule>) -> PropValue {
303    match pair.as_rule() {
304        Rule::obj_literal => {
305            let fields = pair
306                .into_inner()
307                .map(|f| {
308                    let mut inner = f.into_inner();
309                    let key = inner.next().unwrap().as_str().to_string();
310                    let value = build_prop_value(inner.next().unwrap());
311                    (key, value)
312                })
313                .collect();
314            PropValue::Object(fields)
315        }
316        Rule::list_literal => {
317            let items = pair.into_inner().map(build_prop_value).collect();
318            PropValue::List(items)
319        }
320        Rule::string_literal => {
321            let s = extract_string(pair);
322            PropValue::Literal(Literal::String(s))
323        }
324        Rule::number_literal => PropValue::Literal(parse_number_literal(pair.as_str())),
325        Rule::bool_literal => PropValue::Literal(Literal::Bool(pair.as_str() == "true")),
326        Rule::ident => PropValue::Ident(pair.as_str().to_string()),
327        // For expressions nested in prop value contexts, try to extract
328        Rule::expr | Rule::implies_expr => {
329            // Recurse into inner pairs
330            let inner = pair.into_inner().next().unwrap();
331            build_prop_value(inner)
332        }
333        _ => PropValue::Ident(pair.as_str().to_string()),
334    }
335}
336
337fn build_invariant_decl(pair: pest::iterators::Pair<'_, Rule>) -> InvariantDecl {
338    let span = span_of(&pair);
339    let mut doc = None;
340    let mut name = String::new();
341    let mut body = None;
342
343    for p in pair.into_inner() {
344        match p.as_rule() {
345            Rule::doc_block => doc = Some(build_doc_block(p)),
346            Rule::type_ident => name = p.as_str().to_string(),
347            Rule::expr => body = Some(build_expr(p)),
348            _ => {}
349        }
350    }
351
352    InvariantDecl {
353        doc,
354        name,
355        body: body.expect("invariant must have a body expression"),
356        span,
357    }
358}
359
360fn build_edge_cases_decl(pair: pest::iterators::Pair<'_, Rule>) -> EdgeCasesDecl {
361    let span = span_of(&pair);
362    let rules = pair.into_inner().map(build_edge_rule).collect();
363    EdgeCasesDecl { rules, span }
364}
365
366fn build_edge_rule(pair: pest::iterators::Pair<'_, Rule>) -> EdgeRule {
367    let span = span_of(&pair);
368    let mut inner = pair.into_inner();
369    let condition = build_or_expr(inner.next().unwrap());
370    let action = build_action_call(inner.next().unwrap());
371    EdgeRule {
372        condition,
373        action,
374        span,
375    }
376}
377
378fn build_action_call(pair: pest::iterators::Pair<'_, Rule>) -> ActionCall {
379    let span = span_of(&pair);
380    let mut inner = pair.into_inner();
381    let name = inner.next().unwrap().as_str().to_string();
382    let args = inner
383        .next()
384        .map(|p| p.into_inner().map(build_call_arg).collect())
385        .unwrap_or_default();
386    ActionCall { name, args, span }
387}
388
389// ── Type expression builders ─────────────────────────────────
390
391fn build_type_expr(pair: pest::iterators::Pair<'_, Rule>) -> TypeExpr {
392    let span = span_of(&pair);
393    let mut optional = false;
394    let mut ty_kind = None;
395
396    for p in pair.into_inner() {
397        match p.as_rule() {
398            Rule::union_type => ty_kind = Some(build_union_type(p)),
399            Rule::optional_marker => optional = true,
400            _ => {}
401        }
402    }
403
404    TypeExpr {
405        ty: ty_kind.unwrap(),
406        optional,
407        span,
408    }
409}
410
411fn build_union_type(pair: pest::iterators::Pair<'_, Rule>) -> TypeKind {
412    let variants: Vec<TypeKind> = pair.into_inner().map(build_base_type).collect();
413    if variants.len() == 1 {
414        variants.into_iter().next().unwrap()
415    } else {
416        TypeKind::Union(variants)
417    }
418}
419
420fn build_base_type(pair: pest::iterators::Pair<'_, Rule>) -> TypeKind {
421    match pair.as_rule() {
422        Rule::list_type => {
423            let inner = pair.into_inner().next().unwrap();
424            TypeKind::List(Box::new(build_type_expr(inner)))
425        }
426        Rule::set_type => {
427            let inner = pair.into_inner().next().unwrap();
428            TypeKind::Set(Box::new(build_type_expr(inner)))
429        }
430        Rule::map_type => {
431            let mut inner = pair.into_inner();
432            let key = build_type_expr(inner.next().unwrap());
433            let value = build_type_expr(inner.next().unwrap());
434            TypeKind::Map(Box::new(key), Box::new(value))
435        }
436        Rule::parameterized_type => {
437            let mut inner = pair.into_inner();
438            let name = inner.next().unwrap().as_str().to_string();
439            let params = inner.map(build_type_param).collect();
440            TypeKind::Parameterized { name, params }
441        }
442        Rule::simple_type => {
443            let name = pair.into_inner().next().unwrap().as_str().to_string();
444            TypeKind::Simple(name)
445        }
446        _ => TypeKind::Simple(pair.as_str().to_string()),
447    }
448}
449
450fn build_type_param(pair: pest::iterators::Pair<'_, Rule>) -> TypeParam {
451    let span = span_of(&pair);
452    let mut inner = pair.into_inner();
453    let name = inner.next().unwrap().as_str().to_string();
454    let value = parse_number_literal(inner.next().unwrap().as_str());
455    TypeParam { name, value, span }
456}
457
458// ── Expression builders ──────────────────────────────────────
459
460fn build_expr(pair: pest::iterators::Pair<'_, Rule>) -> Expr {
461    let span = span_of(&pair);
462    let inner = pair.into_inner().next().unwrap();
463    match inner.as_rule() {
464        Rule::implies_expr => build_implies_expr(inner),
465        _ => {
466            let kind = build_expr_kind(inner);
467            Expr { kind, span }
468        }
469    }
470}
471
472fn build_implies_expr(pair: pest::iterators::Pair<'_, Rule>) -> Expr {
473    let mut parts: Vec<pest::iterators::Pair<'_, Rule>> = Vec::new();
474
475    for p in pair.into_inner() {
476        match p.as_rule() {
477            Rule::implies_op => {}
478            _ => parts.push(p),
479        }
480    }
481
482    let mut result = build_or_expr(parts.remove(0));
483    for part in parts {
484        let right = build_or_expr(part);
485        let new_span = Span {
486            start: result.span.start,
487            end: right.span.end,
488        };
489        result = Expr {
490            kind: ExprKind::Implies(Box::new(result), Box::new(right)),
491            span: new_span,
492        };
493    }
494    result
495}
496
497fn build_or_expr(pair: pest::iterators::Pair<'_, Rule>) -> Expr {
498    let span = span_of(&pair);
499    let mut parts: Vec<pest::iterators::Pair<'_, Rule>> = Vec::new();
500
501    for p in pair.into_inner() {
502        match p.as_rule() {
503            Rule::or_op => {}
504            _ => parts.push(p),
505        }
506    }
507
508    if parts.is_empty() {
509        return Expr {
510            kind: ExprKind::Literal(Literal::Null),
511            span,
512        };
513    }
514
515    let mut result = build_and_expr(parts.remove(0));
516    for part in parts {
517        let right = build_and_expr(part);
518        let new_span = Span {
519            start: result.span.start,
520            end: right.span.end,
521        };
522        result = Expr {
523            kind: ExprKind::Or(Box::new(result), Box::new(right)),
524            span: new_span,
525        };
526    }
527    result
528}
529
530fn build_and_expr(pair: pest::iterators::Pair<'_, Rule>) -> Expr {
531    let span = span_of(&pair);
532    let mut parts: Vec<pest::iterators::Pair<'_, Rule>> = Vec::new();
533
534    for p in pair.into_inner() {
535        match p.as_rule() {
536            Rule::and_op => {}
537            _ => parts.push(p),
538        }
539    }
540
541    if parts.is_empty() {
542        return Expr {
543            kind: ExprKind::Literal(Literal::Null),
544            span,
545        };
546    }
547
548    let mut result = build_not_expr(parts.remove(0));
549    for part in parts {
550        let right = build_not_expr(part);
551        let new_span = Span {
552            start: result.span.start,
553            end: right.span.end,
554        };
555        result = Expr {
556            kind: ExprKind::And(Box::new(result), Box::new(right)),
557            span: new_span,
558        };
559    }
560    result
561}
562
563fn build_not_expr(pair: pest::iterators::Pair<'_, Rule>) -> Expr {
564    let span = span_of(&pair);
565    let mut inner = pair.into_inner();
566    let first = inner.next().unwrap();
567
568    match first.as_rule() {
569        Rule::not_op => {
570            let operand = build_not_expr(inner.next().unwrap());
571            Expr {
572                kind: ExprKind::Not(Box::new(operand)),
573                span,
574            }
575        }
576        Rule::cmp_expr => build_cmp_expr(first),
577        _ => {
578            let kind = build_expr_kind(first);
579            Expr { kind, span }
580        }
581    }
582}
583
584fn build_cmp_expr(pair: pest::iterators::Pair<'_, Rule>) -> Expr {
585    let span = span_of(&pair);
586    let mut inner = pair.into_inner();
587    let left = build_add_expr(inner.next().unwrap());
588
589    if let Some(op_pair) = inner.next() {
590        let op = match op_pair.as_str() {
591            "==" => CmpOp::Eq,
592            "!=" => CmpOp::Ne,
593            "<" => CmpOp::Lt,
594            ">" => CmpOp::Gt,
595            "<=" => CmpOp::Le,
596            ">=" => CmpOp::Ge,
597            _ => unreachable!("unknown cmp op: {}", op_pair.as_str()),
598        };
599        let right = build_add_expr(inner.next().unwrap());
600        Expr {
601            kind: ExprKind::Compare {
602                left: Box::new(left),
603                op,
604                right: Box::new(right),
605            },
606            span,
607        }
608    } else {
609        Expr {
610            kind: left.kind,
611            span,
612        }
613    }
614}
615
616fn build_add_expr(pair: pest::iterators::Pair<'_, Rule>) -> Expr {
617    let mut children: Vec<pest::iterators::Pair<'_, Rule>> = pair.into_inner().collect();
618
619    if children.len() == 1 {
620        return build_primary(children.remove(0));
621    }
622
623    // Interleaved: primary, op, primary, op, primary, ...
624    let mut iter = children.into_iter();
625    let mut result = build_primary(iter.next().unwrap());
626
627    while let Some(op_pair) = iter.next() {
628        let op = match op_pair.as_str() {
629            "+" => ArithOp::Add,
630            "-" => ArithOp::Sub,
631            _ => unreachable!("unknown add op"),
632        };
633        let right = build_primary(iter.next().unwrap());
634        let new_span = Span {
635            start: result.span.start,
636            end: right.span.end,
637        };
638        result = Expr {
639            kind: ExprKind::Arithmetic {
640                left: Box::new(result),
641                op,
642                right: Box::new(right),
643            },
644            span: new_span,
645        };
646    }
647
648    result
649}
650
651fn build_primary(pair: pest::iterators::Pair<'_, Rule>) -> Expr {
652    let span = span_of(&pair);
653    let mut inner: Vec<pest::iterators::Pair<'_, Rule>> = pair.into_inner().collect();
654
655    // First child is the atom, rest are `.ident` field accesses
656    let atom_pair = inner.remove(0);
657    let base = build_atom(atom_pair);
658
659    if inner.is_empty() {
660        return Expr {
661            kind: base.kind,
662            span,
663        };
664    }
665
666    let fields: Vec<String> = inner.into_iter().map(|p| p.as_str().to_string()).collect();
667
668    Expr {
669        kind: ExprKind::FieldAccess {
670            root: Box::new(base),
671            fields,
672        },
673        span,
674    }
675}
676
677fn build_atom(pair: pest::iterators::Pair<'_, Rule>) -> Expr {
678    let span = span_of(&pair);
679    match pair.as_rule() {
680        Rule::old_expr => {
681            let inner = pair.into_inner().next().unwrap();
682            let expr = build_expr(inner);
683            Expr {
684                kind: ExprKind::Old(Box::new(expr)),
685                span,
686            }
687        }
688        Rule::quantifier_expr => {
689            let mut inner = pair.into_inner();
690            let kw = inner.next().unwrap();
691            let kind = match kw.as_str() {
692                "forall" => QuantifierKind::Forall,
693                "exists" => QuantifierKind::Exists,
694                _ => unreachable!(),
695            };
696            let binding = inner.next().unwrap().as_str().to_string();
697            let ty = inner.next().unwrap().as_str().to_string();
698            let body = build_expr(inner.next().unwrap());
699            Expr {
700                kind: ExprKind::Quantifier {
701                    kind,
702                    binding,
703                    ty,
704                    body: Box::new(body),
705                },
706                span,
707            }
708        }
709        Rule::null_literal => Expr {
710            kind: ExprKind::Literal(Literal::Null),
711            span,
712        },
713        Rule::bool_literal => Expr {
714            kind: ExprKind::Literal(Literal::Bool(pair.as_str() == "true")),
715            span,
716        },
717        Rule::number_literal => Expr {
718            kind: ExprKind::Literal(parse_number_literal(pair.as_str())),
719            span,
720        },
721        Rule::string_literal => Expr {
722            kind: ExprKind::Literal(Literal::String(extract_string(pair))),
723            span,
724        },
725        Rule::list_literal => {
726            // TODO: build list expression
727            Expr {
728                kind: ExprKind::Literal(Literal::Null),
729                span,
730            }
731        }
732        Rule::paren_expr => {
733            let inner = pair.into_inner().next().unwrap();
734            build_expr(inner)
735        }
736        Rule::call_or_ident => {
737            // When call_args is empty (e.g., `now()`), pest produces no inner
738            // pairs for the parens. Check the raw text for `(` to distinguish
739            // zero-arg calls from plain identifiers.
740            let text = pair.as_str();
741            let mut inner = pair.into_inner();
742            let name = inner.next().unwrap().as_str().to_string();
743            if text.contains('(') {
744                let args = inner
745                    .next()
746                    .map(|args_pair| args_pair.into_inner().map(build_call_arg).collect())
747                    .unwrap_or_default();
748                Expr {
749                    kind: ExprKind::Call { name, args },
750                    span,
751                }
752            } else {
753                Expr {
754                    kind: ExprKind::Ident(name),
755                    span,
756                }
757            }
758        }
759        _ => Expr {
760            kind: ExprKind::Ident(pair.as_str().to_string()),
761            span,
762        },
763    }
764}
765
766fn build_call_arg(pair: pest::iterators::Pair<'_, Rule>) -> CallArg {
767    let mut inner = pair.into_inner();
768    let first = inner.next().unwrap();
769
770    match first.as_rule() {
771        Rule::named_arg => {
772            let span = span_of(&first);
773            let mut named_inner = first.into_inner();
774            let key = named_inner.next().unwrap().as_str().to_string();
775            let value = build_expr(named_inner.next().unwrap());
776            CallArg::Named { key, value, span }
777        }
778        _ => CallArg::Positional(build_expr(first)),
779    }
780}
781
782fn build_expr_kind(pair: pest::iterators::Pair<'_, Rule>) -> ExprKind {
783    match pair.as_rule() {
784        Rule::implies_expr => build_implies_expr(pair).kind,
785        Rule::or_expr => build_or_expr(pair).kind,
786        Rule::and_expr => build_and_expr(pair).kind,
787        Rule::not_expr => build_not_expr(pair).kind,
788        Rule::cmp_expr => build_cmp_expr(pair).kind,
789        Rule::add_expr => build_add_expr(pair).kind,
790        Rule::primary => build_primary(pair).kind,
791        _ => build_atom(pair).kind,
792    }
793}
794
795// ── Helpers ──────────────────────────────────────────────────
796
797fn parse_number_literal(s: &str) -> Literal {
798    if s.contains('.') {
799        Literal::Decimal(s.to_string())
800    } else {
801        Literal::Int(s.parse().unwrap_or(0))
802    }
803}
804
805fn extract_string(pair: pest::iterators::Pair<'_, Rule>) -> String {
806    pair.into_inner()
807        .next()
808        .map(|p| p.as_str().to_string())
809        .unwrap_or_default()
810}
811
812#[cfg(test)]
813mod tests {
814    use super::*;
815
816    #[test]
817    fn parse_minimal_module() {
818        let src = "module Foo\n";
819        let file = parse_file(src).unwrap();
820        assert_eq!(file.module.name, "Foo");
821        assert!(file.items.is_empty());
822    }
823
824    #[test]
825    fn parse_entity() {
826        let src = r#"module Test
827
828entity Account {
829  id: UUID
830  balance: Decimal(precision: 2)
831  status: Active | Frozen | Closed
832  notes: String?
833}
834"#;
835        let file = parse_file(src).unwrap();
836        assert_eq!(file.items.len(), 1);
837        if let TopLevelItem::Entity(e) = &file.items[0] {
838            assert_eq!(e.name, "Account");
839            assert_eq!(e.fields.len(), 4);
840            assert_eq!(e.fields[0].name, "id");
841            assert!(e.fields[2].ty.optional == false);
842            assert!(e.fields[3].ty.optional == true);
843        } else {
844            panic!("expected entity");
845        }
846    }
847
848    #[test]
849    fn parse_action_with_requires_ensures() {
850        let src = r#"module Test
851
852action Transfer {
853  from: Account
854  amount: Decimal(precision: 2)
855
856  requires {
857    from.status == Active
858    amount > 0
859  }
860
861  ensures {
862    from.balance == old(from.balance) - amount
863  }
864}
865"#;
866        let file = parse_file(src).unwrap();
867        assert_eq!(file.items.len(), 1);
868        if let TopLevelItem::Action(a) = &file.items[0] {
869            assert_eq!(a.name, "Transfer");
870            assert_eq!(a.params.len(), 2);
871            assert_eq!(a.requires.as_ref().unwrap().conditions.len(), 2);
872            assert_eq!(a.ensures.as_ref().unwrap().items.len(), 1);
873        } else {
874            panic!("expected action");
875        }
876    }
877
878    #[test]
879    fn parse_invariant() {
880        let src = r#"module Test
881
882invariant NoNegativeBalances {
883  forall a: Account => a.balance >= 0
884}
885"#;
886        let file = parse_file(src).unwrap();
887        if let TopLevelItem::Invariant(inv) = &file.items[0] {
888            assert_eq!(inv.name, "NoNegativeBalances");
889            assert!(matches!(inv.body.kind, ExprKind::Quantifier { .. }));
890        } else {
891            panic!("expected invariant");
892        }
893    }
894
895    #[test]
896    fn parse_edge_cases() {
897        let src = r#"module Test
898
899edge_cases {
900  when amount > 10000.00 => require_approval(level: "manager")
901  when from == to => reject("Cannot transfer to same account")
902}
903"#;
904        let file = parse_file(src).unwrap();
905        if let TopLevelItem::EdgeCases(ec) = &file.items[0] {
906            assert_eq!(ec.rules.len(), 2);
907            assert_eq!(ec.rules[0].action.name, "require_approval");
908            assert_eq!(ec.rules[1].action.name, "reject");
909        } else {
910            panic!("expected edge_cases");
911        }
912    }
913
914    #[test]
915    fn parse_transfer_example() {
916        let src = include_str!("../../../examples/transfer.intent");
917        let file = parse_file(src).unwrap();
918        assert_eq!(file.module.name, "TransferFunds");
919        // 2 entities + 2 actions + 2 invariants + 1 edge_cases = 7 items
920        assert_eq!(file.items.len(), 7);
921    }
922
923    #[test]
924    fn parse_auth_example() {
925        let src = include_str!("../../../examples/auth.intent");
926        let file = parse_file(src).unwrap();
927        assert_eq!(file.module.name, "Authentication");
928        // 2 entities + 2 actions + 2 invariants + 1 edge_cases = 7 items
929        assert_eq!(file.items.len(), 7);
930    }
931}