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    parse_with_warnings(tokens, source).map(|(c, _warnings)| c)
104}
105
106/// [`parse`] with the non-fatal diagnostics threaded out alongside the AST
107/// (ADR 0117) — see [`parse_units_with_warnings`].
108pub fn parse_with_warnings(
109    tokens: &[Token],
110    source: &str,
111) -> Result<(Commons, Vec<CompileError>), Vec<CompileError>> {
112    let (unit, warnings) = parse_unit_with_warnings(tokens, source)?;
113    match unit {
114        SourceUnit::Commons(c) => Ok((c, warnings)),
115        SourceUnit::Context(ctx) => Err(vec![
116            CompileError::new(
117                "bynk.parse.unexpected_context",
118                ctx.span,
119                "expected a `commons` declaration but found a `context` declaration",
120            )
121            .with_note(
122                "contexts 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::Suite(t) => Err(vec![
126            CompileError::new(
127                "bynk.parse.unexpected_suite",
128                t.span,
129                "expected a `commons` declaration but found a `suite` declaration",
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            // v0.113: a file may hold more than one top-level unit (an atomic
167            // `commons` + `suite` file, DECISION S). Consume any further units
168            // so trailing declarations are not mis-reported as stray tokens; the
169            // editor view is keyed on the first (primary) unit. A genuinely
170            // malformed trailing declaration is still surfaced via recovery.
171            while p.peek().is_some() {
172                match p.parse_unit() {
173                    Ok(_) => {}
174                    Err(e) => {
175                        p.recovered_errors.push(e);
176                        break;
177                    }
178                }
179            }
180            Some(u)
181        }
182        Err(e) => {
183            p.recovered_errors.push(e);
184            None
185        }
186    };
187    let mut all_errors = p.recovered_errors;
188    all_errors.append(&mut warnings);
189    (unit_opt, all_errors)
190}
191
192/// Parse a token slice into a [`SourceUnit`] — either a commons or a context.
193///
194/// Each `.bynk` file is exactly one declaration of one kind.
195pub fn parse_unit(tokens: &[Token], source: &str) -> Result<SourceUnit, Vec<CompileError>> {
196    parse_unit_with_warnings(tokens, source).map(|(unit, _warnings)| unit)
197}
198
199/// [`parse_unit`] with the non-fatal diagnostics threaded out alongside the
200/// AST (ADR 0117) — see [`parse_units_with_warnings`].
201pub fn parse_unit_with_warnings(
202    tokens: &[Token],
203    source: &str,
204) -> Result<(SourceUnit, Vec<CompileError>), Vec<CompileError>> {
205    let (filtered, trivia) = split_trivia(tokens, source);
206    let mut warnings = Vec::new();
207    let mut p = Parser::new(&filtered, source, trivia, &mut warnings);
208    let result = match p.parse_unit() {
209        Ok(u) => {
210            if let Some(extra) = p.peek() {
211                Err(vec![
212                    CompileError::new(
213                        "bynk.parse.extra_tokens",
214                        extra.span,
215                        "unexpected token after top-level declaration",
216                    )
217                    .with_note(
218                        "a `.bynk` file contains exactly one `commons` or `context` declaration",
219                    ),
220                ])
221            } else {
222                Ok(u)
223            }
224        }
225        Err(e) => Err(vec![e]),
226    };
227    // ADR 0117: warnings (e.g. orphan doc blocks) ride alongside a successful
228    // parse — severity governs gating at the caller, not here.
229    match result {
230        Ok(u) => Ok((u, warnings)),
231        Err(mut errs) => {
232            errs.append(&mut warnings);
233            Err(errs)
234        }
235    }
236}
237
238/// Parse a token slice into **all** the top-level [`SourceUnit`]s in one file
239/// (v0.113, testing track slice 1b). A `.bynk` file may hold more than one
240/// top-level declaration — an *atomic* file with `commons`/`context` **and** a
241/// `suite` together (DECISION S) — so the compiler parses a `Vec`, not a single
242/// unit. Test-ness is a property of each declaration, not of the file.
243///
244/// Bails on the first malformed declaration (like [`parse_unit`], not the
245/// recovering LSP path). An empty file is an error.
246pub fn parse_units(tokens: &[Token], source: &str) -> Result<Vec<SourceUnit>, Vec<CompileError>> {
247    parse_units_with_warnings(tokens, source).map(|(units, _warnings)| units)
248}
249
250/// [`parse_units`] with the non-fatal diagnostics threaded out alongside the
251/// AST (ADR 0117): a successful parse returns `Ok((units, warnings))` instead
252/// of hard-failing on a warning-severity diagnostic (an orphan doc block used
253/// to abort file discovery and throw the good AST away). A failed parse still
254/// returns every diagnostic — errors then warnings — in the `Err`.
255pub fn parse_units_with_warnings(
256    tokens: &[Token],
257    source: &str,
258) -> Result<(Vec<SourceUnit>, Vec<CompileError>), Vec<CompileError>> {
259    let (filtered, trivia) = split_trivia(tokens, source);
260    let mut warnings = Vec::new();
261    let mut p = Parser::new(&filtered, source, trivia, &mut warnings);
262    let mut units = Vec::new();
263    let mut errors: Vec<CompileError> = Vec::new();
264    while p.peek().is_some() {
265        match p.parse_unit() {
266            Ok(u) => units.push(u),
267            Err(e) => {
268                errors.push(e);
269                break;
270            }
271        }
272    }
273    let eof = p.eof_span();
274    // `p` (and thus its `&mut warnings` borrow) is no longer used past here, so
275    // the local `warnings` are readable again.
276    if !errors.is_empty() {
277        errors.append(&mut warnings);
278        return Err(errors);
279    }
280    if units.is_empty() {
281        return Err(vec![CompileError::new(
282            "bynk.parse.unexpected_eof",
283            eof,
284            "expected `commons`, `context`, or `suite` to start the file, found end of file",
285        )]);
286    }
287    Ok((units, warnings))
288}
289
290/// A signed numeric literal in refinement-bound position (v0.21): `InRange`
291/// bounds are either both `Int` or both `Float`.
292enum SignedNumLit {
293    Int(IntBound),
294    Float(FloatBound),
295}
296
297struct Parser<'a> {
298    tokens: &'a [Token],
299    source: &'a str,
300    pos: usize,
301    /// Accumulated non-fatal diagnostics. v0.3 uses this for orphan-doc
302    /// warnings, which are emitted as errors with a distinguishable category.
303    warnings: &'a mut Vec<CompileError>,
304    /// When true, the item-level loops catch errors from individual item
305    /// parses, push them into `recovered_errors`, and skip forward to the
306    /// next top-level item boundary instead of bailing. Used by the LSP via
307    /// [`parse_unit_with_recovery`]; disabled in the normal `parse` path so
308    /// existing single-error behaviour is preserved.
309    recover_mode: bool,
310    /// Errors collected during recovery-mode parsing. Only populated when
311    /// `recover_mode` is true.
312    recovered_errors: Vec<CompileError>,
313    /// Line-comment trivia separated from the token stream. See
314    /// [`TriviaTable`].
315    trivia: TriviaTable,
316    /// Live recursion depth of the three self-recursive parse entry points
317    /// (`parse_expr`, `parse_type_ref`, `parse_pattern`). Incremented on entry
318    /// and decremented on exit by [`Parser::enter_recursion`] so it tracks the
319    /// current stack depth; when it exceeds [`crate::MAX_NESTING_DEPTH`] the
320    /// parser reports a bounded-depth diagnostic instead of overflowing its
321    /// stack (#713).
322    depth: usize,
323    /// When true, a bare `ident {` on the *spine* of the current expression is
324    /// an identifier followed by an unrelated block, never a record
325    /// construction — so an `if`/`match` condition that ends in a bare
326    /// identifier does not swallow the branch/arm block as `Ident { field }`
327    /// (#636). Set only around the condition parse (see [`parse_cond_expr`]);
328    /// `parse_expr` clears it, so the restriction is lifted inside any
329    /// delimited sub-expression (parentheses, call arguments, list, record
330    /// field). Mirrors Rust's `NO_STRUCT_LITERAL` restriction.
331    no_record_literal: bool,
332}
333
334impl<'a> Parser<'a> {
335    fn new(
336        tokens: &'a [Token],
337        source: &'a str,
338        trivia: TriviaTable,
339        warnings: &'a mut Vec<CompileError>,
340    ) -> Self {
341        Self {
342            tokens,
343            source,
344            pos: 0,
345            warnings,
346            recover_mode: false,
347            recovered_errors: Vec::new(),
348            trivia,
349            depth: 0,
350            no_record_literal: false,
351        }
352    }
353
354    /// Enter a self-recursive parse step, bumping the live recursion depth and
355    /// failing with a bounded-depth diagnostic if it would exceed
356    /// [`crate::MAX_NESTING_DEPTH`]. The caller pairs a successful entry with a
357    /// matching `self.depth -= 1` on the way out (see `parse_expr` /
358    /// `parse_type_ref`); on the error path the depth is restored here so a
359    /// recovering caller is not left mis-counted. `what` names the construct
360    /// for the message (e.g. "this expression", "this type"). See #713.
361    fn enter_recursion(&mut self, what: &str) -> Result<(), CompileError> {
362        self.depth += 1;
363        if self.depth > crate::MAX_NESTING_DEPTH {
364            self.depth -= 1;
365            let span = self
366                .peek()
367                .map(|t| t.span)
368                .unwrap_or_else(|| self.eof_span());
369            return Err(self.nesting_too_deep(span, what));
370        }
371        Ok(())
372    }
373
374    /// The bounded-depth diagnostic shared by [`enter_recursion`] and
375    /// [`enter_chain_fold`].
376    fn nesting_too_deep(&self, span: Span, what: &str) -> CompileError {
377        CompileError::new(
378            "bynk.parse.nesting_too_deep",
379            span,
380            format!(
381                "{what} nests more than {} levels deep",
382                crate::MAX_NESTING_DEPTH
383            ),
384        )
385        .with_note(
386            "deeply nested source is rejected to keep the parser from overflowing its \
387             stack and aborting; flatten or split the construct",
388        )
389    }
390
391    /// The bounded-depth diagnostic for the *iteratively*-built spines —
392    /// associative operator chains ([`enter_chain_fold`]) and postfix receiver
393    /// chains ([`deepen_spine`]). Same code as [`nesting_too_deep`] (one budget,
394    /// one diagnostic) but phrased for a flat chain, which is long rather than
395    /// *nested*, and points at the idiomatic fix.
396    fn expression_too_long(&self, span: Span) -> CompileError {
397        CompileError::new(
398            "bynk.parse.nesting_too_deep",
399            span,
400            format!(
401                "this expression is more than {} levels deep",
402                crate::MAX_NESTING_DEPTH
403            ),
404        )
405        .with_note(
406            "a long operator or member chain is rejected to keep the compiler from overflowing \
407             its stack; split it across `let` bindings, or reduce a sequence with \
408             `.sum()`/`.fold(...)`",
409        )
410    }
411
412    /// Count one more operand folded onto an associative operator chain against
413    /// the same recursion budget as [`enter_recursion`] (#714).
414    ///
415    /// Associative chains (`+`, `*`, `&&`, `||`) are built *iteratively* in the
416    /// precedence ladder, so — unlike parentheses, calls, or `implies` — they
417    /// never re-enter `parse_expr` and thus slip past the `enter_recursion`
418    /// guard. Yet each fold deepens the left-nested `Expr` tree by one level,
419    /// and a long flat chain (`1 + 1 + … + 1`) overflows every *recursive*
420    /// consumer of that tree downstream — the checker's `type_of`, the
421    /// formatter, the emitter, and the AST's own recursive `Drop` — exactly as
422    /// deeply nested source overflows the parser. Counting each fold on the
423    /// shared `depth` budget bounds the whole expression's height, and because
424    /// it is the *same* budget it composes with the ambient nesting depth, so a
425    /// chain buried inside deeply nested source cannot exceed the bound either.
426    ///
427    /// The caller accumulates `folds` and subtracts them from `depth` before it
428    /// returns, so the live count unwinds as a recursive descent would; on the
429    /// overflow path the whole chain's contribution is restored here so a
430    /// recovering caller is not left mis-counted.
431    fn enter_chain_fold(&mut self, folds: &mut usize, span: Span) -> Result<(), CompileError> {
432        self.depth += 1;
433        *folds += 1;
434        if self.depth > crate::MAX_NESTING_DEPTH {
435            self.depth -= *folds;
436            *folds = 0;
437            return Err(self.expression_too_long(span));
438        }
439        Ok(())
440    }
441
442    /// Count one more level of an iteratively-built postfix receiver spine
443    /// (`a.b.c…`, `f()?.g()…`) against the shared budget (#714). Like
444    /// [`enter_chain_fold`], postfix loops rather than recurses, so a long spine
445    /// escapes [`enter_recursion`] yet grows an arbitrarily deep receiver tree
446    /// that the downstream walks recurse through. `parse_postfix` restores
447    /// `depth` wholesale on the way out (its many error paths make a
448    /// save/restore wrapper cleaner than per-fold unwinding), so this only bumps
449    /// and checks.
450    fn deepen_spine(&mut self, span: Span) -> Result<(), CompileError> {
451        self.depth += 1;
452        if self.depth > crate::MAX_NESTING_DEPTH {
453            return Err(self.expression_too_long(span));
454        }
455        Ok(())
456    }
457
458    /// Comments immediately preceding the current peek position. Consumed
459    /// (the table entry is cleared) so the same comments are not attached
460    /// to two nodes.
461    fn take_leading_trivia(&mut self) -> Vec<String> {
462        self.trivia.take_leading(self.pos)
463    }
464
465    /// Trailing comment, if any, on the same source line as the most
466    /// recently consumed content token. Call AFTER finishing a declaration
467    /// or statement, while `self.pos` points one past its last token.
468    fn take_trailing_trivia(&mut self) -> Option<String> {
469        if self.pos == 0 {
470            return None;
471        }
472        self.trivia.take_trailing(self.pos - 1)
473    }
474
475    /// Handle a per-item parse error. In recovery mode, record the error and
476    /// advance to the next sync point so the item loop can continue; otherwise
477    /// propagate as a hard failure.
478    fn handle_item_err(&mut self, e: CompileError) -> Result<(), CompileError> {
479        if self.recover_mode {
480            self.recovered_errors.push(e);
481            let before = self.pos;
482            self.recover_to_top_item();
483            // The sync target may be the very token that produced the error —
484            // a context-only keyword (`capability`, `service`, …) at item
485            // position in a commons errors *without consuming it*, and it is
486            // itself a sync point. Recovery must always make progress, or the
487            // item loop re-reports the same error until memory runs out
488            // (found by the `parse` fuzz target on a seed input).
489            if self.pos == before {
490                self.bump();
491            }
492            Ok(())
493        } else {
494            Err(e)
495        }
496    }
497
498    /// Skip forward to the next top-level item boundary: either a top-level
499    /// declaration keyword (`type`, `fn`, `uses`, `consumes`, `exports`,
500    /// `capability`, `provides`, `service`, `agent`), a closing brace, or
501    /// end-of-input. Used only in recovery mode.
502    fn recover_to_top_item(&mut self) {
503        while let Some(t) = self.peek() {
504            match t.kind {
505                TokenKind::Type
506                | TokenKind::Fn
507                | TokenKind::Messages
508                | TokenKind::Uses
509                | TokenKind::Consumes
510                | TokenKind::Exports
511                | TokenKind::Capability
512                | TokenKind::Provides
513                | TokenKind::Stub
514                | TokenKind::Service
515                | TokenKind::Agent
516                | TokenKind::Suite
517                | TokenKind::Case
518                | TokenKind::RBrace
519                | TokenKind::Commons
520                | TokenKind::Context => return,
521                _ => {
522                    self.bump();
523                }
524            }
525        }
526    }
527
528    fn peek(&self) -> Option<Token> {
529        self.tokens.get(self.pos).copied()
530    }
531
532    fn peek_kind(&self) -> Option<TokenKind> {
533        self.peek().map(|t| t.kind)
534    }
535
536    /// The token `n` positions ahead of the cursor (`nth(0)` == `peek()`).
537    fn nth(&self, n: usize) -> Option<Token> {
538        self.tokens.get(self.pos + n).copied()
539    }
540
541    fn nth_kind(&self, n: usize) -> Option<TokenKind> {
542        self.nth(n).map(|t| t.kind)
543    }
544
545    /// The source text of the token `n` positions ahead, or `""` if none.
546    fn nth_text(&self, n: usize) -> &'a str {
547        self.nth(n).map(|t| self.slice(t.span)).unwrap_or("")
548    }
549
550    /// The span of the most recently consumed token (`self.pos - 1`). Falls back
551    /// to the current token's span when nothing has been consumed yet.
552    fn prev_span(&self) -> Span {
553        self.tokens
554            .get(self.pos.wrapping_sub(1))
555            .or_else(|| self.peek_ref())
556            .map(|t| t.span)
557            .unwrap_or_default()
558    }
559
560    fn peek_ref(&self) -> Option<&Token> {
561        self.tokens.get(self.pos)
562    }
563
564    fn bump(&mut self) -> Option<Token> {
565        let t = self.peek();
566        if t.is_some() {
567            self.pos += 1;
568        }
569        t
570    }
571
572    fn eat(&mut self, kind: TokenKind) -> Option<Token> {
573        if self.peek_kind() == Some(kind) {
574            self.bump()
575        } else {
576            None
577        }
578    }
579
580    fn slice(&self, span: Span) -> &'a str {
581        &self.source[span.range()]
582    }
583
584    /// True when the next token sits on a later line than `prev`. Used to
585    /// keep a `[` that opens a new line out of the postfix type-application
586    /// form: `f` followed by `[1, 2]` on the next line is an identifier and
587    /// a list literal, not `f[…]` (v0.20b).
588    fn next_token_on_new_line(&self, prev: Span) -> bool {
589        match self.peek() {
590            Some(t) if prev.end <= t.span.start => {
591                self.source[prev.end..t.span.start].contains('\n')
592            }
593            _ => false,
594        }
595    }
596
597    /// Span pointing at the end of input — used for "unexpected EOF" reports.
598    /// The start backs up to the **start of the final char**, not `len - 1`, so
599    /// the span never splits a multibyte codepoint (an unterminated construct
600    /// whose last line ends in non-ASCII — e.g. a `--` comment ending in `→`).
601    fn eof_span(&self) -> Span {
602        let end = self.source.len();
603        let start = (0..end)
604            .rev()
605            .find(|&i| self.source.is_char_boundary(i))
606            .unwrap_or(0);
607        Span::new(start, end)
608    }
609
610    fn expect(&mut self, kind: TokenKind, ctx: &str) -> Result<Token, CompileError> {
611        match self.peek() {
612            Some(t) if t.kind == kind => {
613                self.bump();
614                Ok(t)
615            }
616            Some(t) => Err(CompileError::new(
617                "bynk.parse.expected_token",
618                t.span,
619                format!(
620                    "expected {} {ctx}, found {}",
621                    kind.describe(),
622                    t.kind.describe()
623                ),
624            )),
625            None => Err(CompileError::new(
626                "bynk.parse.unexpected_eof",
627                self.eof_span(),
628                format!("expected {} {ctx}, found end of file", kind.describe()),
629            )),
630        }
631    }
632
633    fn expect_ident(&mut self, ctx: &str) -> Result<Ident, CompileError> {
634        match self.peek() {
635            Some(t) if t.kind == TokenKind::Ident => {
636                self.bump();
637                Ok(Ident {
638                    name: self.slice(t.span).to_string(),
639                    span: t.span,
640                })
641            }
642            // v0.5 contextual keyword `on` doubles as an identifier in
643            // expression / field-access positions so users can name fields and
644            // parameters using it. It retains its keyword meaning only at
645            // handler-decl-level (`on call(...)`).
646            //
647            // v0.7 / v0.112: `suite` and `case` are contextual too — they
648            // introduce the suite declaration and its cases, but are perfectly
649            // valid commons/context/field names otherwise.
650            //
651            // The tier is single-sourced in `keywords::RESERVED_CONTEXTUAL`:
652            // this arm defers to it rather than hardcoding the token kinds, so
653            // extending that list is enough to admit a new contextual keyword
654            // here. Each of these words lexes only to its own token, so matching
655            // the source text is equivalent to matching the kind.
656            Some(t) if crate::keywords::is_reserved_contextual(self.slice(t.span)) => {
657                self.bump();
658                Ok(Ident {
659                    name: self.slice(t.span).to_string(),
660                    span: t.span,
661                })
662            }
663            Some(t) if is_reserved_keyword(t.kind) => Err(CompileError::new(
664                "bynk.parse.reserved_keyword",
665                t.span,
666                format!(
667                    "expected identifier {ctx}, but `{}` is a reserved keyword",
668                    self.slice(t.span)
669                ),
670            )
671            .with_note("rename the identifier to something that is not a keyword")),
672            Some(t) => Err(CompileError::new(
673                "bynk.parse.expected_token",
674                t.span,
675                format!("expected identifier {ctx}, found {}", t.kind.describe()),
676            )),
677            None => Err(CompileError::new(
678                "bynk.parse.unexpected_eof",
679                self.eof_span(),
680                format!("expected identifier {ctx}, found end of file"),
681            )),
682        }
683    }
684
685    // -- top level --
686
687    /// Consume an optional doc block at the current position, returning the
688    /// (content, end-of-doc span) pair. Returns None if the next token is not
689    /// a doc block.
690    fn take_doc_block(&mut self) -> Option<(String, Span)> {
691        if self.peek_kind() == Some(TokenKind::DocBlock) {
692            let t = self.bump().unwrap();
693            let body = doc_block_content(self.source, t.span);
694            return Some((body, t.span));
695        }
696        None
697    }
698
699    /// Collect all line-comment trivia leading the next declaration plus
700    /// the optional doc block. Comments may appear both *before* and
701    /// *between* the doc and the declaration; the spec canonicalises both
702    /// groups above the doc, so we concatenate them.
703    fn collect_item_lead(&mut self) -> (Vec<String>, Option<(String, Span)>) {
704        let mut leading = self.take_leading_trivia();
705        let doc = self.take_doc_block();
706        if doc.is_some() {
707            leading.extend(self.take_leading_trivia());
708        }
709        (leading, doc)
710    }
711
712    /// Attach a parsed doc block to a following declaration unless a blank
713    /// line separates them, in which case the doc is orphaned (warning).
714    fn finalize_doc(&mut self, doc: Option<(String, Span)>, next_span: Span) -> Option<String> {
715        let (content, doc_span) = doc?;
716        // A blank line between the doc and the next decl orphans the doc.
717        if has_blank_line_between(self.source, doc_span.end, next_span.start) {
718            self.warnings.push(
719                CompileError::new(
720                    "bynk.parse.orphan_doc_block",
721                    doc_span,
722                    "documentation block is separated from the following declaration by a blank line; it will not be attached",
723                )
724                .with_note(
725                    "remove the blank line to attach the doc to the next declaration, \
726                     or remove the doc block if it is not meant to document anything",
727                ),
728            );
729            return None;
730        }
731        Some(content)
732    }
733}
734
735/// Parse the body of a lexed double-quoted string literal (the lexeme,
736/// including surrounding quotes), applying the v0 escape rules.
737fn parse_string_literal(lexeme: &str, span: Span) -> Result<String, CompileError> {
738    let bytes = lexeme.as_bytes();
739    debug_assert!(bytes.first() == Some(&b'"') && bytes.last() == Some(&b'"'));
740    let inner = &lexeme[1..lexeme.len() - 1];
741    let mut out = String::with_capacity(inner.len());
742    let mut chars = inner.chars();
743    while let Some(c) = chars.next() {
744        if c == '\\' {
745            match chars.next() {
746                Some('n') => out.push('\n'),
747                Some('t') => out.push('\t'),
748                Some('"') => out.push('"'),
749                Some('\\') => out.push('\\'),
750                other => {
751                    return Err(CompileError::new(
752                        "bynk.lex.bad_escape",
753                        span,
754                        format!(
755                            "invalid escape sequence `\\{}` in string literal",
756                            other.map(|c| c.to_string()).unwrap_or_default()
757                        ),
758                    )
759                    .with_note("supported escapes: \\n \\t \\\" \\\\"));
760                }
761            }
762        } else {
763            out.push(c);
764        }
765    }
766    Ok(out)
767}
768
769fn is_reserved_keyword(kind: TokenKind) -> bool {
770    use TokenKind::*;
771    matches!(
772        kind,
773        Commons
774            | Type
775            | Fn
776            | Where
777            | True
778            | False
779            | Int
780            | String
781            | Bool
782            | Let
783            | If
784            | Else
785            | Ok
786            | Err
787            | Result
788            | ValidationError
789            | Enum
790            | Match
791            | Option
792            | Record
793            | Self_
794            | Some
795            | None
796            | Is
797            | Opaque
798            | Uses
799            | Context
800            | Consumes
801            | Exports
802            | Transparent
803            | Agent
804            | As
805            | Capability
806            | Effect
807            | Do
808            | Given
809            | On
810            | Http
811            | Provides
812            | Stub
813            | Service
814            | Actor
815            | By
816            | Expect
817            | Suite
818            | Case
819            | Float
820            | Duration
821            | Instant
822            | Bytes
823            | JsonError
824            | Property
825            | Adapter
826            | Binding
827            | Cron
828            | Queue
829            | From
830            | Protocol
831            | Invariant
832            | Implies
833            | Requires
834            | Ensures
835            | Transition
836    )
837}
838
839#[cfg(test)]
840mod tests {
841    use super::*;
842    use crate::lexer::tokenize;
843
844    fn parse_str(src: &str) -> Result<Commons, Vec<CompileError>> {
845        let toks = tokenize(src).map_err(|e| vec![e])?;
846        parse(&toks, src)
847    }
848
849    fn parse_recover_str(src: &str) -> (Option<SourceUnit>, Vec<CompileError>) {
850        let toks = match tokenize(src) {
851            Ok(t) => t,
852            Err(e) => return (None, vec![e]),
853        };
854        parse_unit_with_recovery(&toks, src)
855    }
856
857    #[test]
858    fn eof_span_never_splits_a_multibyte_codepoint() {
859        // An unterminated construct whose final line ends in a non-ASCII char
860        // (here a `--` comment ending in `→`) once produced an `unexpected_eof`
861        // span of `len - 1 .. len`, landing on the arrow's last continuation
862        // byte. Every reported span must sit on char boundaries.
863        for src in [
864            "commons x {\n  -- ends with an arrow →",
865            "agent A {\n  key k: String\n  -- note 🦀",
866            "commons y {\n  type T = é",
867        ] {
868            let (_unit, errors) = parse_recover_str(src);
869            for e in &errors {
870                assert!(
871                    src.is_char_boundary(e.span.start) && src.is_char_boundary(e.span.end),
872                    "span {:?} splits a codepoint in {src:?}",
873                    e.span,
874                );
875            }
876        }
877    }
878
879    #[test]
880    fn recovery_skips_garbage_between_decls() {
881        // Two `type` declarations separated by garbage. Recovery should
882        // accept both and report one error for the garbage between them.
883        let src = "commons x {\n\
884                   type A = Int where NonNegative\n\
885                   ??? !!!\n\
886                   type B = String where NonEmpty\n\
887                   }";
888        let (unit, errors) = parse_recover_str(src);
889        let unit = unit.expect("recovery should produce a partial AST");
890        let SourceUnit::Commons(c) = unit else {
891            panic!("expected commons")
892        };
893        // Both type decls should have been collected despite the garbage.
894        let names: Vec<_> = c
895            .items
896            .iter()
897            .map(|i| match i {
898                CommonsItem::Type(t) => t.name.name.clone(),
899                _ => panic!("expected only types"),
900            })
901            .collect();
902        assert!(
903            names.contains(&"A".to_string()) && names.contains(&"B".to_string()),
904            "expected both A and B; got {names:?}",
905        );
906        assert!(!errors.is_empty(), "expected at least one parse error");
907    }
908
909    #[test]
910    fn recovery_handles_bad_first_decl_then_good_second() {
911        // First decl is malformed (missing `=`); second is well-formed.
912        let src = "commons x {\n\
913                   type A Int where NonNegative\n\
914                   type B = String where NonEmpty\n\
915                   }";
916        let (unit, errors) = parse_recover_str(src);
917        let unit = unit.expect("recovery should produce a partial AST");
918        let SourceUnit::Commons(c) = unit else {
919            panic!("expected commons")
920        };
921        let names: Vec<_> = c
922            .items
923            .iter()
924            .filter_map(|i| match i {
925                CommonsItem::Type(t) => Some(t.name.name.clone()),
926                _ => None,
927            })
928            .collect();
929        assert!(
930            names.contains(&"B".to_string()),
931            "B should be parsed after A's failure; got {names:?}"
932        );
933        assert!(!errors.is_empty(), "expected at least one parse error");
934    }
935
936    #[test]
937    fn doc_block_attaches_to_type() {
938        let c =
939            parse_str("commons x {\n---\nA descriptive doc.\n---\ntype T = Int where Positive\n}")
940                .unwrap();
941        let CommonsItem::Type(t) = &c.items[0] else {
942            panic!()
943        };
944        assert!(t.documentation.is_some());
945        assert!(
946            t.documentation
947                .as_ref()
948                .unwrap()
949                .contains("A descriptive doc.")
950        );
951    }
952
953    #[test]
954    fn interpolated_string_parses_into_parts() {
955        // v0.43: `"Hi, \(name)!"` splits into chunk / hole / chunk.
956        let c = parse_str("commons x\n\nfn f(name: String) -> String {\n  \"Hi, \\(name)!\"\n}\n")
957            .unwrap();
958        let CommonsItem::Fn(f) = &c.items[0] else {
959            panic!("expected fn")
960        };
961        let ExprKind::InterpStr(parts) = &f.body.tail.kind else {
962            panic!("expected InterpStr, got {:?}", f.body.tail.kind)
963        };
964        assert_eq!(parts.len(), 3);
965        assert!(matches!(&parts[0], InterpPart::Chunk(s) if s == "Hi, "));
966        assert!(
967            matches!(&parts[1], InterpPart::Hole(h) if matches!(&h.kind, ExprKind::Ident(id) if id.name == "name"))
968        );
969        assert!(matches!(&parts[2], InterpPart::Chunk(s) if s == "!"));
970    }
971
972    #[test]
973    fn interpolated_hole_parses_a_full_expression() {
974        // A hole holds an arbitrary expression, not just an identifier.
975        let c =
976            parse_str("commons x\n\nfn f(a: Int, b: Int) -> String {\n  \"sum = \\(a + b)\"\n}\n")
977                .unwrap();
978        let CommonsItem::Fn(f) = &c.items[0] else {
979            panic!("expected fn")
980        };
981        let ExprKind::InterpStr(parts) = &f.body.tail.kind else {
982            panic!("expected InterpStr")
983        };
984        assert!(matches!(&parts[1], InterpPart::Hole(h) if matches!(&h.kind, ExprKind::BinOp(..))));
985    }
986
987    #[test]
988    fn empty_interpolation_hole_is_rejected() {
989        let errs = parse_str("commons x\n\nfn f() -> String {\n  \"\\()\"\n}\n").unwrap_err();
990        assert!(
991            errs.iter()
992                .any(|e| e.category == "bynk.parse.empty_interpolation"),
993            "expected empty_interpolation; got {errs:?}"
994        );
995    }
996
997    #[test]
998    fn interpolation_hole_lex_error_span_is_rebased() {
999        // #716: a lex error inside a `\(…)` hole once carried a span relative to
1000        // the hole substring — never rebased by `hole.start` — so it pointed at
1001        // the file's opening bytes and could split a multibyte char, tripping
1002        // the char-boundary invariant. The error must land on the offending
1003        // bytes within the hole and stay on char boundaries.
1004        let cases = [
1005            // `$` is not a valid token; the error should point at it, not byte 0.
1006            "commons x\n\nfn f() -> String {\n  \"a \\($)\"\n}\n",
1007            // Integer overflow — the reported span must cover the literal itself.
1008            "commons x\n\nfn f() -> String {\n  \"n = \\(99999999999999999999)\"\n}\n",
1009            // A multibyte char before the hole means an un-rebased span could
1010            // land inside the `é`; the rebased span must not.
1011            "commons x\n\nfn f() -> String {\n  \"é \\($)\"\n}\n",
1012        ];
1013        for src in cases {
1014            let errs = parse_str(src).unwrap_err();
1015            assert!(!errs.is_empty(), "expected a lex error for {src:?}");
1016            for e in &errs {
1017                assert!(
1018                    src.is_char_boundary(e.span.start) && src.is_char_boundary(e.span.end),
1019                    "span {:?} splits a codepoint in {src:?}",
1020                    e.span,
1021                );
1022                // The error must point inside the interpolation hole, not at the
1023                // header text that precedes it.
1024                let hole_start = src.find("\\(").expect("case has a hole") + 2;
1025                assert!(
1026                    e.span.start >= hole_start,
1027                    "span {:?} precedes the hole (starts at {hole_start}) in {src:?}",
1028                    e.span,
1029                );
1030            }
1031        }
1032    }
1033
1034    #[test]
1035    fn fragment_form_parses() {
1036        let c = parse_str("commons x.y\n\ntype T = Int where NonNegative\n").unwrap();
1037        assert_eq!(c.form, CommonsForm::Fragment);
1038        assert_eq!(c.items.len(), 1);
1039    }
1040
1041    #[test]
1042    fn uses_parses() {
1043        let c = parse_str("commons x\n\nuses other.lib\n").unwrap();
1044        assert_eq!(c.uses.len(), 1);
1045        assert_eq!(c.uses[0].target.joined(), "other.lib");
1046    }
1047
1048    fn parse_unit_str(src: &str) -> Result<SourceUnit, Vec<CompileError>> {
1049        let toks = tokenize(src).map_err(|e| vec![e])?;
1050        parse_unit(&toks, src)
1051    }
1052
1053    #[test]
1054    fn minimal_context_parses() {
1055        let u = parse_unit_str("context commerce.orders {}").unwrap();
1056        let SourceUnit::Context(c) = u else {
1057            panic!("expected context");
1058        };
1059        assert_eq!(c.name.joined(), "commerce.orders");
1060        assert!(c.items.is_empty());
1061    }
1062
1063    #[test]
1064    fn context_consumes_and_exports_parse() {
1065        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}";
1066        let u = parse_unit_str(src).unwrap();
1067        let SourceUnit::Context(c) = u else { panic!() };
1068        assert_eq!(c.uses.len(), 1);
1069        assert_eq!(c.consumes.len(), 1);
1070        assert_eq!(c.exports.len(), 2);
1071        assert_eq!(c.exports[0].kind, ExportKind::Type(Visibility::Opaque));
1072        assert_eq!(c.exports[1].kind, ExportKind::Type(Visibility::Transparent));
1073    }
1074
1075    #[test]
1076    fn context_fragment_form_parses() {
1077        let src = "context x.y\n\nuses other.lib\nconsumes other.ctx\nexports opaque { T }\n\ntype T = Int where NonNegative\n";
1078        let u = parse_unit_str(src).unwrap();
1079        let SourceUnit::Context(c) = u else { panic!() };
1080        assert_eq!(c.form, CommonsForm::Fragment);
1081        assert_eq!(c.uses.len(), 1);
1082        assert_eq!(c.consumes.len(), 1);
1083        assert_eq!(c.exports.len(), 1);
1084    }
1085
1086    #[test]
1087    fn opaque_type_parses() {
1088        let c = parse_str("commons x { type T = opaque Int where NonNegative }").unwrap();
1089        let CommonsItem::Type(t) = &c.items[0] else {
1090            panic!()
1091        };
1092        assert!(matches!(t.body, TypeBody::Opaque { .. }));
1093    }
1094
1095    #[test]
1096    fn empty_commons() {
1097        let c = parse_str("commons fitness.units {}").unwrap();
1098        assert_eq!(c.name.joined(), "fitness.units");
1099        assert!(c.items.is_empty());
1100    }
1101
1102    #[test]
1103    fn one_type_decl() {
1104        let c = parse_str("commons x { type Metres = Int where NonNegative }").unwrap();
1105        assert_eq!(c.items.len(), 1);
1106        let CommonsItem::Type(t) = &c.items[0] else {
1107            panic!()
1108        };
1109        assert_eq!(t.name.name, "Metres");
1110        match &t.body {
1111            TypeBody::Refined {
1112                base, refinement, ..
1113            } => {
1114                assert_eq!(*base, BaseType::Int);
1115                assert!(refinement.is_some());
1116            }
1117            _ => panic!("expected refined body"),
1118        }
1119    }
1120
1121    #[test]
1122    fn function_decl() {
1123        let c = parse_str("commons x { fn add(a: Int, b: Int) -> Int { a + b } }").unwrap();
1124        let CommonsItem::Fn(f) = &c.items[0] else {
1125            panic!()
1126        };
1127        assert_eq!(f.name.ident().name, "add");
1128        assert_eq!(f.params.len(), 2);
1129    }
1130
1131    #[test]
1132    fn chained_comparison_is_error() {
1133        let errs = parse_str("commons x { fn f(a: Int, b: Int, c: Int) -> Bool { a < b < c } }")
1134            .unwrap_err();
1135        assert_eq!(errs[0].category, "bynk.parse.non_associative");
1136    }
1137
1138    #[test]
1139    fn chained_equality_is_error() {
1140        let errs = parse_str("commons x { fn f(a: Int, b: Int, c: Int) -> Bool { a == b == c } }")
1141            .unwrap_err();
1142        assert_eq!(errs[0].category, "bynk.parse.non_associative");
1143    }
1144
1145    /// Run `f` on a thread with a generous stack. The depth-guard tests build
1146    /// source that, *without* the guard, overflows — so if the guard ever
1147    /// regressed we want a clean assertion failure, not a `SIGABRT` that takes
1148    /// the whole test binary down. A large stack also absorbs the fat frames a
1149    /// debug build spends per recursion level (production release frames are
1150    /// ~9 KB/level, so `MAX_NESTING_DEPTH = 64` sits well inside a 1 MB stack;
1151    /// a debug frame is several times larger and would overflow libtest's
1152    /// default 2 MB test thread near the limit even though the guard fires).
1153    fn on_big_stack<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
1154        std::thread::Builder::new()
1155            .stack_size(64 * 1024 * 1024)
1156            .spawn(f)
1157            .unwrap()
1158            .join()
1159            .unwrap()
1160    }
1161
1162    #[test]
1163    fn deeply_nested_parens_are_bounded_not_overflowed() {
1164        // Without a depth guard the parenthesised-expression recursion
1165        // (`parse_primary` -> `parse_expr` -> …) overflows the stack and aborts
1166        // the process (#713). Well past the limit it must instead report a
1167        // bounded-depth diagnostic. The nesting is left open so the guard, not
1168        // a later `)`, is what stops the descent.
1169        let errs = on_big_stack(|| {
1170            let depth = crate::MAX_NESTING_DEPTH + 8;
1171            let src = format!(
1172                "commons x {{ fn f() -> Int {{ {}0{} }} }}",
1173                "(".repeat(depth),
1174                ")".repeat(depth),
1175            );
1176            parse_str(&src).unwrap_err()
1177        });
1178        assert_eq!(errs[0].category, "bynk.parse.nesting_too_deep");
1179    }
1180
1181    #[test]
1182    fn deeply_nested_types_are_bounded_not_overflowed() {
1183        // The type parser self-recurses through generic type arguments
1184        // (`parse_type_ref` -> `parse_type_atom` -> `parse_type_ref`); the same
1185        // guard bounds it (#713). A right-nested `Result[Int, …]` in parameter
1186        // position drives that recursion.
1187        let errs = on_big_stack(|| {
1188            let depth = crate::MAX_NESTING_DEPTH + 8;
1189            let src = format!(
1190                "commons x {{ fn f(x: {}Int{}) -> Int {{ 0 }} }}",
1191                "Result[Int, ".repeat(depth),
1192                "]".repeat(depth),
1193            );
1194            parse_str(&src).unwrap_err()
1195        });
1196        assert_eq!(errs[0].category, "bynk.parse.nesting_too_deep");
1197    }
1198
1199    #[test]
1200    fn deeply_nested_patterns_are_bounded_not_overflowed() {
1201        // Variant patterns are a third self-recursive descent (`parse_pattern`
1202        // -> `parse_pattern_binding` -> `parse_pattern`) that routes through
1203        // neither `parse_expr` nor `parse_type_ref`; without its own guard a
1204        // nested `Ok(Ok(…))` match arm reproduces the #713 crash.
1205        let errs = on_big_stack(|| {
1206            let depth = crate::MAX_NESTING_DEPTH + 8;
1207            let src = format!(
1208                "commons x {{ fn f(n: Int) -> Int {{ match n {{ {}n{} => 0 }} }} }}",
1209                "Ok(".repeat(depth),
1210                ")".repeat(depth),
1211            );
1212            parse_str(&src).unwrap_err()
1213        });
1214        assert_eq!(errs[0].category, "bynk.parse.nesting_too_deep");
1215    }
1216
1217    #[test]
1218    fn nesting_below_the_limit_still_parses() {
1219        // The guard must not reject ordinary well-nested source: a paren-nested
1220        // expression comfortably under the limit still parses cleanly.
1221        let ok = on_big_stack(|| {
1222            let depth = crate::MAX_NESTING_DEPTH - 8;
1223            let src = format!(
1224                "commons x {{ fn f() -> Int {{ {}0{} }} }}",
1225                "(".repeat(depth),
1226                ")".repeat(depth),
1227            );
1228            parse_str(&src).is_ok()
1229        });
1230        assert!(ok, "well-nested source under the limit should parse");
1231    }
1232
1233    #[test]
1234    fn let_statement_parses() {
1235        let c = parse_str("commons x { fn f(n: Int) -> Int { let y = n + 1\n y } }").unwrap();
1236        let CommonsItem::Fn(f) = &c.items[0] else {
1237            panic!()
1238        };
1239        assert_eq!(f.body.statements.len(), 1);
1240        match &f.body.statements[0] {
1241            Statement::Let(l) => {
1242                assert_eq!(l.name.name, "y");
1243                assert!(l.type_annot.is_none());
1244            }
1245            _ => panic!("expected a pure `let` statement"),
1246        }
1247    }
1248
1249    #[test]
1250    fn let_with_annotation() {
1251        let c = parse_str("commons x { fn f(n: Int) -> Int { let y: Int = n\n y } }").unwrap();
1252        let CommonsItem::Fn(f) = &c.items[0] else {
1253            panic!()
1254        };
1255        match &f.body.statements[0] {
1256            Statement::Let(l) => assert!(l.type_annot.is_some()),
1257            _ => panic!("expected a pure `let` statement"),
1258        }
1259    }
1260
1261    #[test]
1262    fn if_else_parses_as_expression() {
1263        let c = parse_str("commons x { fn f(b: Bool) -> Int { if b { 1 } else { 0 } } }").unwrap();
1264        let CommonsItem::Fn(f) = &c.items[0] else {
1265            panic!()
1266        };
1267        assert!(matches!(f.body.tail.kind, ExprKind::If { .. }));
1268    }
1269
1270    #[test]
1271    fn else_if_chain_parses() {
1272        let c = parse_str(
1273            "commons x { fn f(n: Int) -> Int { if n < 0 { -1 } else if n == 0 { 0 } else { 1 } } }",
1274        )
1275        .unwrap();
1276        let CommonsItem::Fn(f) = &c.items[0] else {
1277            panic!()
1278        };
1279        let ExprKind::If { else_block, .. } = &f.body.tail.kind else {
1280            panic!()
1281        };
1282        // The else-branch is a block whose tail is another `If`.
1283        assert!(else_block.statements.is_empty());
1284        assert!(matches!(else_block.tail.kind, ExprKind::If { .. }));
1285    }
1286
1287    #[test]
1288    fn ok_and_err_parse_as_expressions() {
1289        let c = parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Ok(n) } }").unwrap();
1290        let CommonsItem::Fn(f) = &c.items[0] else {
1291            panic!()
1292        };
1293        assert!(matches!(f.body.tail.kind, ExprKind::Ok(_)));
1294
1295        let c =
1296            parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Err(\"x\") } }").unwrap();
1297        let CommonsItem::Fn(f) = &c.items[0] else {
1298            panic!()
1299        };
1300        assert!(matches!(f.body.tail.kind, ExprKind::Err(_)));
1301    }
1302
1303    #[test]
1304    fn question_postfix_parses() {
1305        let c = parse_str(
1306            "commons x { type T = Int where Positive\n fn f(n: Int) -> Result[T, ValidationError] { let x = T.of(n)?\n Ok(x) } }",
1307        )
1308        .unwrap();
1309        let CommonsItem::Fn(f) = &c.items[1] else {
1310            panic!()
1311        };
1312        let Statement::Let(l) = &f.body.statements[0] else {
1313            panic!("expected a pure `let` statement");
1314        };
1315        assert!(matches!(l.value.kind, ExprKind::Question(_)));
1316    }
1317
1318    #[test]
1319    fn constructor_call_parses() {
1320        let c = parse_str(
1321            "commons x { type T = Int where Positive\n fn f(n: Int) -> Result[T, ValidationError] { T.of(n) } }",
1322        )
1323        .unwrap();
1324        let CommonsItem::Fn(f) = &c.items[1] else {
1325            panic!()
1326        };
1327        // v0.2: T.of(n) parses as a MethodCall with receiver Ident("T"); the
1328        // checker reinterprets it as a static call by noticing T is a type.
1329        let ExprKind::MethodCall {
1330            receiver, method, ..
1331        } = &f.body.tail.kind
1332        else {
1333            panic!("expected MethodCall, got {:?}", f.body.tail.kind)
1334        };
1335        let ExprKind::Ident(id) = &receiver.kind else {
1336            panic!("expected receiver Ident");
1337        };
1338        assert_eq!(id.name, "T");
1339        assert_eq!(method.name, "of");
1340    }
1341
1342    #[test]
1343    fn result_type_ref_parses() {
1344        let c = parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Ok(n) } }").unwrap();
1345        let CommonsItem::Fn(f) = &c.items[0] else {
1346            panic!()
1347        };
1348        assert!(matches!(f.return_type, TypeRef::Result(_, _, _)));
1349    }
1350
1351    #[test]
1352    fn result_missing_arg_count_errors() {
1353        let errs = parse_str("commons x { fn f(n: Int) -> Result[Int] { Ok(n) } }").unwrap_err();
1354        assert_eq!(errs[0].category, "bynk.parse.generic_arg_count");
1355    }
1356
1357    #[test]
1358    fn field_access_parses_in_v0_2() {
1359        // v0.2: field access is supported (the type checker validates the
1360        // field exists on the receiver's type). Parser-level acceptance:
1361        let c =
1362            parse_str("commons x { type R = { foo: Int }\n fn f(r: R) -> Int { r.foo } }").unwrap();
1363        let CommonsItem::Fn(f) = &c.items[1] else {
1364            panic!()
1365        };
1366        assert!(matches!(f.body.tail.kind, ExprKind::FieldAccess { .. }));
1367    }
1368
1369    // -- v1.1 trivia attachment --
1370
1371    #[test]
1372    fn leading_line_comment_attaches_to_next_decl() {
1373        let src = "commons x {\n-- explain the type\ntype T = Int where NonNegative\n}";
1374        let c = parse_str(src).unwrap();
1375        let CommonsItem::Type(t) = &c.items[0] else {
1376            panic!()
1377        };
1378        assert_eq!(t.trivia.leading, vec![" explain the type".to_string()]);
1379        assert!(t.trivia.trailing.is_none());
1380    }
1381
1382    #[test]
1383    fn trailing_line_comment_attaches_to_prev_decl() {
1384        let src = "commons x {\ntype T = Int where NonNegative  -- trailing note\n}";
1385        let c = parse_str(src).unwrap();
1386        let CommonsItem::Type(t) = &c.items[0] else {
1387            panic!()
1388        };
1389        assert!(t.trivia.leading.is_empty());
1390        assert_eq!(t.trivia.trailing.as_deref(), Some(" trailing note"));
1391    }
1392
1393    #[test]
1394    fn grouped_leading_comments_attach_together() {
1395        let src = "commons x {\n-- one\n-- two\n-- three\ntype T = Int where Positive\n}";
1396        let c = parse_str(src).unwrap();
1397        let CommonsItem::Type(t) = &c.items[0] else {
1398            panic!()
1399        };
1400        assert_eq!(
1401            t.trivia.leading,
1402            vec![" one".to_string(), " two".to_string(), " three".to_string()],
1403        );
1404    }
1405
1406    #[test]
1407    fn comment_with_doc_block_keeps_both() {
1408        // Both `-- intro` and the doc block should attach to the type decl.
1409        let src = "commons x {\n-- intro\n---\ndocs\n---\ntype T = Int where Positive\n}";
1410        let c = parse_str(src).unwrap();
1411        let CommonsItem::Type(t) = &c.items[0] else {
1412            panic!()
1413        };
1414        assert_eq!(t.trivia.leading, vec![" intro".to_string()]);
1415        assert_eq!(t.documentation.as_deref(), Some("docs"));
1416    }
1417
1418    #[test]
1419    fn messages_keyword_does_not_collide_with_a_commons_name_segment() {
1420        // `messages` is RESERVED_CONTEXTUAL (like `case`/`on`/`suite`), not a
1421        // hard keyword: `commons app.messages { ... }` — the design's own
1422        // natural naming choice for a bundle commons — must still parse.
1423        // (Caught during slice-1 implementation: a first pass made `messages`
1424        // a plain hard keyword and this exact name broke.)
1425        let src = "commons app.messages {\ntype T = Int where Positive\n}";
1426        let c = parse_str(src).unwrap();
1427        assert_eq!(c.name.joined(), "app.messages");
1428    }
1429
1430    #[test]
1431    fn messages_decl_parses_tag_annotation_and_entries() {
1432        // message-bundles slice 1 (#859): the construct + doc/trivia wiring.
1433        let src = "commons app.messages {\n\
1434                   -- intro\n\
1435                   ---\n\
1436                   docs\n\
1437                   ---\n\
1438                   messages en @reference {\n\
1439                   \"greeting\" => \"Hello, {name}!\"\n\
1440                   \"farewell\" => \"Bye\"\n\
1441                   } -- trailing\n\
1442                   }";
1443        let c = parse_str(src).unwrap();
1444        let CommonsItem::Messages(m) = &c.items[0] else {
1445            panic!("expected a messages item, got {:?}", c.items[0]);
1446        };
1447        assert_eq!(m.tag.name, "en");
1448        assert_eq!(m.annotations.len(), 1);
1449        assert_eq!(m.annotations[0].name.name, "reference");
1450        assert!(m.annotations[0].args.is_empty());
1451        assert_eq!(m.entries.len(), 2);
1452        assert_eq!(m.entries[0].code, "greeting");
1453        assert_eq!(m.entries[0].template, "Hello, {name}!");
1454        assert_eq!(m.entries[1].code, "farewell");
1455        assert_eq!(m.entries[1].template, "Bye");
1456        assert_eq!(m.trivia.leading, vec![" intro".to_string()]);
1457        assert_eq!(m.documentation.as_deref(), Some("docs"));
1458        assert_eq!(m.trivia.trailing.as_deref(), Some(" trailing"));
1459    }
1460
1461    #[test]
1462    fn messages_decl_parses_with_no_annotation_and_no_entries() {
1463        // The parser stays permissive on annotation cardinality (zero-or-more)
1464        // — "exactly one `@reference`" is a checker concern (validate.rs), not
1465        // a parse error.
1466        let src = "commons app.messages {\nmessages en {\n}\n}";
1467        let c = parse_str(src).unwrap();
1468        let CommonsItem::Messages(m) = &c.items[0] else {
1469            panic!("expected a messages item, got {:?}", c.items[0]);
1470        };
1471        assert_eq!(m.tag.name, "en");
1472        assert!(m.annotations.is_empty());
1473        assert!(m.entries.is_empty());
1474    }
1475
1476    #[test]
1477    fn messages_decl_parses_syntactically_inside_a_context_too() {
1478        // Commons-only legality is a checker concern (bynk.messages.outside_commons
1479        // in bynk-emit's project validation), not a parser rejection — mirrors
1480        // how `service`/`agent` already parse syntactically inside `adapter`
1481        // bodies for the same reason.
1482        let src = "context app.svc {\nmessages en @reference {\n\"a\" => \"b\"\n}\n}";
1483        let toks = tokenize(src).unwrap();
1484        let (unit, errors) = parse_unit_with_recovery(&toks, src);
1485        assert!(errors.is_empty(), "unexpected parse errors: {errors:?}");
1486        let Some(SourceUnit::Context(ctx)) = unit else {
1487            panic!("expected a context")
1488        };
1489        let CommonsItem::Messages(m) = &ctx.items[0] else {
1490            panic!("expected a messages item, got {:?}", ctx.items[0]);
1491        };
1492        assert_eq!(m.tag.name, "en");
1493    }
1494
1495    #[test]
1496    fn comment_before_let_statement_attaches() {
1497        let src = "commons x {\nfn f(n: Int) -> Int {\n-- pick a value\nlet y = n + 1\ny\n}\n}";
1498        let c = parse_str(src).unwrap();
1499        let CommonsItem::Fn(f) = &c.items[0] else {
1500            panic!()
1501        };
1502        let Statement::Let(l) = &f.body.statements[0] else {
1503            panic!()
1504        };
1505        assert_eq!(l.trivia.leading, vec![" pick a value".to_string()]);
1506    }
1507
1508    #[test]
1509    fn comment_before_tail_attaches_to_block_tail() {
1510        let src = "commons x {\nfn f(n: Int) -> Int {\nlet y = n + 1\n-- result\ny\n}\n}";
1511        let c = parse_str(src).unwrap();
1512        let CommonsItem::Fn(f) = &c.items[0] else {
1513            panic!()
1514        };
1515        assert_eq!(f.body.tail_leading_comments, vec![" result".to_string()],);
1516    }
1517
1518    /// #637 Gap A: the contextual keywords `on` / `suite` / `case` are lexer
1519    /// tokens but `expect_ident` admits them as identifiers outside their one
1520    /// keyword position, so they are valid record-field and parameter names.
1521    /// The keyword reference now renders them as a distinct "contextual" tier
1522    /// rather than claiming (falsely) that they cannot be used as identifiers.
1523    #[test]
1524    fn contextual_keywords_are_valid_identifiers() {
1525        // Record field names.
1526        let c = parse_str("commons demo {\n  type R = { on: Int, suite: String, case: Bool }\n}")
1527            .expect("`on`/`suite`/`case` are valid field names");
1528        let CommonsItem::Type(_) = &c.items[0] else {
1529            panic!("expected a type decl")
1530        };
1531
1532        // Function parameter names (the other `expect_ident` position).
1533        parse_str("commons demo {\n  fn f(on: Int, case: Int) -> Int { 0 }\n}")
1534            .expect("`on`/`case` are valid parameter names");
1535
1536        // `suite` too, as a field name.
1537        parse_str("commons demo {\n  type R = { suite: Int }\n}")
1538            .expect("`suite` is a valid field name");
1539    }
1540
1541    /// Drift guard: every alphabetic keyword the lexer declares must be
1542    /// classified by `is_reserved_keyword`, or be one of the *contextual*
1543    /// keywords `expect_ident` deliberately admits as identifiers
1544    /// (`on`/`suite`/`case`). Everything else in this codebase that can
1545    /// drift has a guard; this predicate had silently fallen 17 keywords
1546    /// behind, degrading the reserved-keyword diagnostic to the generic
1547    /// expected-token one.
1548    #[test]
1549    fn is_reserved_keyword_covers_every_lexer_keyword() {
1550        let lexer_src = include_str!("lexer.rs");
1551        let mut words = Vec::new();
1552        for line in lexer_src.lines() {
1553            let t = line.trim();
1554            if let Some(rest) = t.strip_prefix("#[token(\"")
1555                && let Some(word) = rest.split('"').next()
1556                && word.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
1557                && word.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
1558            {
1559                words.push(word.to_string());
1560            }
1561        }
1562        assert!(
1563            words.len() > 30,
1564            "keyword extraction looks broken: only {} words",
1565            words.len()
1566        );
1567        // Contextual keywords double as identifiers (see `expect_ident`); the
1568        // tier is single-sourced in `keywords::RESERVED_CONTEXTUAL`.
1569        use crate::keywords::RESERVED_CONTEXTUAL;
1570        let mut unclassified = Vec::new();
1571        for word in &words {
1572            let tokens = crate::lexer::tokenize(word).expect("keyword lexes");
1573            let kind = tokens.first().expect("keyword yields a token").kind;
1574            if !is_reserved_keyword(kind) && !RESERVED_CONTEXTUAL.contains(&word.as_str()) {
1575                unclassified.push(word.clone());
1576            }
1577        }
1578        assert!(
1579            unclassified.is_empty(),
1580            "keywords missing from is_reserved_keyword (add them, or document \
1581             them as contextual): {unclassified:?}"
1582        );
1583    }
1584
1585    /// Fuzz-found (#516): a context-only keyword at item position in a
1586    /// commons errors without consuming the token, and the recovery sync
1587    /// stops at exactly that keyword — without a progress guard the item
1588    /// loop re-reported the same error until memory ran out.
1589    #[test]
1590    fn recovery_makes_progress_on_context_only_keyword_in_commons() {
1591        let src = "commons demo\n\ncapability Logger {\n  fn log(m: String) -> Effect[()]\n}\n";
1592        let tokens = crate::lexer::tokenize(src).unwrap();
1593        let (unit, errors) = parse_unit_with_recovery(&tokens, src);
1594        assert!(unit.is_some(), "the commons header still parses");
1595        assert!(
1596            errors
1597                .iter()
1598                .any(|e| e.category == "bynk.capability.outside_context"),
1599            "the misplaced capability is reported: {errors:?}"
1600        );
1601        // Termination is the real assertion (this used to OOM); a bounded,
1602        // non-repeating error list is the observable proxy.
1603        assert!(errors.len() < 10, "recovery repeated itself: {errors:?}");
1604    }
1605
1606    #[test]
1607    fn trailing_file_comment_becomes_unit_trailing() {
1608        // A comment after the last item but before EOF (fragment form)
1609        // becomes the commons body's trailing comments so the formatter
1610        // can preserve it.
1611        let src = "commons x\n\ntype T = Int where Positive\n-- afterword\n";
1612        let c = parse_str(src).unwrap();
1613        assert_eq!(c.trailing_comments, vec![" afterword".to_string()]);
1614    }
1615
1616    // ---- #636: `if`/`match` condition vs record construction ----
1617
1618    /// Parse `body` as the tail expression of a fn and return its kind.
1619    fn body_tail(body: &str) -> ExprKind {
1620        let src = format!("commons x\n\nfn f() -> Int {{\n  {body}\n}}\n");
1621        let c = parse_str(&src).unwrap_or_else(|e| panic!("parse failed for {body:?}: {e:?}"));
1622        let CommonsItem::Fn(f) = &c.items[0] else {
1623            panic!("expected fn, got {:?}", c.items[0]);
1624        };
1625        f.body.tail.kind.clone()
1626    }
1627
1628    fn body_err(body: &str) -> Vec<CompileError> {
1629        let src = format!("commons x\n\nfn f() -> Int {{\n  {body}\n}}\n");
1630        parse_str(&src).expect_err(&format!("expected a parse error for {body:?}"))
1631    }
1632
1633    #[test]
1634    fn if_condition_ending_in_ident_does_not_swallow_a_single_ident_branch() {
1635        // #636: `ready { result }` shares its shape with a shorthand-field
1636        // record construction. In condition position the branch must win.
1637        for src in [
1638            "if ready { result } else { fallback }",
1639            "if ready { fallback } else { result }",
1640            "if !ready { result } else { fallback }",
1641            "if a == b { result } else { fallback }",
1642            "if a && b { result } else { fallback }",
1643        ] {
1644            let ExprKind::If {
1645                then_block,
1646                else_block,
1647                ..
1648            } = body_tail(src)
1649            else {
1650                panic!("expected If for {src:?}, got {:?}", body_tail(src));
1651            };
1652            // Both branches carry a bare-identifier tail — proof the `{ … }`
1653            // was read as a block, not consumed as a record by the condition.
1654            assert!(
1655                matches!(&then_block.tail.kind, ExprKind::Ident(_)),
1656                "then-branch tail not an ident for {src:?}: {:?}",
1657                then_block.tail.kind,
1658            );
1659            assert!(
1660                matches!(&else_block.tail.kind, ExprKind::Ident(_)),
1661                "else-branch tail not an ident for {src:?}: {:?}",
1662                else_block.tail.kind,
1663            );
1664        }
1665    }
1666
1667    #[test]
1668    fn else_less_if_with_single_ident_branch_parses() {
1669        // The no-`else` reproduction: previously errored `found `}``.
1670        let ExprKind::If { then_block, .. } = body_tail("if ready { result }") else {
1671            panic!("expected If");
1672        };
1673        assert!(matches!(&then_block.tail.kind, ExprKind::Ident(_)));
1674    }
1675
1676    #[test]
1677    fn record_construction_still_parses_in_value_position() {
1678        // The restriction is confined to condition spines — an ordinary value
1679        // position still constructs records, including the shorthand tail form.
1680        assert!(matches!(
1681            body_tail("Point { x }"),
1682            ExprKind::RecordConstruction { .. }
1683        ));
1684        assert!(matches!(
1685            body_tail("Point { x: 1, y: 2 }"),
1686            ExprKind::RecordConstruction { .. }
1687        ));
1688        assert!(matches!(
1689            body_tail("Empty {}"),
1690            ExprKind::RecordConstruction { .. }
1691        ));
1692    }
1693
1694    #[test]
1695    fn parenthesised_record_is_allowed_in_condition_head() {
1696        // A delimiter lifts the restriction: `(ready { result })` constructs a
1697        // record even in condition position (mirrors Rust's paren escape).
1698        let ExprKind::If { cond, .. } =
1699            body_tail("if (ready { result }) { branch } else { other }")
1700        else {
1701            panic!("expected If");
1702        };
1703        let ExprKind::Paren(inner) = &cond.kind else {
1704            panic!("expected a parenthesised condition, got {:?}", cond.kind);
1705        };
1706        assert!(
1707            matches!(&inner.kind, ExprKind::RecordConstruction { .. }),
1708            "parenthesised record in condition head should still construct: {:?}",
1709            inner.kind,
1710        );
1711    }
1712
1713    #[test]
1714    fn record_in_call_arg_within_condition_still_constructs() {
1715        // The restriction is lifted through a call-argument delimiter, so a
1716        // record literal passed to a predicate in the condition still parses.
1717        let ExprKind::If { cond, .. } = body_tail("if check(Point { x: 1 }) { a } else { b }")
1718        else {
1719            panic!("expected If");
1720        };
1721        let ExprKind::Call { args, .. } = &cond.kind else {
1722            panic!("expected Call in condition, got {:?}", cond.kind);
1723        };
1724        assert!(matches!(&args[0].kind, ExprKind::RecordConstruction { .. }));
1725    }
1726
1727    #[test]
1728    fn safe_condition_shapes_are_unaffected() {
1729        // Cases the issue lists as already-safe must stay safe.
1730        assert!(matches!(
1731            body_tail("if ready == true { result } else { fallback }"),
1732            ExprKind::If { .. }
1733        ));
1734        assert!(matches!(
1735            body_tail("if (ready) { result } else { fallback }"),
1736            ExprKind::If { .. }
1737        ));
1738        assert!(matches!(
1739            body_tail("if ready { \"a\" } else { \"b\" }"),
1740            ExprKind::If { .. }
1741        ));
1742    }
1743
1744    #[test]
1745    fn empty_match_reports_its_own_diagnostic() {
1746        // #636: `match result {}` once parsed `result {}` as an empty record,
1747        // masking `bynk.parse.empty_match`. The intended diagnostic is now
1748        // reachable.
1749        let errs = body_err("match result {}");
1750        assert!(
1751            errs.iter().any(|e| e.category == "bynk.parse.empty_match"),
1752            "expected empty_match; got {errs:?}",
1753        );
1754    }
1755
1756    #[test]
1757    fn match_discriminant_ending_in_ident_parses() {
1758        // A `match` over a bare-identifier discriminant reaches its arm list.
1759        assert!(matches!(
1760            body_tail("match ready { x => x }"),
1761            ExprKind::Match { .. }
1762        ));
1763    }
1764
1765    #[test]
1766    fn unparenthesised_record_in_condition_head_now_errors() {
1767        // #636 narrowing (matches Rust): a record literal in condition *head*
1768        // position must be parenthesised. Unparenthesised, `Point` reads as the
1769        // discriminant and `{ x: 1 }` as the arm list, whose first "arm" `x: 1`
1770        // is not an arm — so the parse fails. Pinned so the divergence from the
1771        // (still-accepting) tree-sitter grammar is deliberate, not a bug.
1772        assert!(
1773            !body_err("match Point { x: 1 } { p => p }").is_empty(),
1774            "unparenthesised record discriminant should not parse",
1775        );
1776        // Parenthesised, the record is the discriminant and the match parses.
1777        let ExprKind::Match { discriminant, .. } = body_tail("match (Point { x: 1 }) { p => p }")
1778        else {
1779            panic!("expected Match for the parenthesised form");
1780        };
1781        let ExprKind::Paren(inner) = &discriminant.kind else {
1782            panic!(
1783                "expected a parenthesised discriminant, got {:?}",
1784                discriminant.kind
1785            );
1786        };
1787        assert!(matches!(&inner.kind, ExprKind::RecordConstruction { .. }));
1788    }
1789}