Skip to main content

bynk_syntax/
parser.rs

1//! Hand-written recursive-descent parser for Bynk v0.
2//!
3//! Token grammar in spec §4. The expression parser uses one function per
4//! precedence level (§4.4). Errors carry spans and short fix-oriented
5//! messages; the parser does not currently attempt synchronisation, which
6//! means at most one parse error is reported per compilation.
7
8use crate::ast::*;
9use crate::error::CompileError;
10use crate::lexer::{Token, TokenKind, comment_body, doc_block_content, has_blank_line_between};
11use crate::span::Span;
12mod declarations;
13mod expressions;
14mod statements;
15mod types;
16
17/// Side-channel store for line-comment trivia (v1.1 LSP spec §3.5).
18///
19/// Built once up-front by [`split_trivia`] from the raw lexer token stream.
20/// Comments are removed from the token stream the parser walks; their text
21/// is filed into `leading` (comments on lines preceding a content token)
22/// and `trailing` (a single comment on the same line as a content token).
23/// The parser consumes entries through [`TriviaTable::take_leading`] and
24/// [`TriviaTable::take_trailing`] as it recognises declarations.
25#[derive(Debug, Default)]
26struct TriviaTable {
27    /// `leading[i]` holds the comment-body texts that appear immediately
28    /// before content token `i` (zero or more `--` lines, in source order,
29    /// not separated from the token by another content token).
30    leading: Vec<Vec<String>>,
31    /// `trailing[i]` holds an optional comment on the same source line as
32    /// content token `i`. Only one trailing comment is recorded per token
33    /// because a single `--` consumes the rest of the line.
34    trailing: Vec<Option<String>>,
35    /// Any pending leading comments at end-of-file (no content token
36    /// followed). Used to preserve file-trailing comments.
37    epilogue: Vec<String>,
38}
39
40impl TriviaTable {
41    fn take_leading(&mut self, index: usize) -> Vec<String> {
42        match self.leading.get_mut(index) {
43            Some(v) => std::mem::take(v),
44            None => Vec::new(),
45        }
46    }
47
48    fn take_trailing(&mut self, index: usize) -> Option<String> {
49        self.trailing.get_mut(index).and_then(|s| s.take())
50    }
51
52    fn take_epilogue(&mut self) -> Vec<String> {
53        std::mem::take(&mut self.epilogue)
54    }
55}
56
57/// Remove `Comment` trivia tokens from `tokens` and bin them into a
58/// [`TriviaTable`] keyed against the surviving content tokens. A comment
59/// on the same source line as the preceding content token is recorded as
60/// that token's *trailing* trivia; everything else is *leading* for the
61/// next content token.
62fn split_trivia(tokens: &[Token], source: &str) -> (Vec<Token>, TriviaTable) {
63    let mut filtered: Vec<Token> = Vec::with_capacity(tokens.len());
64    let mut table = TriviaTable::default();
65    let mut pending_leading: Vec<String> = Vec::new();
66    let mut last_content_end: Option<usize> = None;
67    for tok in tokens {
68        if tok.kind == TokenKind::Comment {
69            let body = comment_body(source, tok.span).to_string();
70            // If nothing has been buffered as leading for the next token and
71            // there is no newline between the previous content token and
72            // this comment, it trails that token.
73            if pending_leading.is_empty()
74                && let Some(prev_end) = last_content_end
75                && !source[prev_end..tok.span.start].contains('\n')
76            {
77                let last_idx = filtered.len() - 1;
78                // Only attach if no trailing already recorded (shouldn't
79                // happen because `--` consumes through end-of-line).
80                if table.trailing[last_idx].is_none() {
81                    table.trailing[last_idx] = Some(body);
82                    continue;
83                }
84            }
85            pending_leading.push(body);
86            continue;
87        }
88        filtered.push(*tok);
89        table.leading.push(std::mem::take(&mut pending_leading));
90        table.trailing.push(None);
91        last_content_end = Some(tok.span.end);
92    }
93    table.epilogue = pending_leading;
94    (filtered, table)
95}
96
97/// Parse a token slice into a [`Commons`] AST.
98///
99/// Accepts either form of v0.3 commons file:
100/// - Brace form: `commons name { items... }` (v0–v0.2 compatible).
101/// - Fragment form: `commons name uses... items...` to EOF (v0.3).
102pub fn parse(tokens: &[Token], source: &str) -> Result<Commons, Vec<CompileError>> {
103    match parse_unit(tokens, source)? {
104        SourceUnit::Commons(c) => Ok(c),
105        SourceUnit::Context(ctx) => Err(vec![
106            CompileError::new(
107                "bynk.parse.unexpected_context",
108                ctx.span,
109                "expected a `commons` declaration but found a `context` declaration",
110            )
111            .with_note(
112                "contexts must be compiled as part of a project — pass the source directory, e.g. `bynkc compile --target bundle --output out src`",
113            ),
114        ]),
115        SourceUnit::Test(t) => Err(vec![
116            CompileError::new(
117                "bynk.parse.unexpected_test",
118                t.span,
119                "expected a `commons` declaration but found a `test` declaration",
120            )
121            .with_note(
122                "tests must be compiled as part of a project — pass the source directory, e.g. `bynkc compile --target bundle --output out src`",
123            ),
124        ]),
125        SourceUnit::Integration(i) => Err(vec![
126            CompileError::new(
127                "bynk.parse.unexpected_test",
128                i.span,
129                "expected a `commons` declaration but found an integration test",
130            )
131            .with_note(
132                "tests must be compiled as part of a project — pass the source directory, e.g. `bynkc compile --target bundle --output out src`",
133            ),
134        ]),
135        SourceUnit::Adapter(a) => Err(vec![
136            CompileError::new(
137                "bynk.parse.unexpected_adapter",
138                a.span,
139                "expected a `commons` declaration but found an `adapter` declaration",
140            )
141            .with_note(
142                "adapters must be compiled as part of a project — pass the source directory, e.g. `bynkc compile --target bundle --output out src`",
143            ),
144        ]),
145    }
146}
147
148/// Parse a token slice into a [`SourceUnit`] with error recovery, returning a
149/// best-effort partial AST plus the full list of parse errors and warnings.
150///
151/// Used by the LSP: item-level recovery skips past a malformed declaration to
152/// the next top-level item, so multiple errors are reported per compilation
153/// rather than just the first. Compared to [`parse_unit`], this never bails;
154/// if no SourceUnit could be parsed at all (e.g. the file is empty or the
155/// header itself fails) the returned `Option` is `None`.
156pub fn parse_unit_with_recovery(
157    tokens: &[Token],
158    source: &str,
159) -> (Option<SourceUnit>, Vec<CompileError>) {
160    let (filtered, trivia) = split_trivia(tokens, source);
161    let mut warnings = Vec::new();
162    let mut p = Parser::new(&filtered, source, trivia, &mut warnings);
163    p.recover_mode = true;
164    let unit_opt = match p.parse_unit() {
165        Ok(u) => {
166            if let Some(extra) = p.peek() {
167                p.recovered_errors.push(
168                    CompileError::new(
169                        "bynk.parse.extra_tokens",
170                        extra.span,
171                        "unexpected token after top-level declaration",
172                    )
173                    .with_note(
174                        "a `.bynk` file contains exactly one `commons` or `context` declaration",
175                    ),
176                );
177            }
178            Some(u)
179        }
180        Err(e) => {
181            p.recovered_errors.push(e);
182            None
183        }
184    };
185    let mut all_errors = p.recovered_errors;
186    all_errors.append(&mut warnings);
187    (unit_opt, all_errors)
188}
189
190/// Parse a token slice into a [`SourceUnit`] — either a commons or a context.
191///
192/// Each `.bynk` file is exactly one declaration of one kind.
193pub fn parse_unit(tokens: &[Token], source: &str) -> Result<SourceUnit, Vec<CompileError>> {
194    let (filtered, trivia) = split_trivia(tokens, source);
195    let mut warnings = Vec::new();
196    let mut p = Parser::new(&filtered, source, trivia, &mut warnings);
197    let result = match p.parse_unit() {
198        Ok(u) => {
199            if let Some(extra) = p.peek() {
200                Err(vec![
201                    CompileError::new(
202                        "bynk.parse.extra_tokens",
203                        extra.span,
204                        "unexpected token after top-level declaration",
205                    )
206                    .with_note(
207                        "a `.bynk` file contains exactly one `commons` or `context` declaration",
208                    ),
209                ])
210            } else {
211                Ok(u)
212            }
213        }
214        Err(e) => Err(vec![e]),
215    };
216    // Warnings (e.g. orphan doc blocks) are returned as errors in v0.3 — there
217    // is no separate warning channel yet; the test harness matches on category.
218    if !warnings.is_empty() {
219        match result {
220            Ok(_) => return Err(warnings),
221            Err(mut errs) => {
222                errs.append(&mut warnings);
223                return Err(errs);
224            }
225        }
226    }
227    result
228}
229
230/// A signed numeric literal in refinement-bound position (v0.21): `InRange`
231/// bounds are either both `Int` or both `Float`.
232enum SignedNumLit {
233    Int(IntBound),
234    Float(FloatBound),
235}
236
237struct Parser<'a> {
238    tokens: &'a [Token],
239    source: &'a str,
240    pos: usize,
241    /// Accumulated non-fatal diagnostics. v0.3 uses this for orphan-doc
242    /// warnings, which are emitted as errors with a distinguishable category.
243    warnings: &'a mut Vec<CompileError>,
244    /// When true, the item-level loops catch errors from individual item
245    /// parses, push them into `recovered_errors`, and skip forward to the
246    /// next top-level item boundary instead of bailing. Used by the LSP via
247    /// [`parse_unit_with_recovery`]; disabled in the normal `parse` path so
248    /// existing single-error behaviour is preserved.
249    recover_mode: bool,
250    /// Errors collected during recovery-mode parsing. Only populated when
251    /// `recover_mode` is true.
252    recovered_errors: Vec<CompileError>,
253    /// Line-comment trivia separated from the token stream. See
254    /// [`TriviaTable`].
255    trivia: TriviaTable,
256}
257
258impl<'a> Parser<'a> {
259    fn new(
260        tokens: &'a [Token],
261        source: &'a str,
262        trivia: TriviaTable,
263        warnings: &'a mut Vec<CompileError>,
264    ) -> Self {
265        Self {
266            tokens,
267            source,
268            pos: 0,
269            warnings,
270            recover_mode: false,
271            recovered_errors: Vec::new(),
272            trivia,
273        }
274    }
275
276    /// Comments immediately preceding the current peek position. Consumed
277    /// (the table entry is cleared) so the same comments are not attached
278    /// to two nodes.
279    fn take_leading_trivia(&mut self) -> Vec<String> {
280        self.trivia.take_leading(self.pos)
281    }
282
283    /// Trailing comment, if any, on the same source line as the most
284    /// recently consumed content token. Call AFTER finishing a declaration
285    /// or statement, while `self.pos` points one past its last token.
286    fn take_trailing_trivia(&mut self) -> Option<String> {
287        if self.pos == 0 {
288            return None;
289        }
290        self.trivia.take_trailing(self.pos - 1)
291    }
292
293    /// Handle a per-item parse error. In recovery mode, record the error and
294    /// advance to the next sync point so the item loop can continue; otherwise
295    /// propagate as a hard failure.
296    fn handle_item_err(&mut self, e: CompileError) -> Result<(), CompileError> {
297        if self.recover_mode {
298            self.recovered_errors.push(e);
299            self.recover_to_top_item();
300            Ok(())
301        } else {
302            Err(e)
303        }
304    }
305
306    /// Skip forward to the next top-level item boundary: either a top-level
307    /// declaration keyword (`type`, `fn`, `uses`, `consumes`, `exports`,
308    /// `capability`, `provides`, `service`, `agent`), a closing brace, or
309    /// end-of-input. Used only in recovery mode.
310    fn recover_to_top_item(&mut self) {
311        while let Some(t) = self.peek() {
312            match t.kind {
313                TokenKind::Type
314                | TokenKind::Fn
315                | TokenKind::Uses
316                | TokenKind::Consumes
317                | TokenKind::Exports
318                | TokenKind::Capability
319                | TokenKind::Provides
320                | TokenKind::Service
321                | TokenKind::Agent
322                | TokenKind::Mocks
323                | TokenKind::Test
324                | TokenKind::RBrace
325                | TokenKind::Commons
326                | TokenKind::Context => return,
327                _ => {
328                    self.bump();
329                }
330            }
331        }
332    }
333
334    fn peek(&self) -> Option<Token> {
335        self.tokens.get(self.pos).copied()
336    }
337
338    fn peek_kind(&self) -> Option<TokenKind> {
339        self.peek().map(|t| t.kind)
340    }
341
342    fn bump(&mut self) -> Option<Token> {
343        let t = self.peek();
344        if t.is_some() {
345            self.pos += 1;
346        }
347        t
348    }
349
350    fn eat(&mut self, kind: TokenKind) -> Option<Token> {
351        if self.peek_kind() == Some(kind) {
352            self.bump()
353        } else {
354            None
355        }
356    }
357
358    fn slice(&self, span: Span) -> &'a str {
359        &self.source[span.range()]
360    }
361
362    /// True when the next token sits on a later line than `prev`. Used to
363    /// keep a `[` that opens a new line out of the postfix type-application
364    /// form: `f` followed by `[1, 2]` on the next line is an identifier and
365    /// a list literal, not `f[…]` (v0.20b).
366    fn next_token_on_new_line(&self, prev: Span) -> bool {
367        match self.peek() {
368            Some(t) if prev.end <= t.span.start => {
369                self.source[prev.end..t.span.start].contains('\n')
370            }
371            _ => false,
372        }
373    }
374
375    /// Span pointing at the end of input — used for "unexpected EOF" reports.
376    fn eof_span(&self) -> Span {
377        let end = self.source.len();
378        Span::new(end.saturating_sub(1), end)
379    }
380
381    fn expect(&mut self, kind: TokenKind, ctx: &str) -> Result<Token, CompileError> {
382        match self.peek() {
383            Some(t) if t.kind == kind => {
384                self.bump();
385                Ok(t)
386            }
387            Some(t) => Err(CompileError::new(
388                "bynk.parse.expected_token",
389                t.span,
390                format!(
391                    "expected {} {ctx}, found {}",
392                    kind.describe(),
393                    t.kind.describe()
394                ),
395            )),
396            None => Err(CompileError::new(
397                "bynk.parse.unexpected_eof",
398                self.eof_span(),
399                format!("expected {} {ctx}, found end of file", kind.describe()),
400            )),
401        }
402    }
403
404    fn expect_ident(&mut self, ctx: &str) -> Result<Ident, CompileError> {
405        match self.peek() {
406            Some(t) if t.kind == TokenKind::Ident => {
407                self.bump();
408                Ok(Ident {
409                    name: self.slice(t.span).to_string(),
410                    span: t.span,
411                })
412            }
413            // v0.5 contextual keyword `on` doubles as an identifier in
414            // expression / field-access positions so users can name fields and
415            // parameters using it. It retains its keyword meaning only at
416            // handler-decl-level (`on call(...)`).
417            //
418            // v0.7: `test` is contextual too — it introduces the test
419            // declaration kind at the file top level, but is a perfectly
420            // valid commons or context name otherwise.
421            Some(t) if matches!(t.kind, TokenKind::On | TokenKind::Test) => {
422                self.bump();
423                Ok(Ident {
424                    name: self.slice(t.span).to_string(),
425                    span: t.span,
426                })
427            }
428            Some(t) if is_reserved_keyword(t.kind) => Err(CompileError::new(
429                "bynk.parse.reserved_keyword",
430                t.span,
431                format!(
432                    "expected identifier {ctx}, but `{}` is a reserved keyword",
433                    self.slice(t.span)
434                ),
435            )
436            .with_note("rename the identifier to something that is not a keyword")),
437            Some(t) => Err(CompileError::new(
438                "bynk.parse.expected_token",
439                t.span,
440                format!("expected identifier {ctx}, found {}", t.kind.describe()),
441            )),
442            None => Err(CompileError::new(
443                "bynk.parse.unexpected_eof",
444                self.eof_span(),
445                format!("expected identifier {ctx}, found end of file"),
446            )),
447        }
448    }
449
450    // -- top level --
451
452    /// Consume an optional doc block at the current position, returning the
453    /// (content, end-of-doc span) pair. Returns None if the next token is not
454    /// a doc block.
455    fn take_doc_block(&mut self) -> Option<(String, Span)> {
456        if self.peek_kind() == Some(TokenKind::DocBlock) {
457            let t = self.bump().unwrap();
458            let body = doc_block_content(self.source, t.span);
459            return Some((body, t.span));
460        }
461        None
462    }
463
464    /// Collect all line-comment trivia leading the next declaration plus
465    /// the optional doc block. Comments may appear both *before* and
466    /// *between* the doc and the declaration; the spec canonicalises both
467    /// groups above the doc, so we concatenate them.
468    fn collect_item_lead(&mut self) -> (Vec<String>, Option<(String, Span)>) {
469        let mut leading = self.take_leading_trivia();
470        let doc = self.take_doc_block();
471        if doc.is_some() {
472            leading.extend(self.take_leading_trivia());
473        }
474        (leading, doc)
475    }
476
477    /// Attach a parsed doc block to a following declaration unless a blank
478    /// line separates them, in which case the doc is orphaned (warning).
479    fn finalize_doc(&mut self, doc: Option<(String, Span)>, next_span: Span) -> Option<String> {
480        let (content, doc_span) = doc?;
481        // A blank line between the doc and the next decl orphans the doc.
482        if has_blank_line_between(self.source, doc_span.end, next_span.start) {
483            self.warnings.push(
484                CompileError::new(
485                    "bynk.parse.orphan_doc_block",
486                    doc_span,
487                    "documentation block is separated from the following declaration by a blank line; it will not be attached",
488                )
489                .with_note(
490                    "remove the blank line to attach the doc to the next declaration, \
491                     or remove the doc block if it is not meant to document anything",
492                ),
493            );
494            return None;
495        }
496        Some(content)
497    }
498}
499
500/// Parse the body of a lexed double-quoted string literal (the lexeme,
501/// including surrounding quotes), applying the v0 escape rules.
502fn parse_string_literal(lexeme: &str, span: Span) -> Result<String, CompileError> {
503    let bytes = lexeme.as_bytes();
504    debug_assert!(bytes.first() == Some(&b'"') && bytes.last() == Some(&b'"'));
505    let inner = &lexeme[1..lexeme.len() - 1];
506    let mut out = String::with_capacity(inner.len());
507    let mut chars = inner.chars();
508    while let Some(c) = chars.next() {
509        if c == '\\' {
510            match chars.next() {
511                Some('n') => out.push('\n'),
512                Some('t') => out.push('\t'),
513                Some('"') => out.push('"'),
514                Some('\\') => out.push('\\'),
515                other => {
516                    return Err(CompileError::new(
517                        "bynk.lex.bad_escape",
518                        span,
519                        format!(
520                            "invalid escape sequence `\\{}` in string literal",
521                            other.map(|c| c.to_string()).unwrap_or_default()
522                        ),
523                    )
524                    .with_note("supported escapes: \\n \\t \\\" \\\\"));
525                }
526            }
527        } else {
528            out.push(c);
529        }
530    }
531    Ok(out)
532}
533
534fn is_reserved_keyword(kind: TokenKind) -> bool {
535    use TokenKind::*;
536    matches!(
537        kind,
538        Commons
539            | Type
540            | Fn
541            | Where
542            | And
543            | True
544            | False
545            | Int
546            | String
547            | Bool
548            | Let
549            | If
550            | Else
551            | Ok
552            | Err
553            | Result
554            | ValidationError
555            | Enum
556            | Match
557            | Option
558            | Record
559            | Self_
560            | Some
561            | None
562            | Is
563            | Opaque
564            | Uses
565            | Context
566            | Consumes
567            | Exports
568            | Transparent
569            | Agent
570            | As
571            | Capability
572            | Effect
573            | Given
574            | On
575            | Http
576            | Provides
577            | Service
578            | Actor
579            | By
580            | Assert
581            | Expect
582            | Mocks
583    )
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589    use crate::lexer::tokenize;
590
591    fn parse_str(src: &str) -> Result<Commons, Vec<CompileError>> {
592        let toks = tokenize(src).map_err(|e| vec![e])?;
593        parse(&toks, src)
594    }
595
596    fn parse_recover_str(src: &str) -> (Option<SourceUnit>, Vec<CompileError>) {
597        let toks = match tokenize(src) {
598            Ok(t) => t,
599            Err(e) => return (None, vec![e]),
600        };
601        parse_unit_with_recovery(&toks, src)
602    }
603
604    #[test]
605    fn recovery_skips_garbage_between_decls() {
606        // Two `type` declarations separated by garbage. Recovery should
607        // accept both and report one error for the garbage between them.
608        let src = "commons x {\n\
609                   type A = Int where NonNegative\n\
610                   ??? !!!\n\
611                   type B = String where NonEmpty\n\
612                   }";
613        let (unit, errors) = parse_recover_str(src);
614        let unit = unit.expect("recovery should produce a partial AST");
615        let SourceUnit::Commons(c) = unit else {
616            panic!("expected commons")
617        };
618        // Both type decls should have been collected despite the garbage.
619        let names: Vec<_> = c
620            .items
621            .iter()
622            .map(|i| match i {
623                CommonsItem::Type(t) => t.name.name.clone(),
624                _ => panic!("expected only types"),
625            })
626            .collect();
627        assert!(
628            names.contains(&"A".to_string()) && names.contains(&"B".to_string()),
629            "expected both A and B; got {names:?}",
630        );
631        assert!(!errors.is_empty(), "expected at least one parse error");
632    }
633
634    #[test]
635    fn recovery_handles_bad_first_decl_then_good_second() {
636        // First decl is malformed (missing `=`); second is well-formed.
637        let src = "commons x {\n\
638                   type A Int where NonNegative\n\
639                   type B = String where NonEmpty\n\
640                   }";
641        let (unit, errors) = parse_recover_str(src);
642        let unit = unit.expect("recovery should produce a partial AST");
643        let SourceUnit::Commons(c) = unit else {
644            panic!("expected commons")
645        };
646        let names: Vec<_> = c
647            .items
648            .iter()
649            .filter_map(|i| match i {
650                CommonsItem::Type(t) => Some(t.name.name.clone()),
651                _ => None,
652            })
653            .collect();
654        assert!(
655            names.contains(&"B".to_string()),
656            "B should be parsed after A's failure; got {names:?}"
657        );
658        assert!(!errors.is_empty(), "expected at least one parse error");
659    }
660
661    #[test]
662    fn doc_block_attaches_to_type() {
663        let c =
664            parse_str("commons x {\n---\nA descriptive doc.\n---\ntype T = Int where Positive\n}")
665                .unwrap();
666        let CommonsItem::Type(t) = &c.items[0] else {
667            panic!()
668        };
669        assert!(t.documentation.is_some());
670        assert!(
671            t.documentation
672                .as_ref()
673                .unwrap()
674                .contains("A descriptive doc.")
675        );
676    }
677
678    #[test]
679    fn interpolated_string_parses_into_parts() {
680        // v0.43: `"Hi, \(name)!"` splits into chunk / hole / chunk.
681        let c = parse_str("commons x\n\nfn f(name: String) -> String {\n  \"Hi, \\(name)!\"\n}\n")
682            .unwrap();
683        let CommonsItem::Fn(f) = &c.items[0] else {
684            panic!("expected fn")
685        };
686        let ExprKind::InterpStr(parts) = &f.body.tail.kind else {
687            panic!("expected InterpStr, got {:?}", f.body.tail.kind)
688        };
689        assert_eq!(parts.len(), 3);
690        assert!(matches!(&parts[0], InterpPart::Chunk(s) if s == "Hi, "));
691        assert!(
692            matches!(&parts[1], InterpPart::Hole(h) if matches!(&h.kind, ExprKind::Ident(id) if id.name == "name"))
693        );
694        assert!(matches!(&parts[2], InterpPart::Chunk(s) if s == "!"));
695    }
696
697    #[test]
698    fn interpolated_hole_parses_a_full_expression() {
699        // A hole holds an arbitrary expression, not just an identifier.
700        let c =
701            parse_str("commons x\n\nfn f(a: Int, b: Int) -> String {\n  \"sum = \\(a + b)\"\n}\n")
702                .unwrap();
703        let CommonsItem::Fn(f) = &c.items[0] else {
704            panic!("expected fn")
705        };
706        let ExprKind::InterpStr(parts) = &f.body.tail.kind else {
707            panic!("expected InterpStr")
708        };
709        assert!(matches!(&parts[1], InterpPart::Hole(h) if matches!(&h.kind, ExprKind::BinOp(..))));
710    }
711
712    #[test]
713    fn empty_interpolation_hole_is_rejected() {
714        let errs = parse_str("commons x\n\nfn f() -> String {\n  \"\\()\"\n}\n").unwrap_err();
715        assert!(
716            errs.iter()
717                .any(|e| e.category == "bynk.parse.empty_interpolation"),
718            "expected empty_interpolation; got {errs:?}"
719        );
720    }
721
722    #[test]
723    fn fragment_form_parses() {
724        let c = parse_str("commons x.y\n\ntype T = Int where NonNegative\n").unwrap();
725        assert_eq!(c.form, CommonsForm::Fragment);
726        assert_eq!(c.items.len(), 1);
727    }
728
729    #[test]
730    fn uses_parses() {
731        let c = parse_str("commons x\n\nuses other.lib\n").unwrap();
732        assert_eq!(c.uses.len(), 1);
733        assert_eq!(c.uses[0].target.joined(), "other.lib");
734    }
735
736    fn parse_unit_str(src: &str) -> Result<SourceUnit, Vec<CompileError>> {
737        let toks = tokenize(src).map_err(|e| vec![e])?;
738        parse_unit(&toks, src)
739    }
740
741    #[test]
742    fn minimal_context_parses() {
743        let u = parse_unit_str("context commerce.orders {}").unwrap();
744        let SourceUnit::Context(c) = u else {
745            panic!("expected context");
746        };
747        assert_eq!(c.name.joined(), "commerce.orders");
748        assert!(c.items.is_empty());
749    }
750
751    #[test]
752    fn context_consumes_and_exports_parse() {
753        let src = "context commerce.orders {\n  uses commerce.money\n  consumes commerce.payment\n  exports opaque { OrderId }\n  exports transparent { OrderError }\n  type OrderId = String where Matches(\"ORD-[0-9]+\")\n  type OrderError = enum { CartEmpty, BadInput }\n}";
754        let u = parse_unit_str(src).unwrap();
755        let SourceUnit::Context(c) = u else { panic!() };
756        assert_eq!(c.uses.len(), 1);
757        assert_eq!(c.consumes.len(), 1);
758        assert_eq!(c.exports.len(), 2);
759        assert_eq!(c.exports[0].kind, ExportKind::Type(Visibility::Opaque));
760        assert_eq!(c.exports[1].kind, ExportKind::Type(Visibility::Transparent));
761    }
762
763    #[test]
764    fn context_fragment_form_parses() {
765        let src = "context x.y\n\nuses other.lib\nconsumes other.ctx\nexports opaque { T }\n\ntype T = Int where NonNegative\n";
766        let u = parse_unit_str(src).unwrap();
767        let SourceUnit::Context(c) = u else { panic!() };
768        assert_eq!(c.form, CommonsForm::Fragment);
769        assert_eq!(c.uses.len(), 1);
770        assert_eq!(c.consumes.len(), 1);
771        assert_eq!(c.exports.len(), 1);
772    }
773
774    #[test]
775    fn opaque_type_parses() {
776        let c = parse_str("commons x { type T = opaque Int where NonNegative }").unwrap();
777        let CommonsItem::Type(t) = &c.items[0] else {
778            panic!()
779        };
780        assert!(matches!(t.body, TypeBody::Opaque { .. }));
781    }
782
783    #[test]
784    fn empty_commons() {
785        let c = parse_str("commons fitness.units {}").unwrap();
786        assert_eq!(c.name.joined(), "fitness.units");
787        assert!(c.items.is_empty());
788    }
789
790    #[test]
791    fn one_type_decl() {
792        let c = parse_str("commons x { type Metres = Int where NonNegative }").unwrap();
793        assert_eq!(c.items.len(), 1);
794        let CommonsItem::Type(t) = &c.items[0] else {
795            panic!()
796        };
797        assert_eq!(t.name.name, "Metres");
798        match &t.body {
799            TypeBody::Refined {
800                base, refinement, ..
801            } => {
802                assert_eq!(*base, BaseType::Int);
803                assert!(refinement.is_some());
804            }
805            _ => panic!("expected refined body"),
806        }
807    }
808
809    #[test]
810    fn function_decl() {
811        let c = parse_str("commons x { fn add(a: Int, b: Int) -> Int { a + b } }").unwrap();
812        let CommonsItem::Fn(f) = &c.items[0] else {
813            panic!()
814        };
815        assert_eq!(f.name.ident().name, "add");
816        assert_eq!(f.params.len(), 2);
817    }
818
819    #[test]
820    fn chained_comparison_is_error() {
821        let errs = parse_str("commons x { fn f(a: Int, b: Int, c: Int) -> Bool { a < b < c } }")
822            .unwrap_err();
823        assert_eq!(errs[0].category, "bynk.parse.non_associative");
824    }
825
826    #[test]
827    fn chained_equality_is_error() {
828        let errs = parse_str("commons x { fn f(a: Int, b: Int, c: Int) -> Bool { a == b == c } }")
829            .unwrap_err();
830        assert_eq!(errs[0].category, "bynk.parse.non_associative");
831    }
832
833    #[test]
834    fn let_statement_parses() {
835        let c = parse_str("commons x { fn f(n: Int) -> Int { let y = n + 1\n y } }").unwrap();
836        let CommonsItem::Fn(f) = &c.items[0] else {
837            panic!()
838        };
839        assert_eq!(f.body.statements.len(), 1);
840        match &f.body.statements[0] {
841            Statement::Let(l) => {
842                assert_eq!(l.name.name, "y");
843                assert!(l.type_annot.is_none());
844            }
845            _ => panic!("expected a pure `let` statement"),
846        }
847    }
848
849    #[test]
850    fn let_with_annotation() {
851        let c = parse_str("commons x { fn f(n: Int) -> Int { let y: Int = n\n y } }").unwrap();
852        let CommonsItem::Fn(f) = &c.items[0] else {
853            panic!()
854        };
855        match &f.body.statements[0] {
856            Statement::Let(l) => assert!(l.type_annot.is_some()),
857            _ => panic!("expected a pure `let` statement"),
858        }
859    }
860
861    #[test]
862    fn if_else_parses_as_expression() {
863        let c = parse_str("commons x { fn f(b: Bool) -> Int { if b { 1 } else { 0 } } }").unwrap();
864        let CommonsItem::Fn(f) = &c.items[0] else {
865            panic!()
866        };
867        assert!(matches!(f.body.tail.kind, ExprKind::If { .. }));
868    }
869
870    #[test]
871    fn else_if_chain_parses() {
872        let c = parse_str(
873            "commons x { fn f(n: Int) -> Int { if n < 0 { -1 } else if n == 0 { 0 } else { 1 } } }",
874        )
875        .unwrap();
876        let CommonsItem::Fn(f) = &c.items[0] else {
877            panic!()
878        };
879        let ExprKind::If { else_block, .. } = &f.body.tail.kind else {
880            panic!()
881        };
882        // The else-branch is a block whose tail is another `If`.
883        assert!(else_block.statements.is_empty());
884        assert!(matches!(else_block.tail.kind, ExprKind::If { .. }));
885    }
886
887    #[test]
888    fn ok_and_err_parse_as_expressions() {
889        let c = parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Ok(n) } }").unwrap();
890        let CommonsItem::Fn(f) = &c.items[0] else {
891            panic!()
892        };
893        assert!(matches!(f.body.tail.kind, ExprKind::Ok(_)));
894
895        let c =
896            parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Err(\"x\") } }").unwrap();
897        let CommonsItem::Fn(f) = &c.items[0] else {
898            panic!()
899        };
900        assert!(matches!(f.body.tail.kind, ExprKind::Err(_)));
901    }
902
903    #[test]
904    fn question_postfix_parses() {
905        let c = parse_str(
906            "commons x { type T = Int where Positive\n fn f(n: Int) -> Result[T, ValidationError] { let x = T.of(n)?\n Ok(x) } }",
907        )
908        .unwrap();
909        let CommonsItem::Fn(f) = &c.items[1] else {
910            panic!()
911        };
912        let Statement::Let(l) = &f.body.statements[0] else {
913            panic!("expected a pure `let` statement");
914        };
915        assert!(matches!(l.value.kind, ExprKind::Question(_)));
916    }
917
918    #[test]
919    fn constructor_call_parses() {
920        let c = parse_str(
921            "commons x { type T = Int where Positive\n fn f(n: Int) -> Result[T, ValidationError] { T.of(n) } }",
922        )
923        .unwrap();
924        let CommonsItem::Fn(f) = &c.items[1] else {
925            panic!()
926        };
927        // v0.2: T.of(n) parses as a MethodCall with receiver Ident("T"); the
928        // checker reinterprets it as a static call by noticing T is a type.
929        let ExprKind::MethodCall {
930            receiver, method, ..
931        } = &f.body.tail.kind
932        else {
933            panic!("expected MethodCall, got {:?}", f.body.tail.kind)
934        };
935        let ExprKind::Ident(id) = &receiver.kind else {
936            panic!("expected receiver Ident");
937        };
938        assert_eq!(id.name, "T");
939        assert_eq!(method.name, "of");
940    }
941
942    #[test]
943    fn result_type_ref_parses() {
944        let c = parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Ok(n) } }").unwrap();
945        let CommonsItem::Fn(f) = &c.items[0] else {
946            panic!()
947        };
948        assert!(matches!(f.return_type, TypeRef::Result(_, _, _)));
949    }
950
951    #[test]
952    fn result_missing_arg_count_errors() {
953        let errs = parse_str("commons x { fn f(n: Int) -> Result[Int] { Ok(n) } }").unwrap_err();
954        assert_eq!(errs[0].category, "bynk.parse.generic_arg_count");
955    }
956
957    #[test]
958    fn field_access_parses_in_v0_2() {
959        // v0.2: field access is supported (the type checker validates the
960        // field exists on the receiver's type). Parser-level acceptance:
961        let c =
962            parse_str("commons x { type R = { foo: Int }\n fn f(r: R) -> Int { r.foo } }").unwrap();
963        let CommonsItem::Fn(f) = &c.items[1] else {
964            panic!()
965        };
966        assert!(matches!(f.body.tail.kind, ExprKind::FieldAccess { .. }));
967    }
968
969    // -- v1.1 trivia attachment --
970
971    #[test]
972    fn leading_line_comment_attaches_to_next_decl() {
973        let src = "commons x {\n-- explain the type\ntype T = Int where NonNegative\n}";
974        let c = parse_str(src).unwrap();
975        let CommonsItem::Type(t) = &c.items[0] else {
976            panic!()
977        };
978        assert_eq!(t.trivia.leading, vec![" explain the type".to_string()]);
979        assert!(t.trivia.trailing.is_none());
980    }
981
982    #[test]
983    fn trailing_line_comment_attaches_to_prev_decl() {
984        let src = "commons x {\ntype T = Int where NonNegative  -- trailing note\n}";
985        let c = parse_str(src).unwrap();
986        let CommonsItem::Type(t) = &c.items[0] else {
987            panic!()
988        };
989        assert!(t.trivia.leading.is_empty());
990        assert_eq!(t.trivia.trailing.as_deref(), Some(" trailing note"));
991    }
992
993    #[test]
994    fn grouped_leading_comments_attach_together() {
995        let src = "commons x {\n-- one\n-- two\n-- three\ntype T = Int where Positive\n}";
996        let c = parse_str(src).unwrap();
997        let CommonsItem::Type(t) = &c.items[0] else {
998            panic!()
999        };
1000        assert_eq!(
1001            t.trivia.leading,
1002            vec![" one".to_string(), " two".to_string(), " three".to_string()],
1003        );
1004    }
1005
1006    #[test]
1007    fn comment_with_doc_block_keeps_both() {
1008        // Both `-- intro` and the doc block should attach to the type decl.
1009        let src = "commons x {\n-- intro\n---\ndocs\n---\ntype T = Int where Positive\n}";
1010        let c = parse_str(src).unwrap();
1011        let CommonsItem::Type(t) = &c.items[0] else {
1012            panic!()
1013        };
1014        assert_eq!(t.trivia.leading, vec![" intro".to_string()]);
1015        assert_eq!(t.documentation.as_deref(), Some("docs"));
1016    }
1017
1018    #[test]
1019    fn comment_before_let_statement_attaches() {
1020        let src = "commons x {\nfn f(n: Int) -> Int {\n-- pick a value\nlet y = n + 1\ny\n}\n}";
1021        let c = parse_str(src).unwrap();
1022        let CommonsItem::Fn(f) = &c.items[0] else {
1023            panic!()
1024        };
1025        let Statement::Let(l) = &f.body.statements[0] else {
1026            panic!()
1027        };
1028        assert_eq!(l.trivia.leading, vec![" pick a value".to_string()]);
1029    }
1030
1031    #[test]
1032    fn comment_before_tail_attaches_to_block_tail() {
1033        let src = "commons x {\nfn f(n: Int) -> Int {\nlet y = n + 1\n-- result\ny\n}\n}";
1034        let c = parse_str(src).unwrap();
1035        let CommonsItem::Fn(f) = &c.items[0] else {
1036            panic!()
1037        };
1038        assert_eq!(f.body.tail_leading_comments, vec![" result".to_string()],);
1039    }
1040
1041    #[test]
1042    fn trailing_file_comment_becomes_unit_trailing() {
1043        // A comment after the last item but before EOF (fragment form)
1044        // becomes the commons body's trailing comments so the formatter
1045        // can preserve it.
1046        let src = "commons x\n\ntype T = Int where Positive\n-- afterword\n";
1047        let c = parse_str(src).unwrap();
1048        assert_eq!(c.trailing_comments, vec![" afterword".to_string()]);
1049    }
1050}