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    /// True when every entry has been drained via `take_leading`/
57    /// `take_trailing`/`take_epilogue` — i.e. no comment was silently
58    /// dropped. Each harvest is already a `mem::take`, so anything still
59    /// present here is exactly the set of comments that never reached an
60    /// AST `Trivia` field.
61    ///
62    /// Deliberately **not** wired into a `debug_assert!` in the general parse
63    /// path: expressions carry no per-node trivia (§ "Comment trivia" in the
64    /// 2026-07-27 pipeline review), so an ordinary, valid program with a
65    /// comment inside a `match`/list/record/binop — a common, accepted
66    /// pattern `bynk-fmt`'s own comment-loss guard already handles
67    /// gracefully — would leave `leading`/`trailing` non-empty and trip it on
68    /// every compile, not just on formatting. Instead surfaced through
69    /// [`parse_units_with_drain_check`] (finding #66), whose one caller
70    /// (`bynk-fmt`) *does* care about exactly this signal.
71    /// [`Self::epilogue_is_empty`] is the narrower, safe-to-assert check.
72    fn is_fully_drained(&self) -> bool {
73        self.leading.iter().all(Vec::is_empty)
74            && self.trailing.iter().all(Option::is_none)
75            && self.epilogue.is_empty()
76    }
77
78    /// True when no file-trailing comment was left stranded. Unlike
79    /// [`Self::is_fully_drained`], this is safe to assert unconditionally: a
80    /// clean file's epilogue is empty by construction (nothing pending at
81    /// EOF), and the one shape that legitimately populates it — a top-level
82    /// trailing comment — is drained by every parse path that calls
83    /// `take_epilogue`. A brace-form declaration that forgets to is exactly
84    /// the bug this catches.
85    fn epilogue_is_empty(&self) -> bool {
86        self.epilogue.is_empty()
87    }
88}
89
90/// Remove `Comment` trivia tokens from `tokens` and bin them into a
91/// [`TriviaTable`] keyed against the surviving content tokens. A comment
92/// on the same source line as the preceding content token is recorded as
93/// that token's *trailing* trivia; everything else is *leading* for the
94/// next content token.
95fn split_trivia(tokens: &[Token], source: &str) -> (Vec<Token>, TriviaTable) {
96    let mut filtered: Vec<Token> = Vec::with_capacity(tokens.len());
97    let mut table = TriviaTable::default();
98    let mut pending_leading: Vec<String> = Vec::new();
99    let mut last_content_end: Option<usize> = None;
100    for tok in tokens {
101        if tok.kind == TokenKind::Comment {
102            let body = comment_body(source, tok.span).to_string();
103            // If nothing has been buffered as leading for the next token and
104            // there is no newline between the previous content token and
105            // this comment, it trails that token.
106            if pending_leading.is_empty()
107                && let Some(prev_end) = last_content_end
108                && !source[prev_end..tok.span.start].contains('\n')
109            {
110                let last_idx = filtered.len() - 1;
111                // Only attach if no trailing already recorded (shouldn't
112                // happen because `--` consumes through end-of-line).
113                if table.trailing[last_idx].is_none() {
114                    table.trailing[last_idx] = Some(body);
115                    continue;
116                }
117            }
118            pending_leading.push(body);
119            continue;
120        }
121        filtered.push(*tok);
122        table.leading.push(std::mem::take(&mut pending_leading));
123        table.trailing.push(None);
124        last_content_end = Some(tok.span.end);
125    }
126    table.epilogue = pending_leading;
127    (filtered, table)
128}
129
130/// Parse a token slice into a [`Commons`] AST.
131///
132/// Accepts either form of v0.3 commons file:
133/// - Brace form: `commons name { items... }` (v0–v0.2 compatible).
134/// - Fragment form: `commons name uses... items...` to EOF (v0.3).
135pub fn parse(tokens: &[Token], source: &str) -> Result<Commons, Vec<CompileError>> {
136    parse_with_warnings(tokens, source).map(|(c, _warnings)| c)
137}
138
139/// [`parse`] with the non-fatal diagnostics threaded out alongside the AST
140/// (ADR 0117) — see [`parse_units_with_warnings`].
141pub fn parse_with_warnings(
142    tokens: &[Token],
143    source: &str,
144) -> Result<(Commons, Vec<CompileError>), Vec<CompileError>> {
145    let (unit, warnings) = parse_unit_with_warnings(tokens, source)?;
146    match unit {
147        SourceUnit::Commons(c) => Ok((c, warnings)),
148        SourceUnit::Context(ctx) => Err(vec![
149            CompileError::new(
150                "bynk.parse.unexpected_context",
151                ctx.span,
152                "expected a `commons` declaration but found a `context` declaration",
153            )
154            .with_note(
155                "contexts must be compiled as part of a project — pass the source directory, e.g. `bynkc compile --target bundle --output out src`",
156            ),
157        ]),
158        SourceUnit::Suite(t) => Err(vec![
159            CompileError::new(
160                "bynk.parse.unexpected_suite",
161                t.span,
162                "expected a `commons` declaration but found a `suite` declaration",
163            )
164            .with_note(
165                "tests must be compiled as part of a project — pass the source directory, e.g. `bynkc compile --target bundle --output out src`",
166            ),
167        ]),
168        SourceUnit::Adapter(a) => Err(vec![
169            CompileError::new(
170                "bynk.parse.unexpected_adapter",
171                a.span,
172                "expected a `commons` declaration but found an `adapter` declaration",
173            )
174            .with_note(
175                "adapters must be compiled as part of a project — pass the source directory, e.g. `bynkc compile --target bundle --output out src`",
176            ),
177        ]),
178    }
179}
180
181/// Parse a token slice into a [`SourceUnit`] with error recovery, returning a
182/// best-effort partial AST plus the full list of parse errors and warnings.
183///
184/// Used by the LSP: item-level recovery skips past a malformed declaration to
185/// the next top-level item, so multiple errors are reported per compilation
186/// rather than just the first. Compared to [`parse_unit`], this never bails;
187/// if no SourceUnit could be parsed at all (e.g. the file is empty or the
188/// header itself fails) the returned `Option` is `None`.
189///
190/// Keeps only the *first* unit — v0.113 allows more than one top-level unit
191/// per file (an atomic `commons` + `suite`, DECISION S), and every existing
192/// caller here is keyed on the primary declaration. [`parse_units_with_recovery`]
193/// is the same recovery parse without that narrowing, for the one caller
194/// (finding #29/#30) that needs every unit a file declares.
195pub fn parse_unit_with_recovery(
196    tokens: &[Token],
197    source: &str,
198) -> (Option<SourceUnit>, Vec<CompileError>) {
199    let (units, errors) = parse_units_with_recovery(tokens, source);
200    (units.into_iter().next(), errors)
201}
202
203/// [`parse_unit_with_recovery`], keeping **every** top-level unit instead of
204/// discarding all but the first (finding #29/#30). Used by the IDE's own parse
205/// entry point, which needs to see a trailing `suite` in an atomic
206/// `commons`+`suite` file, not just the primary declaration.
207pub fn parse_units_with_recovery(
208    tokens: &[Token],
209    source: &str,
210) -> (Vec<SourceUnit>, Vec<CompileError>) {
211    let (filtered, trivia) = split_trivia(tokens, source);
212    let mut warnings = Vec::new();
213    let mut p = Parser::new(&filtered, source, trivia, &mut warnings);
214    p.recover_mode = true;
215    let mut units = Vec::new();
216    loop {
217        match p.parse_unit() {
218            Ok(u) => units.push(u),
219            Err(e) => {
220                p.recovered_errors.push(e);
221                break;
222            }
223        }
224        // A genuinely malformed trailing declaration is still surfaced via
225        // recovery — checked *after* each successful parse, matching
226        // `parse_unit`'s own "at least once" attempt on the first unit (an
227        // empty file must still produce its usual unexpected-EOF diagnostic,
228        // not silently yield an empty `units` with no error at all).
229        if p.peek().is_none() {
230            break;
231        }
232    }
233    let mut all_errors = p.recovered_errors;
234    all_errors.append(&mut warnings);
235    (units, all_errors)
236}
237
238/// Parse a token slice into a [`SourceUnit`] — either a commons or a context.
239///
240/// Each `.bynk` file is exactly one declaration of one kind.
241pub fn parse_unit(tokens: &[Token], source: &str) -> Result<SourceUnit, Vec<CompileError>> {
242    parse_unit_with_warnings(tokens, source).map(|(unit, _warnings)| unit)
243}
244
245/// [`parse_unit`] with the non-fatal diagnostics threaded out alongside the
246/// AST (ADR 0117) — see [`parse_units_with_warnings`].
247pub fn parse_unit_with_warnings(
248    tokens: &[Token],
249    source: &str,
250) -> Result<(SourceUnit, Vec<CompileError>), Vec<CompileError>> {
251    parse_unit_with_warnings_from(tokens, source, &mut 0)
252}
253
254/// [`parse_unit_with_warnings`], continuing [`ExprId`] allocation from
255/// `next_id` instead of starting at 0, and writing the id one past the last
256/// one this parse handed out back into it. T3.4 (R2.4): every top-level parse
257/// entry point in this file constructs its own `Parser` and therefore its own
258/// zero-based id space; a caller that will check two files' output together
259/// in one pass (a multi-file commons — `bynk-emit`'s `collect_unit_methods`
260/// merges a type's methods from sibling files into the file that declares the
261/// type, before one `check_record` call) must thread one counter across every
262/// file it parses, or two independently-numbered files collide on the same
263/// id in the same `expr_types` map. Every *other* caller (a single buffer, an
264/// LSP hover/completion query, a fixture test) never merges its output with
265/// another file's before checking, so starting at 0 every time is correct —
266/// [`parse_unit_with_warnings`] above is that default, unchanged.
267pub fn parse_unit_with_warnings_from(
268    tokens: &[Token],
269    source: &str,
270    next_id: &mut u32,
271) -> Result<(SourceUnit, Vec<CompileError>), Vec<CompileError>> {
272    let (filtered, trivia) = split_trivia(tokens, source);
273    let mut warnings = Vec::new();
274    let mut p = Parser::new(&filtered, source, trivia, &mut warnings);
275    p.next_expr_id = *next_id;
276    let result = match p.parse_unit() {
277        Ok(u) => {
278            if let Some(extra) = p.peek() {
279                Err(vec![
280                    CompileError::new(
281                        "bynk.parse.extra_tokens",
282                        extra.span,
283                        "unexpected token after top-level declaration",
284                    )
285                    .with_note(
286                        "a `.bynk` file contains exactly one `commons` or `context` declaration",
287                    ),
288                ])
289            } else {
290                Ok(u)
291            }
292        }
293        Err(e) => Err(vec![e]),
294    };
295    *next_id = p.next_expr_id;
296    // ADR 0117: warnings (e.g. orphan doc blocks) ride alongside a successful
297    // parse — severity governs gating at the caller, not here.
298    match result {
299        Ok(u) => {
300            // See `parse_units_with_warnings`: a file-trailing comment must
301            // have been drained by `take_epilogue`.
302            debug_assert!(
303                p.trivia.epilogue_is_empty(),
304                "a file-trailing comment was left undrained after a successful parse"
305            );
306            Ok((u, warnings))
307        }
308        Err(mut errs) => {
309            errs.append(&mut warnings);
310            Err(errs)
311        }
312    }
313}
314
315/// Parse a token slice into **all** the top-level [`SourceUnit`]s in one file
316/// (v0.113, testing track slice 1b). A `.bynk` file may hold more than one
317/// top-level declaration — an *atomic* file with `commons`/`context` **and** a
318/// `suite` together (DECISION S) — so the compiler parses a `Vec`, not a single
319/// unit. Test-ness is a property of each declaration, not of the file.
320///
321/// Bails on the first malformed declaration (like [`parse_unit`], not the
322/// recovering LSP path). An empty file is an error.
323pub fn parse_units(tokens: &[Token], source: &str) -> Result<Vec<SourceUnit>, Vec<CompileError>> {
324    parse_units_with_warnings(tokens, source).map(|(units, _warnings)| units)
325}
326
327/// [`parse_units`] with the non-fatal diagnostics threaded out alongside the
328/// AST (ADR 0117): a successful parse returns `Ok((units, warnings))` instead
329/// of hard-failing on a warning-severity diagnostic (an orphan doc block used
330/// to abort file discovery and throw the good AST away). A failed parse still
331/// returns every diagnostic — errors then warnings — in the `Err`.
332pub fn parse_units_with_warnings(
333    tokens: &[Token],
334    source: &str,
335) -> Result<(Vec<SourceUnit>, Vec<CompileError>), Vec<CompileError>> {
336    parse_units_with_drain_check(tokens, source)
337        .map(|(units, warnings, _drained)| (units, warnings))
338}
339
340/// [`parse_units_with_warnings`], continuing [`ExprId`] allocation from
341/// `next_id` rather than starting at 0 — see [`parse_unit_with_warnings_from`]
342/// for why this exists. The one production caller is `bynk-emit`'s per-file
343/// parse loop (`phase_parse`), which owns one counter across every file in a
344/// single project parse so two files whose methods later get merged into one
345/// `check_record` call (a multi-file commons) never collide.
346pub fn parse_units_with_warnings_from(
347    tokens: &[Token],
348    source: &str,
349    next_id: &mut u32,
350) -> Result<(Vec<SourceUnit>, Vec<CompileError>), Vec<CompileError>> {
351    parse_units_with_drain_check_from(tokens, source, next_id)
352        .map(|(units, warnings, _drained)| (units, warnings))
353}
354
355/// [`parse_units_with_warnings`] plus whether every comment's trivia was
356/// drained into the AST (`TriviaTable::is_fully_drained`). Finding #66:
357/// `bynk-fmt`'s comment-preservation guard re-tokenized its own rendered
358/// output just to diff comment bodies against the input — wasted work in the
359/// overwhelming common case where nothing was left behind. That guard uses
360/// this drain signal, computed from the same parse it already needs for
361/// rendering, as a fast-path: `true` means every comment landed in the AST,
362/// so re-checking the output can be skipped outright. No other caller needs
363/// the signal, so it rides its own entry point rather than widening
364/// [`parse_units_with_warnings`].
365pub fn parse_units_with_drain_check(
366    tokens: &[Token],
367    source: &str,
368) -> Result<(Vec<SourceUnit>, Vec<CompileError>, bool), Vec<CompileError>> {
369    parse_units_with_drain_check_from(tokens, source, &mut 0)
370}
371
372/// [`parse_units_with_drain_check`], continuing [`ExprId`] allocation from
373/// `next_id` — see [`parse_unit_with_warnings_from`].
374pub fn parse_units_with_drain_check_from(
375    tokens: &[Token],
376    source: &str,
377    next_id: &mut u32,
378) -> Result<(Vec<SourceUnit>, Vec<CompileError>, bool), Vec<CompileError>> {
379    let (filtered, trivia) = split_trivia(tokens, source);
380    let mut warnings = Vec::new();
381    let mut p = Parser::new(&filtered, source, trivia, &mut warnings);
382    p.next_expr_id = *next_id;
383    let mut units = Vec::new();
384    let mut errors: Vec<CompileError> = Vec::new();
385    while p.peek().is_some() {
386        match p.parse_unit() {
387            Ok(u) => units.push(u),
388            Err(e) => {
389                errors.push(e);
390                break;
391            }
392        }
393    }
394    *next_id = p.next_expr_id;
395    let eof = p.eof_span();
396    let fully_drained = p.trivia.is_fully_drained();
397    // `p` (and thus its `&mut warnings` borrow) is no longer used past here, so
398    // the local `warnings` are readable again.
399    if !errors.is_empty() {
400        errors.append(&mut warnings);
401        return Err(errors);
402    }
403    if units.is_empty() {
404        return Err(vec![CompileError::new(
405            "bynk.parse.unexpected_eof",
406            eof,
407            "expected `commons`, `context`, or `suite` to start the file, found end of file",
408        )]);
409    }
410    // A file-trailing comment must have been drained by `take_epilogue` — the
411    // one brace-form declarations forgot to call (a live comment-loss bug,
412    // not the fundamentally-unfixed expression-interior case: expressions
413    // carry no trivia at all, so asserting full drainage here would fire on
414    // any ordinary program with a comment inside a `match`/list/record, which
415    // `bynk-fmt`'s own comment-loss guard already handles gracefully rather
416    // than as a hard failure).
417    debug_assert!(
418        p.trivia.epilogue_is_empty(),
419        "a file-trailing comment was left undrained after a successful parse"
420    );
421    Ok((units, warnings, fully_drained))
422}
423
424/// A signed numeric literal in refinement-bound position (v0.21): `InRange`
425/// bounds are either both `Int` or both `Float`.
426enum SignedNumLit {
427    Int(IntBound),
428    Float(FloatBound),
429}
430
431struct Parser<'a> {
432    tokens: &'a [Token],
433    source: &'a str,
434    pos: usize,
435    /// Accumulated non-fatal diagnostics. v0.3 uses this for orphan-doc
436    /// warnings, which are emitted as errors with a distinguishable category.
437    warnings: &'a mut Vec<CompileError>,
438    /// When true, the item-level loops catch errors from individual item
439    /// parses, push them into `recovered_errors`, and skip forward to the
440    /// next top-level item boundary instead of bailing. Used by the LSP via
441    /// [`parse_unit_with_recovery`]; disabled in the normal `parse` path so
442    /// existing single-error behaviour is preserved.
443    recover_mode: bool,
444    /// Errors collected during recovery-mode parsing. Only populated when
445    /// `recover_mode` is true.
446    recovered_errors: Vec<CompileError>,
447    /// Line-comment trivia separated from the token stream. See
448    /// [`TriviaTable`].
449    trivia: TriviaTable,
450    /// Live recursion depth of the three self-recursive parse entry points
451    /// (`parse_expr`, `parse_type_ref`, `parse_pattern`). Incremented on entry
452    /// and decremented on exit by [`Parser::enter_recursion`] so it tracks the
453    /// current stack depth; when it exceeds [`crate::MAX_NESTING_DEPTH`] the
454    /// parser reports a bounded-depth diagnostic instead of overflowing its
455    /// stack (#713).
456    depth: usize,
457    /// When true, a bare `ident {` on the *spine* of the current expression is
458    /// an identifier followed by an unrelated block, never a record
459    /// construction — so an `if`/`match` condition that ends in a bare
460    /// identifier does not swallow the branch/arm block as `Ident { field }`
461    /// (#636). Set only around the condition parse (see [`parse_cond_expr`]);
462    /// `parse_expr` clears it, so the restriction is lifted inside any
463    /// delimited sub-expression (parentheses, call arguments, list, record
464    /// field). Mirrors Rust's `NO_STRUCT_LITERAL` restriction.
465    no_record_literal: bool,
466    /// Running count of unclosed `{` seen so far — maintained solely by
467    /// [`Self::bump`] (the one primitive that advances `self.pos`), so it
468    /// always reflects the true nesting depth no matter which parse function
469    /// is on the call stack. Finding #27/#30: `recover_to_top_item` reads it
470    /// against [`Self::item_loop_baseline`] to tell "the enclosing item
471    /// loop's own closing brace" apart from a still-unclosed nested
472    /// construct's — without it, a sync scan that started partway through
473    /// such a construct (an error deep inside a function body) stopped at the
474    /// first `}` it saw, however deeply nested, and the enclosing item loop
475    /// mistook that for its own body's end.
476    brace_depth: usize,
477    /// Stack of `brace_depth` snapshots, one per active item-loop body
478    /// (commons/context/adapter/suite) — pushed right after that body's own
479    /// `{` is consumed (or at loop entry, for a brace-free fragment form),
480    /// popped at the loop's normal exit. `recover_to_top_item` treats its top
481    /// entry as the depth an `}` must return to before it counts as the
482    /// enclosing body's own closing brace rather than a nested construct's.
483    item_loop_baseline: Vec<usize>,
484    /// T3.4 (R2.4): next [`ExprId`] to hand out — monotonic, incremented by
485    /// [`Self::alloc_expr_id`], the sole allocation point every `Expr`
486    /// construction site in this parser calls.
487    next_expr_id: u32,
488}
489
490impl<'a> Parser<'a> {
491    fn new(
492        tokens: &'a [Token],
493        source: &'a str,
494        trivia: TriviaTable,
495        warnings: &'a mut Vec<CompileError>,
496    ) -> Self {
497        Self {
498            tokens,
499            source,
500            pos: 0,
501            warnings,
502            recover_mode: false,
503            recovered_errors: Vec::new(),
504            trivia,
505            depth: 0,
506            no_record_literal: false,
507            brace_depth: 0,
508            item_loop_baseline: Vec::new(),
509            next_expr_id: 0,
510        }
511    }
512
513    /// T3.4 (R2.4): allocate the next [`ExprId`]. The sole allocation point —
514    /// every `Expr { id: self.alloc_expr_id(), .. }` construction in this
515    /// parser calls it exactly once, so two nodes never share an id and every
516    /// id a caller holds was actually handed out here.
517    fn alloc_expr_id(&mut self) -> ExprId {
518        let id = ExprId(self.next_expr_id);
519        self.next_expr_id += 1;
520        id
521    }
522
523    /// Enter a self-recursive parse step, bumping the live recursion depth and
524    /// failing with a bounded-depth diagnostic if it would exceed
525    /// [`crate::MAX_NESTING_DEPTH`]. The caller pairs a successful entry with a
526    /// matching `self.depth -= 1` on the way out (see `parse_expr` /
527    /// `parse_type_ref`); on the error path the depth is restored here so a
528    /// recovering caller is not left mis-counted. `what` names the construct
529    /// for the message (e.g. "this expression", "this type"). See #713.
530    fn enter_recursion(&mut self, what: &str) -> Result<(), CompileError> {
531        self.depth += 1;
532        if self.depth > crate::MAX_NESTING_DEPTH {
533            self.depth -= 1;
534            let span = self
535                .peek()
536                .map(|t| t.span)
537                .unwrap_or_else(|| self.eof_span());
538            return Err(self.nesting_too_deep(span, what));
539        }
540        Ok(())
541    }
542
543    /// The bounded-depth diagnostic shared by [`enter_recursion`] and
544    /// [`enter_chain_fold`].
545    fn nesting_too_deep(&self, span: Span, what: &str) -> CompileError {
546        CompileError::new(
547            "bynk.parse.nesting_too_deep",
548            span,
549            format!(
550                "{what} nests more than {} levels deep",
551                crate::MAX_NESTING_DEPTH
552            ),
553        )
554        .with_note(
555            "deeply nested source is rejected to keep the parser from overflowing its \
556             stack and aborting; flatten or split the construct",
557        )
558    }
559
560    /// The bounded-depth diagnostic for the *iteratively*-built spines —
561    /// associative operator chains ([`enter_chain_fold`]) and postfix receiver
562    /// chains ([`deepen_spine`]). Same code as [`nesting_too_deep`] (one budget,
563    /// one diagnostic) but phrased for a flat chain, which is long rather than
564    /// *nested*, and points at the idiomatic fix.
565    fn expression_too_long(&self, span: Span) -> CompileError {
566        CompileError::new(
567            "bynk.parse.nesting_too_deep",
568            span,
569            format!(
570                "this expression is more than {} levels deep",
571                crate::MAX_NESTING_DEPTH
572            ),
573        )
574        .with_note(
575            "a long operator or member chain is rejected to keep the compiler from overflowing \
576             its stack; split it across `let` bindings, or reduce a sequence with \
577             `.sum()`/`.fold(...)`",
578        )
579    }
580
581    /// Count one more operand folded onto an associative operator chain against
582    /// the same recursion budget as [`enter_recursion`] (#714).
583    ///
584    /// Associative chains (`+`, `*`, `&&`, `||`) are built *iteratively* in the
585    /// precedence ladder, so — unlike parentheses, calls, or `implies` — they
586    /// never re-enter `parse_expr` and thus slip past the `enter_recursion`
587    /// guard. Yet each fold deepens the left-nested `Expr` tree by one level,
588    /// and a long flat chain (`1 + 1 + … + 1`) overflows every *recursive*
589    /// consumer of that tree downstream — the checker's `type_of`, the
590    /// formatter, the emitter, and the AST's own recursive `Drop` — exactly as
591    /// deeply nested source overflows the parser. Counting each fold on the
592    /// shared `depth` budget bounds the whole expression's height, and because
593    /// it is the *same* budget it composes with the ambient nesting depth, so a
594    /// chain buried inside deeply nested source cannot exceed the bound either.
595    ///
596    /// The caller accumulates `folds` and subtracts them from `depth` before it
597    /// returns, so the live count unwinds as a recursive descent would; on the
598    /// overflow path the whole chain's contribution is restored here so a
599    /// recovering caller is not left mis-counted.
600    fn enter_chain_fold(&mut self, folds: &mut usize, span: Span) -> Result<(), CompileError> {
601        self.depth += 1;
602        *folds += 1;
603        if self.depth > crate::MAX_NESTING_DEPTH {
604            self.depth -= *folds;
605            *folds = 0;
606            return Err(self.expression_too_long(span));
607        }
608        Ok(())
609    }
610
611    /// Count one more level of an iteratively-built postfix receiver spine
612    /// (`a.b.c…`, `f()?.g()…`) against the shared budget (#714). Like
613    /// [`enter_chain_fold`], postfix loops rather than recurses, so a long spine
614    /// escapes [`enter_recursion`] yet grows an arbitrarily deep receiver tree
615    /// that the downstream walks recurse through. `parse_postfix` restores
616    /// `depth` wholesale on the way out (its many error paths make a
617    /// save/restore wrapper cleaner than per-fold unwinding), so this only bumps
618    /// and checks.
619    fn deepen_spine(&mut self, span: Span) -> Result<(), CompileError> {
620        self.depth += 1;
621        if self.depth > crate::MAX_NESTING_DEPTH {
622            return Err(self.expression_too_long(span));
623        }
624        Ok(())
625    }
626
627    /// Comments immediately preceding the current peek position. Consumed
628    /// (the table entry is cleared) so the same comments are not attached
629    /// to two nodes.
630    fn take_leading_trivia(&mut self) -> Vec<String> {
631        self.trivia.take_leading(self.pos)
632    }
633
634    /// Trailing comment, if any, on the same source line as the most
635    /// recently consumed content token. Call AFTER finishing a declaration
636    /// or statement, while `self.pos` points one past its last token.
637    fn take_trailing_trivia(&mut self) -> Option<String> {
638        if self.pos == 0 {
639            return None;
640        }
641        self.trivia.take_trailing(self.pos - 1)
642    }
643
644    /// Handle a per-item parse error. In recovery mode, record the error and
645    /// advance to the next sync point so the item loop can continue; otherwise
646    /// propagate as a hard failure.
647    fn handle_item_err(&mut self, e: CompileError) -> Result<(), CompileError> {
648        if self.recover_mode {
649            self.recovered_errors.push(e);
650            let before = self.pos;
651            self.recover_to_top_item();
652            // The sync target may be the very token that produced the error —
653            // a context-only keyword (`capability`, `service`, …) at item
654            // position in a commons errors *without consuming it*, and it is
655            // itself a sync point. Recovery must always make progress, or the
656            // item loop re-reports the same error until memory runs out
657            // (found by the `parse` fuzz target on a seed input).
658            if self.pos == before {
659                self.bump();
660            }
661            Ok(())
662        } else {
663            Err(e)
664        }
665    }
666
667    /// Skip forward to the next top-level item boundary: either an
668    /// [`is_item_start`] keyword at the enclosing item loop's own nesting
669    /// depth, a closing brace that returns to that depth, or end-of-input.
670    /// Used only in recovery mode.
671    ///
672    /// Finding #27/#30: brace-depth-gated against
673    /// [`Self::item_loop_baseline`], so a `}` deep inside a still-unclosed
674    /// nested construct (an error partway through a function body, itself
675    /// inside a `match` arm) is skipped over rather than mistaken for the
676    /// enclosing body's own closing brace — the old flat scan stopped at
677    /// literally the first `}` it saw, however deep, handing the item loop a
678    /// brace that did not belong to it and making it return with zero items.
679    fn recover_to_top_item(&mut self) {
680        let baseline = self.item_loop_baseline.last().copied().unwrap_or(0);
681        while let Some(t) = self.peek() {
682            match t.kind {
683                TokenKind::RBrace if self.brace_depth == baseline => return,
684                _ if self.brace_depth == baseline && is_item_start(t.kind) => return,
685                _ => {
686                    self.bump();
687                }
688            }
689        }
690    }
691
692    /// Mark the start of a top-level item loop (`declarations.rs`'s
693    /// `parse_commons_brace`/`_fragment`, `parse_context_brace`/`_fragment`,
694    /// `parse_test_brace`/`_fragment`, `parse_adapter_body`) — called right
695    /// after that body's own `{` is consumed (brace form) or at the loop's
696    /// own entry (fragment form, which has no enclosing brace of its own).
697    /// Paired with [`Self::exit_item_loop`] at the loop's normal exit.
698    fn enter_item_loop(&mut self) {
699        self.item_loop_baseline.push(self.brace_depth);
700    }
701
702    /// Pair of [`Self::enter_item_loop`].
703    fn exit_item_loop(&mut self) {
704        self.item_loop_baseline.pop();
705    }
706
707    fn peek(&self) -> Option<Token> {
708        self.tokens.get(self.pos).copied()
709    }
710
711    fn peek_kind(&self) -> Option<TokenKind> {
712        self.peek().map(|t| t.kind)
713    }
714
715    /// The token `n` positions ahead of the cursor (`nth(0)` == `peek()`).
716    fn nth(&self, n: usize) -> Option<Token> {
717        self.tokens.get(self.pos + n).copied()
718    }
719
720    fn nth_kind(&self, n: usize) -> Option<TokenKind> {
721        self.nth(n).map(|t| t.kind)
722    }
723
724    /// The source text of the token `n` positions ahead, or `""` if none.
725    fn nth_text(&self, n: usize) -> &'a str {
726        self.nth(n).map(|t| self.slice(t.span)).unwrap_or("")
727    }
728
729    /// The span of the most recently consumed token (`self.pos - 1`). Falls back
730    /// to the current token's span when nothing has been consumed yet.
731    fn prev_span(&self) -> Span {
732        self.tokens
733            .get(self.pos.wrapping_sub(1))
734            .or_else(|| self.peek_ref())
735            .map(|t| t.span)
736            .unwrap_or_default()
737    }
738
739    fn peek_ref(&self) -> Option<&Token> {
740        self.tokens.get(self.pos)
741    }
742
743    fn bump(&mut self) -> Option<Token> {
744        let t = self.peek();
745        if let Some(t) = t {
746            match t.kind {
747                TokenKind::LBrace => self.brace_depth += 1,
748                TokenKind::RBrace => self.brace_depth = self.brace_depth.saturating_sub(1),
749                _ => {}
750            }
751            self.pos += 1;
752        }
753        t
754    }
755
756    fn eat(&mut self, kind: TokenKind) -> Option<Token> {
757        if self.peek_kind() == Some(kind) {
758            self.bump()
759        } else {
760            None
761        }
762    }
763
764    fn slice(&self, span: Span) -> &'a str {
765        &self.source[span.range()]
766    }
767
768    /// True when the next token sits on a later line than `prev`. Used to
769    /// keep a `[` that opens a new line out of the postfix type-application
770    /// form: `f` followed by `[1, 2]` on the next line is an identifier and
771    /// a list literal, not `f[…]` (v0.20b).
772    fn next_token_on_new_line(&self, prev: Span) -> bool {
773        match self.peek() {
774            Some(t) if prev.end <= t.span.start => {
775                self.source[prev.end..t.span.start].contains('\n')
776            }
777            _ => false,
778        }
779    }
780
781    /// Span pointing at the end of input — used for "unexpected EOF" reports.
782    /// The start backs up to the **start of the final char**, not `len - 1`, so
783    /// the span never splits a multibyte codepoint (an unterminated construct
784    /// whose last line ends in non-ASCII — e.g. a `--` comment ending in `→`).
785    fn eof_span(&self) -> Span {
786        let end = self.source.len();
787        let start = (0..end)
788            .rev()
789            .find(|&i| self.source.is_char_boundary(i))
790            .unwrap_or(0);
791        Span::new(start, end)
792    }
793
794    fn expect(&mut self, kind: TokenKind, ctx: &str) -> Result<Token, CompileError> {
795        match self.peek() {
796            Some(t) if t.kind == kind => {
797                self.bump();
798                Ok(t)
799            }
800            Some(t) => Err(CompileError::new(
801                "bynk.parse.expected_token",
802                t.span,
803                format!(
804                    "expected {} {ctx}, found {}",
805                    kind.describe(),
806                    t.kind.describe()
807                ),
808            )),
809            None => Err(CompileError::new(
810                "bynk.parse.unexpected_eof",
811                self.eof_span(),
812                format!("expected {} {ctx}, found end of file", kind.describe()),
813            )),
814        }
815    }
816
817    fn expect_ident(&mut self, ctx: &str) -> Result<Ident, CompileError> {
818        match self.peek() {
819            Some(t) if t.kind == TokenKind::Ident => {
820                self.bump();
821                Ok(Ident {
822                    name: self.slice(t.span).to_string(),
823                    span: t.span,
824                })
825            }
826            // v0.5 contextual keyword `on` doubles as an identifier in
827            // expression / field-access positions so users can name fields and
828            // parameters using it. It retains its keyword meaning only at
829            // handler-decl-level (`on call(...)`).
830            //
831            // v0.7 / v0.112: `suite` and `case` are contextual too — they
832            // introduce the suite declaration and its cases, but are perfectly
833            // valid commons/context/field names otherwise.
834            //
835            // The tier is single-sourced in `keywords::RESERVED_CONTEXTUAL`:
836            // this arm defers to it rather than hardcoding the token kinds, so
837            // extending that list is enough to admit a new contextual keyword
838            // here. Each of these words lexes only to its own token, so matching
839            // the source text is equivalent to matching the kind.
840            Some(t) if crate::keywords::is_reserved_contextual(self.slice(t.span)) => {
841                self.bump();
842                Ok(Ident {
843                    name: self.slice(t.span).to_string(),
844                    span: t.span,
845                })
846            }
847            Some(t) if is_reserved_keyword(t.kind) => Err(CompileError::new(
848                "bynk.parse.reserved_keyword",
849                t.span,
850                format!(
851                    "expected identifier {ctx}, but `{}` is a reserved keyword",
852                    self.slice(t.span)
853                ),
854            )
855            .with_note("rename the identifier to something that is not a keyword")),
856            Some(t) => Err(CompileError::new(
857                "bynk.parse.expected_token",
858                t.span,
859                format!("expected identifier {ctx}, found {}", t.kind.describe()),
860            )),
861            None => Err(CompileError::new(
862                "bynk.parse.unexpected_eof",
863                self.eof_span(),
864                format!("expected identifier {ctx}, found end of file"),
865            )),
866        }
867    }
868
869    // -- top level --
870
871    /// Consume an optional doc block at the current position, returning the
872    /// (content, end-of-doc span) pair. Returns None if the next token is not
873    /// a doc block.
874    fn take_doc_block(&mut self) -> Option<(String, Span)> {
875        if self.peek_kind() == Some(TokenKind::DocBlock) {
876            let t = self.bump().unwrap();
877            let body = doc_block_content(self.source, t.span);
878            return Some((body, t.span));
879        }
880        None
881    }
882
883    /// Collect all line-comment trivia leading the next declaration plus
884    /// the optional doc block. Comments may appear both *before* and
885    /// *between* the doc and the declaration; the spec canonicalises both
886    /// groups above the doc, so we concatenate them.
887    fn collect_item_lead(&mut self) -> (Vec<String>, Option<(String, Span)>) {
888        let mut leading = self.take_leading_trivia();
889        let doc = self.take_doc_block();
890        if doc.is_some() {
891            leading.extend(self.take_leading_trivia());
892        }
893        (leading, doc)
894    }
895
896    /// Attach a parsed doc block to a following declaration unless a blank
897    /// line separates them, in which case the doc is orphaned (warning).
898    fn finalize_doc(&mut self, doc: Option<(String, Span)>, next_span: Span) -> Option<String> {
899        let (content, doc_span) = doc?;
900        // A blank line between the doc and the next decl orphans the doc.
901        if has_blank_line_between(self.source, doc_span.end, next_span.start) {
902            self.warnings.push(
903                CompileError::new(
904                    "bynk.parse.orphan_doc_block",
905                    doc_span,
906                    "documentation block is separated from the following declaration by a blank line; it will not be attached",
907                )
908                .with_note(
909                    "remove the blank line to attach the doc to the next declaration, \
910                     or remove the doc block if it is not meant to document anything",
911                ),
912            );
913            return None;
914        }
915        Some(content)
916    }
917}
918
919/// Parse the body of a lexed double-quoted string literal (the lexeme,
920/// including surrounding quotes), applying the v0 escape rules.
921fn parse_string_literal(lexeme: &str, span: Span) -> Result<String, CompileError> {
922    let bytes = lexeme.as_bytes();
923    debug_assert!(bytes.first() == Some(&b'"') && bytes.last() == Some(&b'"'));
924    let inner = &lexeme[1..lexeme.len() - 1];
925    let mut out = String::with_capacity(inner.len());
926    let mut chars = inner.chars();
927    while let Some(c) = chars.next() {
928        if c == '\\' {
929            match chars.next() {
930                Some('n') => out.push('\n'),
931                Some('t') => out.push('\t'),
932                Some('"') => out.push('"'),
933                Some('\\') => out.push('\\'),
934                other => {
935                    return Err(CompileError::new(
936                        "bynk.lex.bad_escape",
937                        span,
938                        format!(
939                            "invalid escape sequence `\\{}` in string literal",
940                            other.map(|c| c.to_string()).unwrap_or_default()
941                        ),
942                    )
943                    .with_note("supported escapes: \\n \\t \\\" \\\\"));
944                }
945            }
946        } else {
947            out.push(c);
948        }
949    }
950    Ok(out)
951}
952
953fn is_reserved_keyword(kind: TokenKind) -> bool {
954    use TokenKind::*;
955    matches!(
956        kind,
957        Commons
958            | Type
959            | Fn
960            | Where
961            | True
962            | False
963            | Int
964            | String
965            | Bool
966            | Let
967            | If
968            | Else
969            | Ok
970            | Err
971            | Result
972            | ValidationError
973            | Enum
974            | Match
975            | Option
976            | Record
977            | Self_
978            | Some
979            | None
980            | Is
981            | Opaque
982            | Uses
983            | Context
984            | Consumes
985            | Exports
986            | Transparent
987            | Agent
988            | As
989            | Capability
990            | Effect
991            | Do
992            | Given
993            | On
994            | Http
995            | Provides
996            | Stub
997            | Service
998            | Actor
999            | By
1000            | Expect
1001            | Suite
1002            | Case
1003            | Float
1004            | Duration
1005            | Instant
1006            | Bytes
1007            | JsonError
1008            | Property
1009            | Adapter
1010            | Binding
1011            | Cron
1012            | Queue
1013            | From
1014            | Protocol
1015            | Invariant
1016            | Implies
1017            | Requires
1018            | Ensures
1019            | Transition
1020    )
1021}
1022
1023/// True when `kind` starts a top-level unit (`commons`/`context`/`adapter`/
1024/// `suite`) or an item within one of their bodies — every keyword any of
1025/// `parse_commons_brace`/`_fragment`, `parse_context_brace`/`_fragment`,
1026/// `parse_test_brace`/`_fragment`, or `parse_adapter_body` dispatches on
1027/// (`declarations.rs`). The single set [`Parser::recover_to_top_item`]'s sync
1028/// scan checks against — finding #27/#30: that scan had drifted from what the
1029/// item loops actually recognise (`Property`, `Actor`, `Event`, `Binding`,
1030/// and the `adapter` unit keyword itself were all missing), so an error
1031/// recovery sync could walk past a real item/unit boundary instead of
1032/// stopping there.
1033fn is_item_start(kind: TokenKind) -> bool {
1034    use TokenKind::*;
1035    matches!(
1036        kind,
1037        // Top-level unit keywords.
1038        Commons | Context | Adapter | Suite
1039        // Body items shared across commons/context/adapter.
1040        | Type | Fn | Messages | Event | Uses
1041        // Context/adapter-only body items.
1042        | Consumes | Exports | Capability | Provides | Service | Agent | Actor
1043        // Adapter-only.
1044        | Binding
1045        // Suite/test-only body items.
1046        | Stub | Case | Property
1047    )
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052    use super::*;
1053    use crate::lexer::tokenize;
1054
1055    fn parse_str(src: &str) -> Result<Commons, Vec<CompileError>> {
1056        let toks = tokenize(src).map_err(|e| vec![e])?;
1057        parse(&toks, src)
1058    }
1059
1060    fn parse_recover_str(src: &str) -> (Option<SourceUnit>, Vec<CompileError>) {
1061        let toks = match tokenize(src) {
1062            Ok(t) => t,
1063            Err(e) => return (None, vec![e]),
1064        };
1065        parse_unit_with_recovery(&toks, src)
1066    }
1067
1068    /// Finding #29/#30: `parse_units_with_recovery` keeps every top-level unit
1069    /// an atomic `commons`+`suite` file declares (v0.113, DECISION S), where
1070    /// `parse_unit_with_recovery` keeps only the first — the defect the IDE's
1071    /// old single-unit parse entry point had (it silently discarded the
1072    /// trailing `suite`).
1073    #[test]
1074    fn parse_units_with_recovery_keeps_every_top_level_unit() {
1075        let src =
1076            "commons m {\n  fn f() -> Int { 1 }\n}\n\nsuite m\n\ncase \"c\" {\n  expect true\n}\n";
1077        let toks = tokenize(src).unwrap();
1078        let (units, errors) = parse_units_with_recovery(&toks, src);
1079        assert!(errors.is_empty(), "{errors:?}");
1080        assert_eq!(units.len(), 2, "expected both units, got {units:?}");
1081        assert!(matches!(units[0], SourceUnit::Commons(_)));
1082        assert!(matches!(units[1], SourceUnit::Suite(_)));
1083
1084        // The singular wrapper still narrows to just the first, unchanged.
1085        let (unit, errors) = parse_unit_with_recovery(&toks, src);
1086        assert!(errors.is_empty(), "{errors:?}");
1087        assert!(matches!(unit, Some(SourceUnit::Commons(_))));
1088    }
1089
1090    /// `parse_unit_with_recovery` always attempts at least one parse, even on
1091    /// empty input — `parse_units_with_recovery`'s loop must preserve that
1092    /// (its `while`-style peek check alone would skip the body entirely and
1093    /// silently return no error), since 16+ existing callers rely on an empty
1094    /// file still producing its usual diagnostic rather than a silent `None`
1095    /// with no error at all.
1096    #[test]
1097    fn empty_input_still_reports_an_error_through_the_plural_entry_point() {
1098        let toks = tokenize("").unwrap();
1099        let (units, errors) = parse_units_with_recovery(&toks, "");
1100        assert!(units.is_empty());
1101        assert!(
1102            !errors.is_empty(),
1103            "an empty file must still produce a diagnostic, not silently no units and no error"
1104        );
1105
1106        let (unit, unit_errors) = parse_unit_with_recovery(&toks, "");
1107        assert!(unit.is_none());
1108        assert_eq!(
1109            errors.len(),
1110            unit_errors.len(),
1111            "the singular wrapper must see the same error(s) as the plural entry point"
1112        );
1113    }
1114
1115    /// Finding #66: `parse_units_with_drain_check`'s `fully_drained` flag is
1116    /// `bynk-fmt`'s signal for whether its comment-loss guard can skip a
1117    /// re-tokenize-and-diff of its own output. It must be `true` for an
1118    /// ordinary file (every comment sits before a declaration/statement or
1119    /// trails one) and `false` the moment a comment sits inside an expression
1120    /// subtree, where `TriviaTable` has no field to attach it to.
1121    #[test]
1122    fn drain_check_reports_expression_interior_comments_as_undrained() {
1123        let ordinary = "commons x {\n-- note\ntype T = Int where Positive\n}\n";
1124        let toks = tokenize(ordinary).unwrap();
1125        let (_, _, drained) = parse_units_with_drain_check(&toks, ordinary).unwrap();
1126        assert!(
1127            drained,
1128            "a declaration-leading comment must be fully drained"
1129        );
1130
1131        let lossy = "commons x {\n  fn f() -> Int {\n    1 + -- note\n    2\n  }\n}\n";
1132        let toks = tokenize(lossy).unwrap();
1133        let (_, _, drained) = parse_units_with_drain_check(&toks, lossy).unwrap();
1134        assert!(
1135            !drained,
1136            "a comment inside a binop expression must be reported as undrained"
1137        );
1138    }
1139
1140    #[test]
1141    fn eof_span_never_splits_a_multibyte_codepoint() {
1142        // An unterminated construct whose final line ends in a non-ASCII char
1143        // (here a `--` comment ending in `→`) once produced an `unexpected_eof`
1144        // span of `len - 1 .. len`, landing on the arrow's last continuation
1145        // byte. Every reported span must sit on char boundaries.
1146        for src in [
1147            "commons x {\n  -- ends with an arrow →",
1148            "agent A {\n  key k: String\n  -- note 🦀",
1149            "commons y {\n  type T = é",
1150        ] {
1151            let (_unit, errors) = parse_recover_str(src);
1152            for e in &errors {
1153                assert!(
1154                    src.is_char_boundary(e.span.start) && src.is_char_boundary(e.span.end),
1155                    "span {:?} splits a codepoint in {src:?}",
1156                    e.span,
1157                );
1158            }
1159        }
1160    }
1161
1162    #[test]
1163    fn reserved_contextual_keywords_readable_in_expression_position() {
1164        // Events track, slice 0 (#939): `expect_ident`'s `RESERVED_CONTEXTUAL`
1165        // exemption (`keywords::RESERVED_CONTEXTUAL`: case/event/messages/on/
1166        // suite) covers *declaring* a binding with one of these names — a
1167        // parameter, a `let` — but the primary-expression parser previously
1168        // only admitted plain `TokenKind::Ident` when *reading one back*.
1169        // Latent since messages/on/case/suite shipped (no fixture happened to
1170        // name a binding after one of them and read it back inside the body);
1171        // surfaced concretely when `event` joined the tier and collided with
1172        // `examples/event-log`'s pre-existing `add(event: Event)` handler.
1173        for kw in ["case", "event", "messages", "on", "suite"] {
1174            let src = format!("commons x\n\nfn f({kw}: Int) -> Int {{\n  {kw}\n}}\n");
1175            let result = parse_str(&src);
1176            assert!(
1177                result.is_ok(),
1178                "a parameter named `{kw}` must be readable in expression position: {:?}",
1179                result.err()
1180            );
1181        }
1182    }
1183
1184    #[test]
1185    fn recovery_skips_garbage_between_decls() {
1186        // Two `type` declarations separated by garbage. Recovery should
1187        // accept both and report one error for the garbage between them.
1188        let src = "commons x {\n\
1189                   type A = Int where NonNegative\n\
1190                   ??? !!!\n\
1191                   type B = String where NonEmpty\n\
1192                   }";
1193        let (unit, errors) = parse_recover_str(src);
1194        let unit = unit.expect("recovery should produce a partial AST");
1195        let SourceUnit::Commons(c) = unit else {
1196            panic!("expected commons")
1197        };
1198        // Both type decls should have been collected despite the garbage.
1199        let names: Vec<_> = c
1200            .items
1201            .iter()
1202            .map(|i| match i {
1203                CommonsItem::Type(t) => t.name.name.clone(),
1204                _ => panic!("expected only types"),
1205            })
1206            .collect();
1207        assert!(
1208            names.contains(&"A".to_string()) && names.contains(&"B".to_string()),
1209            "expected both A and B; got {names:?}",
1210        );
1211        assert!(!errors.is_empty(), "expected at least one parse error");
1212    }
1213
1214    #[test]
1215    fn recovery_handles_bad_first_decl_then_good_second() {
1216        // First decl is malformed (missing `=`); second is well-formed.
1217        let src = "commons x {\n\
1218                   type A Int where NonNegative\n\
1219                   type B = String where NonEmpty\n\
1220                   }";
1221        let (unit, errors) = parse_recover_str(src);
1222        let unit = unit.expect("recovery should produce a partial AST");
1223        let SourceUnit::Commons(c) = unit else {
1224            panic!("expected commons")
1225        };
1226        let names: Vec<_> = c
1227            .items
1228            .iter()
1229            .filter_map(|i| match i {
1230                CommonsItem::Type(t) => Some(t.name.name.clone()),
1231                _ => None,
1232            })
1233            .collect();
1234        assert!(
1235            names.contains(&"B".to_string()),
1236            "B should be parsed after A's failure; got {names:?}"
1237        );
1238        assert!(!errors.is_empty(), "expected at least one parse error");
1239    }
1240
1241    /// Finding #27/#30: an error two levels deep inside `f`'s body (a
1242    /// `match` arm's own block) used to make `recover_to_top_item`'s flat,
1243    /// depth-blind scan stop at the *first* `}` it saw — the arm block's own,
1244    /// not `f`'s. Two more `}` (the match's, then `f`'s) then got consumed one
1245    /// at a time across repeated recovery re-entries, and the outer item loop
1246    /// eventually mistook the commons's *own* closing `}` for having arrived
1247    /// early, returning zero items and a spurious second
1248    /// `bynk.parse.expected_unit_header` error. With brace-depth tracking, `g`
1249    /// is recovered as the sole item and only `f`'s own error is reported.
1250    #[test]
1251    fn recovery_skips_a_nested_blocks_own_closing_brace() {
1252        let src = "commons m {\n  \
1253                   fn f() -> Int {\n    \
1254                   match 1 {\n      \
1255                   is 1 -> { let z = }\n      \
1256                   is _ -> 2\n    \
1257                   }\n  \
1258                   }\n  \
1259                   fn g() -> Int { 2 }\n\
1260                   }\n";
1261        let (unit, errors) = parse_recover_str(src);
1262        let unit = unit.expect("recovery should produce a partial AST");
1263        let SourceUnit::Commons(c) = unit else {
1264            panic!("expected commons")
1265        };
1266        let names: Vec<_> = c
1267            .items
1268            .iter()
1269            .filter_map(|i| match i {
1270                CommonsItem::Fn(f) => match &f.name {
1271                    FnName::Free(id) => Some(id.name.clone()),
1272                    _ => None,
1273                },
1274                _ => None,
1275            })
1276            .collect();
1277        assert_eq!(
1278            names,
1279            vec!["g".to_string()],
1280            "g must still be recovered as an item; got {names:?}"
1281        );
1282        assert!(
1283            !errors
1284                .iter()
1285                .any(|e| e.category == "bynk.parse.expected_unit_header"),
1286            "the outer body's own closing brace must not be mistaken for \
1287             end-of-file: {errors:?}"
1288        );
1289    }
1290
1291    #[test]
1292    fn doc_block_attaches_to_type() {
1293        let c =
1294            parse_str("commons x {\n---\nA descriptive doc.\n---\ntype T = Int where Positive\n}")
1295                .unwrap();
1296        let CommonsItem::Type(t) = &c.items[0] else {
1297            panic!()
1298        };
1299        assert!(t.documentation.is_some());
1300        assert!(
1301            t.documentation
1302                .as_ref()
1303                .unwrap()
1304                .contains("A descriptive doc.")
1305        );
1306    }
1307
1308    #[test]
1309    fn interpolated_string_parses_into_parts() {
1310        // v0.43: `"Hi, \(name)!"` splits into chunk / hole / chunk.
1311        let c = parse_str("commons x\n\nfn f(name: String) -> String {\n  \"Hi, \\(name)!\"\n}\n")
1312            .unwrap();
1313        let CommonsItem::Fn(f) = &c.items[0] else {
1314            panic!("expected fn")
1315        };
1316        let ExprKind::InterpStr(parts) = &f.body.tail.kind else {
1317            panic!("expected InterpStr, got {:?}", f.body.tail.kind)
1318        };
1319        assert_eq!(parts.len(), 3);
1320        assert!(matches!(&parts[0], InterpPart::Chunk(s) if s == "Hi, "));
1321        assert!(
1322            matches!(&parts[1], InterpPart::Hole(h) if matches!(&h.kind, ExprKind::Ident(id) if id.name == "name"))
1323        );
1324        assert!(matches!(&parts[2], InterpPart::Chunk(s) if s == "!"));
1325    }
1326
1327    #[test]
1328    fn interpolated_hole_parses_a_full_expression() {
1329        // A hole holds an arbitrary expression, not just an identifier.
1330        let c =
1331            parse_str("commons x\n\nfn f(a: Int, b: Int) -> String {\n  \"sum = \\(a + b)\"\n}\n")
1332                .unwrap();
1333        let CommonsItem::Fn(f) = &c.items[0] else {
1334            panic!("expected fn")
1335        };
1336        let ExprKind::InterpStr(parts) = &f.body.tail.kind else {
1337            panic!("expected InterpStr")
1338        };
1339        assert!(matches!(&parts[1], InterpPart::Hole(h) if matches!(&h.kind, ExprKind::BinOp(..))));
1340    }
1341
1342    #[test]
1343    fn empty_interpolation_hole_is_rejected() {
1344        let errs = parse_str("commons x\n\nfn f() -> String {\n  \"\\()\"\n}\n").unwrap_err();
1345        assert!(
1346            errs.iter()
1347                .any(|e| e.category == "bynk.parse.empty_interpolation"),
1348            "expected empty_interpolation; got {errs:?}"
1349        );
1350    }
1351
1352    #[test]
1353    fn interpolation_hole_lex_error_span_is_rebased() {
1354        // #716: a lex error inside a `\(…)` hole once carried a span relative to
1355        // the hole substring — never rebased by `hole.start` — so it pointed at
1356        // the file's opening bytes and could split a multibyte char, tripping
1357        // the char-boundary invariant. The error must land on the offending
1358        // bytes within the hole and stay on char boundaries.
1359        let cases = [
1360            // `$` is not a valid token; the error should point at it, not byte 0.
1361            "commons x\n\nfn f() -> String {\n  \"a \\($)\"\n}\n",
1362            // Integer overflow — the reported span must cover the literal itself.
1363            "commons x\n\nfn f() -> String {\n  \"n = \\(99999999999999999999)\"\n}\n",
1364            // A multibyte char before the hole means an un-rebased span could
1365            // land inside the `é`; the rebased span must not.
1366            "commons x\n\nfn f() -> String {\n  \"é \\($)\"\n}\n",
1367        ];
1368        for src in cases {
1369            let errs = parse_str(src).unwrap_err();
1370            assert!(!errs.is_empty(), "expected a lex error for {src:?}");
1371            for e in &errs {
1372                assert!(
1373                    src.is_char_boundary(e.span.start) && src.is_char_boundary(e.span.end),
1374                    "span {:?} splits a codepoint in {src:?}",
1375                    e.span,
1376                );
1377                // The error must point inside the interpolation hole, not at the
1378                // header text that precedes it.
1379                let hole_start = src.find("\\(").expect("case has a hole") + 2;
1380                assert!(
1381                    e.span.start >= hole_start,
1382                    "span {:?} precedes the hole (starts at {hole_start}) in {src:?}",
1383                    e.span,
1384                );
1385            }
1386        }
1387    }
1388
1389    #[test]
1390    fn fragment_form_parses() {
1391        let c = parse_str("commons x.y\n\ntype T = Int where NonNegative\n").unwrap();
1392        assert_eq!(c.form, CommonsForm::Fragment);
1393        assert_eq!(c.items.len(), 1);
1394    }
1395
1396    #[test]
1397    fn uses_parses() {
1398        let c = parse_str("commons x\n\nuses other.lib\n").unwrap();
1399        assert_eq!(c.uses.len(), 1);
1400        assert_eq!(c.uses[0].target.joined(), "other.lib");
1401    }
1402
1403    fn parse_unit_str(src: &str) -> Result<SourceUnit, Vec<CompileError>> {
1404        let toks = tokenize(src).map_err(|e| vec![e])?;
1405        parse_unit(&toks, src)
1406    }
1407
1408    #[test]
1409    fn minimal_context_parses() {
1410        let u = parse_unit_str("context commerce.orders {}").unwrap();
1411        let SourceUnit::Context(c) = u else {
1412            panic!("expected context");
1413        };
1414        assert_eq!(c.name.joined(), "commerce.orders");
1415        assert!(c.items.is_empty());
1416    }
1417
1418    #[test]
1419    fn context_consumes_and_exports_parse() {
1420        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}";
1421        let u = parse_unit_str(src).unwrap();
1422        let SourceUnit::Context(c) = u else { panic!() };
1423        assert_eq!(c.uses.len(), 1);
1424        assert_eq!(c.consumes.len(), 1);
1425        assert_eq!(c.exports.len(), 2);
1426        assert_eq!(c.exports[0].kind, ExportKind::Type(Visibility::Opaque));
1427        assert_eq!(c.exports[1].kind, ExportKind::Type(Visibility::Transparent));
1428    }
1429
1430    #[test]
1431    fn context_fragment_form_parses() {
1432        let src = "context x.y\n\nuses other.lib\nconsumes other.ctx\nexports opaque { T }\n\ntype T = Int where NonNegative\n";
1433        let u = parse_unit_str(src).unwrap();
1434        let SourceUnit::Context(c) = u else { panic!() };
1435        assert_eq!(c.form, CommonsForm::Fragment);
1436        assert_eq!(c.uses.len(), 1);
1437        assert_eq!(c.consumes.len(), 1);
1438        assert_eq!(c.exports.len(), 1);
1439    }
1440
1441    #[test]
1442    fn opaque_type_parses() {
1443        let c = parse_str("commons x { type T = opaque Int where NonNegative }").unwrap();
1444        let CommonsItem::Type(t) = &c.items[0] else {
1445            panic!()
1446        };
1447        assert!(matches!(t.body, TypeBody::Opaque { .. }));
1448    }
1449
1450    #[test]
1451    fn empty_commons() {
1452        let c = parse_str("commons fitness.units {}").unwrap();
1453        assert_eq!(c.name.joined(), "fitness.units");
1454        assert!(c.items.is_empty());
1455    }
1456
1457    #[test]
1458    fn one_type_decl() {
1459        let c = parse_str("commons x { type Metres = Int where NonNegative }").unwrap();
1460        assert_eq!(c.items.len(), 1);
1461        let CommonsItem::Type(t) = &c.items[0] else {
1462            panic!()
1463        };
1464        assert_eq!(t.name.name, "Metres");
1465        match &t.body {
1466            TypeBody::Refined {
1467                base, refinement, ..
1468            } => {
1469                assert_eq!(*base, BaseType::Int);
1470                assert!(refinement.is_some());
1471            }
1472            _ => panic!("expected refined body"),
1473        }
1474    }
1475
1476    #[test]
1477    fn function_decl() {
1478        let c = parse_str("commons x { fn add(a: Int, b: Int) -> Int { a + b } }").unwrap();
1479        let CommonsItem::Fn(f) = &c.items[0] else {
1480            panic!()
1481        };
1482        assert_eq!(f.name.ident().name, "add");
1483        assert_eq!(f.params.len(), 2);
1484    }
1485
1486    #[test]
1487    fn chained_comparison_is_error() {
1488        let errs = parse_str("commons x { fn f(a: Int, b: Int, c: Int) -> Bool { a < b < c } }")
1489            .unwrap_err();
1490        assert_eq!(errs[0].category, "bynk.parse.non_associative");
1491    }
1492
1493    #[test]
1494    fn chained_equality_is_error() {
1495        let errs = parse_str("commons x { fn f(a: Int, b: Int, c: Int) -> Bool { a == b == c } }")
1496            .unwrap_err();
1497        assert_eq!(errs[0].category, "bynk.parse.non_associative");
1498    }
1499
1500    /// Run `f` on a thread with a generous stack. The depth-guard tests build
1501    /// source that, *without* the guard, overflows — so if the guard ever
1502    /// regressed we want a clean assertion failure, not a `SIGABRT` that takes
1503    /// the whole test binary down. A large stack also absorbs the fat frames a
1504    /// debug build spends per recursion level (production release frames are
1505    /// ~9 KB/level, so `MAX_NESTING_DEPTH = 64` sits well inside a 1 MB stack;
1506    /// a debug frame is several times larger and would overflow libtest's
1507    /// default 2 MB test thread near the limit even though the guard fires).
1508    fn on_big_stack<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
1509        std::thread::Builder::new()
1510            .stack_size(64 * 1024 * 1024)
1511            .spawn(f)
1512            .unwrap()
1513            .join()
1514            .unwrap()
1515    }
1516
1517    #[test]
1518    fn deeply_nested_parens_are_bounded_not_overflowed() {
1519        // Without a depth guard the parenthesised-expression recursion
1520        // (`parse_primary` -> `parse_expr` -> …) overflows the stack and aborts
1521        // the process (#713). Well past the limit it must instead report a
1522        // bounded-depth diagnostic. The nesting is left open so the guard, not
1523        // a later `)`, is what stops the descent.
1524        let errs = on_big_stack(|| {
1525            let depth = crate::MAX_NESTING_DEPTH + 8;
1526            let src = format!(
1527                "commons x {{ fn f() -> Int {{ {}0{} }} }}",
1528                "(".repeat(depth),
1529                ")".repeat(depth),
1530            );
1531            parse_str(&src).unwrap_err()
1532        });
1533        assert_eq!(errs[0].category, "bynk.parse.nesting_too_deep");
1534    }
1535
1536    #[test]
1537    fn deeply_nested_types_are_bounded_not_overflowed() {
1538        // The type parser self-recurses through generic type arguments
1539        // (`parse_type_ref` -> `parse_type_atom` -> `parse_type_ref`); the same
1540        // guard bounds it (#713). A right-nested `Result[Int, …]` in parameter
1541        // position drives that recursion.
1542        let errs = on_big_stack(|| {
1543            let depth = crate::MAX_NESTING_DEPTH + 8;
1544            let src = format!(
1545                "commons x {{ fn f(x: {}Int{}) -> Int {{ 0 }} }}",
1546                "Result[Int, ".repeat(depth),
1547                "]".repeat(depth),
1548            );
1549            parse_str(&src).unwrap_err()
1550        });
1551        assert_eq!(errs[0].category, "bynk.parse.nesting_too_deep");
1552    }
1553
1554    #[test]
1555    fn deeply_nested_patterns_are_bounded_not_overflowed() {
1556        // Variant patterns are a third self-recursive descent (`parse_pattern`
1557        // -> `parse_pattern_binding` -> `parse_pattern`) that routes through
1558        // neither `parse_expr` nor `parse_type_ref`; without its own guard a
1559        // nested `Ok(Ok(…))` match arm reproduces the #713 crash.
1560        let errs = on_big_stack(|| {
1561            let depth = crate::MAX_NESTING_DEPTH + 8;
1562            let src = format!(
1563                "commons x {{ fn f(n: Int) -> Int {{ match n {{ {}n{} => 0 }} }} }}",
1564                "Ok(".repeat(depth),
1565                ")".repeat(depth),
1566            );
1567            parse_str(&src).unwrap_err()
1568        });
1569        assert_eq!(errs[0].category, "bynk.parse.nesting_too_deep");
1570    }
1571
1572    #[test]
1573    fn nesting_below_the_limit_still_parses() {
1574        // The guard must not reject ordinary well-nested source: a paren-nested
1575        // expression comfortably under the limit still parses cleanly.
1576        let ok = on_big_stack(|| {
1577            let depth = crate::MAX_NESTING_DEPTH - 8;
1578            let src = format!(
1579                "commons x {{ fn f() -> Int {{ {}0{} }} }}",
1580                "(".repeat(depth),
1581                ")".repeat(depth),
1582            );
1583            parse_str(&src).is_ok()
1584        });
1585        assert!(ok, "well-nested source under the limit should parse");
1586    }
1587
1588    #[test]
1589    fn let_statement_parses() {
1590        let c = parse_str("commons x { fn f(n: Int) -> Int { let y = n + 1\n y } }").unwrap();
1591        let CommonsItem::Fn(f) = &c.items[0] else {
1592            panic!()
1593        };
1594        assert_eq!(f.body.statements.len(), 1);
1595        match &f.body.statements[0] {
1596            Statement::Let(l) => {
1597                assert_eq!(l.name.name, "y");
1598                assert!(l.type_annot.is_none());
1599            }
1600            _ => panic!("expected a pure `let` statement"),
1601        }
1602    }
1603
1604    #[test]
1605    fn let_with_annotation() {
1606        let c = parse_str("commons x { fn f(n: Int) -> Int { let y: Int = n\n y } }").unwrap();
1607        let CommonsItem::Fn(f) = &c.items[0] else {
1608            panic!()
1609        };
1610        match &f.body.statements[0] {
1611            Statement::Let(l) => assert!(l.type_annot.is_some()),
1612            _ => panic!("expected a pure `let` statement"),
1613        }
1614    }
1615
1616    #[test]
1617    fn if_else_parses_as_expression() {
1618        let c = parse_str("commons x { fn f(b: Bool) -> Int { if b { 1 } else { 0 } } }").unwrap();
1619        let CommonsItem::Fn(f) = &c.items[0] else {
1620            panic!()
1621        };
1622        assert!(matches!(f.body.tail.kind, ExprKind::If { .. }));
1623    }
1624
1625    #[test]
1626    fn else_if_chain_parses() {
1627        let c = parse_str(
1628            "commons x { fn f(n: Int) -> Int { if n < 0 { -1 } else if n == 0 { 0 } else { 1 } } }",
1629        )
1630        .unwrap();
1631        let CommonsItem::Fn(f) = &c.items[0] else {
1632            panic!()
1633        };
1634        let ExprKind::If { else_block, .. } = &f.body.tail.kind else {
1635            panic!()
1636        };
1637        // The else-branch is a block whose tail is another `If`.
1638        assert!(else_block.statements.is_empty());
1639        assert!(matches!(else_block.tail.kind, ExprKind::If { .. }));
1640    }
1641
1642    #[test]
1643    fn ok_and_err_parse_as_expressions() {
1644        let c = parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Ok(n) } }").unwrap();
1645        let CommonsItem::Fn(f) = &c.items[0] else {
1646            panic!()
1647        };
1648        assert!(matches!(f.body.tail.kind, ExprKind::Ok(_)));
1649
1650        let c =
1651            parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Err(\"x\") } }").unwrap();
1652        let CommonsItem::Fn(f) = &c.items[0] else {
1653            panic!()
1654        };
1655        assert!(matches!(f.body.tail.kind, ExprKind::Err(_)));
1656    }
1657
1658    #[test]
1659    fn question_postfix_parses() {
1660        let c = parse_str(
1661            "commons x { type T = Int where Positive\n fn f(n: Int) -> Result[T, ValidationError] { let x = T.of(n)?\n Ok(x) } }",
1662        )
1663        .unwrap();
1664        let CommonsItem::Fn(f) = &c.items[1] else {
1665            panic!()
1666        };
1667        let Statement::Let(l) = &f.body.statements[0] else {
1668            panic!("expected a pure `let` statement");
1669        };
1670        assert!(matches!(l.value.kind, ExprKind::Question(_)));
1671    }
1672
1673    #[test]
1674    fn constructor_call_parses() {
1675        let c = parse_str(
1676            "commons x { type T = Int where Positive\n fn f(n: Int) -> Result[T, ValidationError] { T.of(n) } }",
1677        )
1678        .unwrap();
1679        let CommonsItem::Fn(f) = &c.items[1] else {
1680            panic!()
1681        };
1682        // v0.2: T.of(n) parses as a MethodCall with receiver Ident("T"); the
1683        // checker reinterprets it as a static call by noticing T is a type.
1684        let ExprKind::MethodCall {
1685            receiver, method, ..
1686        } = &f.body.tail.kind
1687        else {
1688            panic!("expected MethodCall, got {:?}", f.body.tail.kind)
1689        };
1690        let ExprKind::Ident(id) = &receiver.kind else {
1691            panic!("expected receiver Ident");
1692        };
1693        assert_eq!(id.name, "T");
1694        assert_eq!(method.name, "of");
1695    }
1696
1697    #[test]
1698    fn result_type_ref_parses() {
1699        let c = parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Ok(n) } }").unwrap();
1700        let CommonsItem::Fn(f) = &c.items[0] else {
1701            panic!()
1702        };
1703        assert!(matches!(f.return_type, TypeRef::Result(_, _, _)));
1704    }
1705
1706    #[test]
1707    fn result_missing_arg_count_errors() {
1708        let errs = parse_str("commons x { fn f(n: Int) -> Result[Int] { Ok(n) } }").unwrap_err();
1709        assert_eq!(errs[0].category, "bynk.parse.generic_arg_count");
1710    }
1711
1712    #[test]
1713    fn field_access_parses_in_v0_2() {
1714        // v0.2: field access is supported (the type checker validates the
1715        // field exists on the receiver's type). Parser-level acceptance:
1716        let c =
1717            parse_str("commons x { type R = { foo: Int }\n fn f(r: R) -> Int { r.foo } }").unwrap();
1718        let CommonsItem::Fn(f) = &c.items[1] else {
1719            panic!()
1720        };
1721        assert!(matches!(f.body.tail.kind, ExprKind::FieldAccess { .. }));
1722    }
1723
1724    // -- v1.1 trivia attachment --
1725
1726    #[test]
1727    fn leading_line_comment_attaches_to_next_decl() {
1728        let src = "commons x {\n-- explain the type\ntype T = Int where NonNegative\n}";
1729        let c = parse_str(src).unwrap();
1730        let CommonsItem::Type(t) = &c.items[0] else {
1731            panic!()
1732        };
1733        assert_eq!(t.trivia.leading, vec![" explain the type".to_string()]);
1734        assert!(t.trivia.trailing.is_none());
1735    }
1736
1737    #[test]
1738    fn trailing_line_comment_attaches_to_prev_decl() {
1739        let src = "commons x {\ntype T = Int where NonNegative  -- trailing note\n}";
1740        let c = parse_str(src).unwrap();
1741        let CommonsItem::Type(t) = &c.items[0] else {
1742            panic!()
1743        };
1744        assert!(t.trivia.leading.is_empty());
1745        assert_eq!(t.trivia.trailing.as_deref(), Some(" trailing note"));
1746    }
1747
1748    #[test]
1749    fn grouped_leading_comments_attach_together() {
1750        let src = "commons x {\n-- one\n-- two\n-- three\ntype T = Int where Positive\n}";
1751        let c = parse_str(src).unwrap();
1752        let CommonsItem::Type(t) = &c.items[0] else {
1753            panic!()
1754        };
1755        assert_eq!(
1756            t.trivia.leading,
1757            vec![" one".to_string(), " two".to_string(), " three".to_string()],
1758        );
1759    }
1760
1761    #[test]
1762    fn comment_with_doc_block_keeps_both() {
1763        // Both `-- intro` and the doc block should attach to the type decl.
1764        let src = "commons x {\n-- intro\n---\ndocs\n---\ntype T = Int where Positive\n}";
1765        let c = parse_str(src).unwrap();
1766        let CommonsItem::Type(t) = &c.items[0] else {
1767            panic!()
1768        };
1769        assert_eq!(t.trivia.leading, vec![" intro".to_string()]);
1770        assert_eq!(t.documentation.as_deref(), Some("docs"));
1771    }
1772
1773    #[test]
1774    fn messages_keyword_does_not_collide_with_a_commons_name_segment() {
1775        // `messages` is RESERVED_CONTEXTUAL (like `case`/`on`/`suite`), not a
1776        // hard keyword: `commons app.messages { ... }` — the design's own
1777        // natural naming choice for a bundle commons — must still parse.
1778        // (Caught during slice-1 implementation: a first pass made `messages`
1779        // a plain hard keyword and this exact name broke.)
1780        let src = "commons app.messages {\ntype T = Int where Positive\n}";
1781        let c = parse_str(src).unwrap();
1782        assert_eq!(c.name.joined(), "app.messages");
1783    }
1784
1785    #[test]
1786    fn messages_decl_parses_tag_annotation_and_entries() {
1787        // message-bundles slice 1 (#859): the construct + doc/trivia wiring.
1788        let src = "commons app.messages {\n\
1789                   -- intro\n\
1790                   ---\n\
1791                   docs\n\
1792                   ---\n\
1793                   messages \"en\" @reference {\n\
1794                   \"greeting\" => \"Hello, {name}!\"\n\
1795                   \"farewell\" => \"Bye\"\n\
1796                   } -- trailing\n\
1797                   }";
1798        let c = parse_str(src).unwrap();
1799        let CommonsItem::Messages(m) = &c.items[0] else {
1800            panic!("expected a messages item, got {:?}", c.items[0]);
1801        };
1802        assert_eq!(m.tag, "en");
1803        assert_eq!(m.annotations.len(), 1);
1804        assert_eq!(m.annotations[0].name.name, "reference");
1805        assert!(m.annotations[0].args.is_empty());
1806        assert_eq!(m.entries.len(), 2);
1807        assert_eq!(m.entries[0].code, "greeting");
1808        assert_eq!(m.entries[0].template, "Hello, {name}!");
1809        assert_eq!(m.entries[1].code, "farewell");
1810        assert_eq!(m.entries[1].template, "Bye");
1811        assert_eq!(m.trivia.leading, vec![" intro".to_string()]);
1812        assert_eq!(m.documentation.as_deref(), Some("docs"));
1813        assert_eq!(m.trivia.trailing.as_deref(), Some(" trailing"));
1814    }
1815
1816    #[test]
1817    fn messages_decl_parses_with_no_annotation_and_no_entries() {
1818        // The parser stays permissive on annotation cardinality (zero-or-more)
1819        // — "exactly one `@reference`" is a checker concern (validate.rs), not
1820        // a parse error.
1821        let src = "commons app.messages {\nmessages \"en\" {\n}\n}";
1822        let c = parse_str(src).unwrap();
1823        let CommonsItem::Messages(m) = &c.items[0] else {
1824            panic!("expected a messages item, got {:?}", c.items[0]);
1825        };
1826        assert_eq!(m.tag, "en");
1827        assert!(m.annotations.is_empty());
1828        assert!(m.entries.is_empty());
1829    }
1830
1831    #[test]
1832    fn messages_decl_parses_syntactically_inside_a_context_too() {
1833        // Commons-only legality is a checker concern (bynk.messages.outside_commons
1834        // in bynk-emit's project validation), not a parser rejection — mirrors
1835        // how `service`/`agent` already parse syntactically inside `adapter`
1836        // bodies for the same reason.
1837        let src = "context app.svc {\nmessages \"en\" @reference {\n\"a\" => \"b\"\n}\n}";
1838        let toks = tokenize(src).unwrap();
1839        let (unit, errors) = parse_unit_with_recovery(&toks, src);
1840        assert!(errors.is_empty(), "unexpected parse errors: {errors:?}");
1841        let Some(SourceUnit::Context(ctx)) = unit else {
1842            panic!("expected a context")
1843        };
1844        let CommonsItem::Messages(m) = &ctx.items[0] else {
1845            panic!("expected a messages item, got {:?}", ctx.items[0]);
1846        };
1847        assert_eq!(m.tag, "en");
1848    }
1849
1850    #[test]
1851    fn comment_before_let_statement_attaches() {
1852        let src = "commons x {\nfn f(n: Int) -> Int {\n-- pick a value\nlet y = n + 1\ny\n}\n}";
1853        let c = parse_str(src).unwrap();
1854        let CommonsItem::Fn(f) = &c.items[0] else {
1855            panic!()
1856        };
1857        let Statement::Let(l) = &f.body.statements[0] else {
1858            panic!()
1859        };
1860        assert_eq!(l.trivia.leading, vec![" pick a value".to_string()]);
1861    }
1862
1863    #[test]
1864    fn comment_before_tail_attaches_to_block_tail() {
1865        let src = "commons x {\nfn f(n: Int) -> Int {\nlet y = n + 1\n-- result\ny\n}\n}";
1866        let c = parse_str(src).unwrap();
1867        let CommonsItem::Fn(f) = &c.items[0] else {
1868            panic!()
1869        };
1870        assert_eq!(f.body.tail_leading_comments, vec![" result".to_string()],);
1871    }
1872
1873    /// #637 Gap A: the contextual keywords `on` / `suite` / `case` are lexer
1874    /// tokens but `expect_ident` admits them as identifiers outside their one
1875    /// keyword position, so they are valid record-field and parameter names.
1876    /// The keyword reference now renders them as a distinct "contextual" tier
1877    /// rather than claiming (falsely) that they cannot be used as identifiers.
1878    #[test]
1879    fn contextual_keywords_are_valid_identifiers() {
1880        // Record field names.
1881        let c = parse_str("commons demo {\n  type R = { on: Int, suite: String, case: Bool }\n}")
1882            .expect("`on`/`suite`/`case` are valid field names");
1883        let CommonsItem::Type(_) = &c.items[0] else {
1884            panic!("expected a type decl")
1885        };
1886
1887        // Function parameter names (the other `expect_ident` position).
1888        parse_str("commons demo {\n  fn f(on: Int, case: Int) -> Int { 0 }\n}")
1889            .expect("`on`/`case` are valid parameter names");
1890
1891        // `suite` too, as a field name.
1892        parse_str("commons demo {\n  type R = { suite: Int }\n}")
1893            .expect("`suite` is a valid field name");
1894    }
1895
1896    /// Drift guard: every alphabetic keyword the lexer declares must be
1897    /// classified by `is_reserved_keyword`, or be one of the *contextual*
1898    /// keywords `expect_ident` deliberately admits as identifiers
1899    /// (`on`/`suite`/`case`). Everything else in this codebase that can
1900    /// drift has a guard; this predicate had silently fallen 17 keywords
1901    /// behind, degrading the reserved-keyword diagnostic to the generic
1902    /// expected-token one.
1903    #[test]
1904    fn is_reserved_keyword_covers_every_lexer_keyword() {
1905        let lexer_src = include_str!("lexer.rs");
1906        let mut words = Vec::new();
1907        for line in lexer_src.lines() {
1908            let t = line.trim();
1909            if let Some(rest) = t.strip_prefix("#[token(\"")
1910                && let Some(word) = rest.split('"').next()
1911                && word.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
1912                && word.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
1913            {
1914                words.push(word.to_string());
1915            }
1916        }
1917        assert!(
1918            words.len() > 30,
1919            "keyword extraction looks broken: only {} words",
1920            words.len()
1921        );
1922        // Contextual keywords double as identifiers (see `expect_ident`); the
1923        // tier is single-sourced in `keywords::RESERVED_CONTEXTUAL`.
1924        use crate::keywords::RESERVED_CONTEXTUAL;
1925        let mut unclassified = Vec::new();
1926        for word in &words {
1927            let tokens = crate::lexer::tokenize(word).expect("keyword lexes");
1928            let kind = tokens.first().expect("keyword yields a token").kind;
1929            if !is_reserved_keyword(kind) && !RESERVED_CONTEXTUAL.contains(&word.as_str()) {
1930                unclassified.push(word.clone());
1931            }
1932        }
1933        assert!(
1934            unclassified.is_empty(),
1935            "keywords missing from is_reserved_keyword (add them, or document \
1936             them as contextual): {unclassified:?}"
1937        );
1938    }
1939
1940    /// Finding #27/#30: pins `is_item_start` to exactly the keyword set
1941    /// `declarations.rs`'s six item loops (`parse_commons_brace`/`_fragment`,
1942    /// `parse_context_brace`/`_fragment`, `parse_test_brace`/`_fragment`) plus
1943    /// `parse_adapter_body` dispatch on, as of this writing — the drift this
1944    /// finding fixed (`Property`, `Actor`, `Event`, `Binding`, and the
1945    /// `adapter` unit keyword itself were all missing from
1946    /// `recover_to_top_item`'s old hand-written sync list, even though every
1947    /// one of them is a real item/unit start). Adding a new item keyword to
1948    /// any of those loops should mean deliberately updating this list too,
1949    /// not silently leaving recovery unable to resync at it.
1950    #[test]
1951    fn is_item_start_matches_the_pinned_keyword_set() {
1952        use TokenKind::*;
1953        let expected_true = [
1954            Commons, Context, Adapter, Suite, Type, Fn, Messages, Event, Uses, Consumes, Exports,
1955            Capability, Provides, Service, Agent, Actor, Binding, Stub, Case, Property,
1956        ];
1957        for kind in expected_true {
1958            assert!(is_item_start(kind), "{kind:?} must be an item start");
1959        }
1960        let expected_false = [
1961            Ident, Plus, Minus, Colon, Dot, Eq, LBrace, RBrace, LParen, RParen, If, Else, Let,
1962            Where, True, False, Match, Is, On, Given,
1963        ];
1964        for kind in expected_false {
1965            assert!(!is_item_start(kind), "{kind:?} must not be an item start");
1966        }
1967    }
1968
1969    /// Fuzz-found (#516): a context-only keyword at item position in a
1970    /// commons errors without consuming the token, and the recovery sync
1971    /// stops at exactly that keyword — without a progress guard the item
1972    /// loop re-reported the same error until memory ran out.
1973    #[test]
1974    fn recovery_makes_progress_on_context_only_keyword_in_commons() {
1975        let src = "commons demo\n\ncapability Logger {\n  fn log(m: String) -> Effect[()]\n}\n";
1976        let tokens = crate::lexer::tokenize(src).unwrap();
1977        let (unit, errors) = parse_unit_with_recovery(&tokens, src);
1978        assert!(unit.is_some(), "the commons header still parses");
1979        assert!(
1980            errors
1981                .iter()
1982                .any(|e| e.category == "bynk.capability.outside_context"),
1983            "the misplaced capability is reported: {errors:?}"
1984        );
1985        // Termination is the real assertion (this used to OOM); a bounded,
1986        // non-repeating error list is the observable proxy.
1987        assert!(errors.len() < 10, "recovery repeated itself: {errors:?}");
1988    }
1989
1990    #[test]
1991    fn trailing_file_comment_becomes_unit_trailing() {
1992        // A comment after the last item but before EOF (fragment form)
1993        // becomes the commons body's trailing comments so the formatter
1994        // can preserve it.
1995        let src = "commons x\n\ntype T = Int where Positive\n-- afterword\n";
1996        let c = parse_str(src).unwrap();
1997        assert_eq!(c.trailing_comments, vec![" afterword".to_string()]);
1998    }
1999
2000    #[test]
2001    fn trailing_file_comment_after_a_brace_form_commons_is_not_dropped() {
2002        // Regression: the brace form's item loop exits on `RBrace`, never
2003        // reaching the fragment form's end-of-input case that drains the
2004        // trivia table's epilogue — so a comment after the closing `}` was
2005        // silently discarded (and, per `epilogue_is_empty`'s debug_assert,
2006        // would panic a debug build instead of round-tripping through
2007        // `bynk-fmt`).
2008        let src = "commons x {\n  type T = Int where Positive\n}\n-- afterword\n";
2009        let c = parse_str(src).unwrap();
2010        assert_eq!(c.trailing_comments, vec![" afterword".to_string()]);
2011    }
2012
2013    #[test]
2014    fn trailing_file_comment_after_a_brace_form_context_is_not_dropped() {
2015        // Same regression as the commons case, for `parse_context_brace`.
2016        let src = "context x {\n  type T = Int where Positive\n}\n-- afterword\n";
2017        let SourceUnit::Context(c) = parse_unit_str(src).unwrap() else {
2018            panic!("expected context");
2019        };
2020        assert_eq!(c.trailing_comments, vec![" afterword".to_string()]);
2021    }
2022
2023    #[test]
2024    fn trailing_file_comment_after_a_brace_form_suite_is_not_dropped() {
2025        // Same regression as the commons case, for `parse_test_brace`.
2026        let src = "suite x {\n  case \"c\" {\n    expect 1 == 1\n  }\n}\n-- afterword\n";
2027        let SourceUnit::Suite(s) = parse_unit_str(src).unwrap() else {
2028            panic!("expected suite");
2029        };
2030        assert_eq!(s.trailing_comments, vec![" afterword".to_string()]);
2031    }
2032
2033    /// Finding #30: unlike the three regressions just above,
2034    /// `parse_adapter_body`'s brace-closing path never called
2035    /// `take_epilogue` at all (not a regression from a shared pattern — it
2036    /// simply never had the call), so a comment after a brace-form adapter's
2037    /// closing `}` was silently dropped. The fragment form (no braces) was
2038    /// already correct.
2039    #[test]
2040    fn trailing_file_comment_after_a_brace_form_adapter_is_not_dropped() {
2041        let src = "adapter x {\n  binding \"./x.ts\"\n}\n-- afterword\n";
2042        let SourceUnit::Adapter(a) = parse_unit_str(src).unwrap() else {
2043            panic!("expected adapter");
2044        };
2045        assert_eq!(a.trailing_comments, vec![" afterword".to_string()]);
2046    }
2047
2048    // -- Six-fold unification (review Part 3): the fragment-only ordering
2049    // restrictions declarations.rs's brace/fragment pairs preserve, now that
2050    // they share one function each behind `brace: bool`. None of these had
2051    // any prior test coverage at all. --
2052
2053    /// Fragment-form commons: `uses` must precede every `type`/`fn`.
2054    #[test]
2055    fn commons_fragment_rejects_uses_after_a_decl() {
2056        let src = "commons x\n\ntype T = Int where Positive\nuses bynk.list\n";
2057        let errs = parse_str(src).unwrap_err();
2058        assert!(
2059            errs.iter()
2060                .any(|e| e.category == "bynk.parse.uses_after_decls"),
2061            "{errs:?}"
2062        );
2063    }
2064
2065    /// The same ordering is NOT enforced in brace form — `uses` may appear
2066    /// anywhere in the body.
2067    #[test]
2068    fn commons_brace_allows_uses_after_a_decl() {
2069        let src = "commons x {\n  type T = Int where Positive\n  uses bynk.list\n}\n";
2070        parse_str(src).expect("brace form must not enforce fragment's uses-ordering rule");
2071    }
2072
2073    /// Fragment-form context: `consumes` must precede every `type`/`fn`/etc.
2074    #[test]
2075    fn context_fragment_rejects_consumes_after_a_decl() {
2076        let src = "context x\n\ntype T = Int where Positive\nconsumes bynk\n";
2077        let errs = parse_unit_str(src).unwrap_err();
2078        assert!(
2079            errs.iter()
2080                .any(|e| e.category == "bynk.parse.consumes_after_decls"),
2081            "{errs:?}"
2082        );
2083    }
2084
2085    /// Fragment-form context: `exports` must precede every `type`/`fn`/etc.
2086    #[test]
2087    fn context_fragment_rejects_exports_after_a_decl() {
2088        let src = "context x\n\ntype T = Int where Positive\nexports opaque { T }\n";
2089        let errs = parse_unit_str(src).unwrap_err();
2090        assert!(
2091            errs.iter()
2092                .any(|e| e.category == "bynk.parse.exports_after_decls"),
2093            "{errs:?}"
2094        );
2095    }
2096
2097    /// Brace-form context enforces none of the three orderings.
2098    #[test]
2099    fn context_brace_allows_consumes_and_exports_after_a_decl() {
2100        let src = "context x {\n  type T = Int where Positive\n  consumes bynk\n  exports opaque { T }\n}\n";
2101        parse_unit_str(src)
2102            .expect("brace form must not enforce fragment's consumes/exports-ordering rules");
2103    }
2104
2105    /// Fragment-form suite/test: `uses` must precede every `stub`/`case`/`property`.
2106    #[test]
2107    fn test_fragment_rejects_uses_after_a_decl() {
2108        let src = "suite m\n\ncase \"c\" {\n  expect true\n}\nuses bynk.list\n";
2109        let errs = parse_unit_str(src).unwrap_err();
2110        assert!(
2111            errs.iter()
2112                .any(|e| e.category == "bynk.parse.uses_after_decls"),
2113            "{errs:?}"
2114        );
2115    }
2116
2117    /// Brace-form suite/test allows `uses` anywhere.
2118    #[test]
2119    fn test_brace_allows_uses_after_a_decl() {
2120        let src = "suite m {\n  case \"c\" {\n    expect true\n  }\n  uses bynk.list\n}\n";
2121        parse_unit_str(src).expect("brace form must not enforce fragment's uses-ordering rule");
2122    }
2123
2124    // ---- #636: `if`/`match` condition vs record construction ----
2125
2126    /// Parse `body` as the tail expression of a fn and return its kind.
2127    fn body_tail(body: &str) -> ExprKind {
2128        let src = format!("commons x\n\nfn f() -> Int {{\n  {body}\n}}\n");
2129        let c = parse_str(&src).unwrap_or_else(|e| panic!("parse failed for {body:?}: {e:?}"));
2130        let CommonsItem::Fn(f) = &c.items[0] else {
2131            panic!("expected fn, got {:?}", c.items[0]);
2132        };
2133        f.body.tail.kind.clone()
2134    }
2135
2136    fn body_err(body: &str) -> Vec<CompileError> {
2137        let src = format!("commons x\n\nfn f() -> Int {{\n  {body}\n}}\n");
2138        parse_str(&src).expect_err(&format!("expected a parse error for {body:?}"))
2139    }
2140
2141    #[test]
2142    fn if_condition_ending_in_ident_does_not_swallow_a_single_ident_branch() {
2143        // #636: `ready { result }` shares its shape with a shorthand-field
2144        // record construction. In condition position the branch must win.
2145        for src in [
2146            "if ready { result } else { fallback }",
2147            "if ready { fallback } else { result }",
2148            "if !ready { result } else { fallback }",
2149            "if a == b { result } else { fallback }",
2150            "if a && b { result } else { fallback }",
2151        ] {
2152            let ExprKind::If {
2153                then_block,
2154                else_block,
2155                ..
2156            } = body_tail(src)
2157            else {
2158                panic!("expected If for {src:?}, got {:?}", body_tail(src));
2159            };
2160            // Both branches carry a bare-identifier tail — proof the `{ … }`
2161            // was read as a block, not consumed as a record by the condition.
2162            assert!(
2163                matches!(&then_block.tail.kind, ExprKind::Ident(_)),
2164                "then-branch tail not an ident for {src:?}: {:?}",
2165                then_block.tail.kind,
2166            );
2167            assert!(
2168                matches!(&else_block.tail.kind, ExprKind::Ident(_)),
2169                "else-branch tail not an ident for {src:?}: {:?}",
2170                else_block.tail.kind,
2171            );
2172        }
2173    }
2174
2175    #[test]
2176    fn else_less_if_with_single_ident_branch_parses() {
2177        // The no-`else` reproduction: previously errored `found `}``.
2178        let ExprKind::If { then_block, .. } = body_tail("if ready { result }") else {
2179            panic!("expected If");
2180        };
2181        assert!(matches!(&then_block.tail.kind, ExprKind::Ident(_)));
2182    }
2183
2184    #[test]
2185    fn record_construction_still_parses_in_value_position() {
2186        // The restriction is confined to condition spines — an ordinary value
2187        // position still constructs records, including the shorthand tail form.
2188        assert!(matches!(
2189            body_tail("Point { x }"),
2190            ExprKind::RecordConstruction { .. }
2191        ));
2192        assert!(matches!(
2193            body_tail("Point { x: 1, y: 2 }"),
2194            ExprKind::RecordConstruction { .. }
2195        ));
2196        assert!(matches!(
2197            body_tail("Empty {}"),
2198            ExprKind::RecordConstruction { .. }
2199        ));
2200    }
2201
2202    #[test]
2203    fn parenthesised_record_is_allowed_in_condition_head() {
2204        // A delimiter lifts the restriction: `(ready { result })` constructs a
2205        // record even in condition position (mirrors Rust's paren escape).
2206        let ExprKind::If { cond, .. } =
2207            body_tail("if (ready { result }) { branch } else { other }")
2208        else {
2209            panic!("expected If");
2210        };
2211        let ExprKind::Paren(inner) = &cond.kind else {
2212            panic!("expected a parenthesised condition, got {:?}", cond.kind);
2213        };
2214        assert!(
2215            matches!(&inner.kind, ExprKind::RecordConstruction { .. }),
2216            "parenthesised record in condition head should still construct: {:?}",
2217            inner.kind,
2218        );
2219    }
2220
2221    #[test]
2222    fn record_in_call_arg_within_condition_still_constructs() {
2223        // The restriction is lifted through a call-argument delimiter, so a
2224        // record literal passed to a predicate in the condition still parses.
2225        let ExprKind::If { cond, .. } = body_tail("if check(Point { x: 1 }) { a } else { b }")
2226        else {
2227            panic!("expected If");
2228        };
2229        let ExprKind::Call { args, .. } = &cond.kind else {
2230            panic!("expected Call in condition, got {:?}", cond.kind);
2231        };
2232        assert!(matches!(&args[0].kind, ExprKind::RecordConstruction { .. }));
2233    }
2234
2235    #[test]
2236    fn safe_condition_shapes_are_unaffected() {
2237        // Cases the issue lists as already-safe must stay safe.
2238        assert!(matches!(
2239            body_tail("if ready == true { result } else { fallback }"),
2240            ExprKind::If { .. }
2241        ));
2242        assert!(matches!(
2243            body_tail("if (ready) { result } else { fallback }"),
2244            ExprKind::If { .. }
2245        ));
2246        assert!(matches!(
2247            body_tail("if ready { \"a\" } else { \"b\" }"),
2248            ExprKind::If { .. }
2249        ));
2250    }
2251
2252    #[test]
2253    fn empty_match_reports_its_own_diagnostic() {
2254        // #636: `match result {}` once parsed `result {}` as an empty record,
2255        // masking `bynk.parse.empty_match`. The intended diagnostic is now
2256        // reachable.
2257        let errs = body_err("match result {}");
2258        assert!(
2259            errs.iter().any(|e| e.category == "bynk.parse.empty_match"),
2260            "expected empty_match; got {errs:?}",
2261        );
2262    }
2263
2264    #[test]
2265    fn match_discriminant_ending_in_ident_parses() {
2266        // A `match` over a bare-identifier discriminant reaches its arm list.
2267        assert!(matches!(
2268            body_tail("match ready { x => x }"),
2269            ExprKind::Match { .. }
2270        ));
2271    }
2272
2273    /// #981: an identifier statement immediately followed, on its own line, by
2274    /// a standalone `()` must stay two separate constructs — not merge into a
2275    /// zero-arg call. `status := Paid` / `()` is an Assign statement whose
2276    /// value is the bare identifier `Paid`, then a unit tail; it must never
2277    /// parse as a single `status := Paid()` (a call). The call-parens rule
2278    /// mirrors the v0.20b same-line `[` rule already applied to type
2279    /// arguments: a postfix opener that begins a new line does not continue
2280    /// the previous token.
2281    #[test]
2282    fn identifier_statement_followed_by_unit_tail_does_not_merge_into_a_call() {
2283        let src = "commons c\n\nfn f() -> Int {\n  status := Paid\n  ()\n}\n";
2284        let c = parse_str(src).unwrap_or_else(|e| panic!("parse failed: {e:?}"));
2285        let CommonsItem::Fn(f) = &c.items[0] else {
2286            panic!("expected fn, got {:?}", c.items[0]);
2287        };
2288        assert_eq!(
2289            f.body.statements.len(),
2290            1,
2291            "expected exactly one Assign statement, got {:?}",
2292            f.body.statements
2293        );
2294        let Statement::Assign(a) = &f.body.statements[0] else {
2295            panic!(
2296                "expected an Assign statement, got {:?}",
2297                f.body.statements[0]
2298            );
2299        };
2300        assert!(
2301            matches!(a.value.kind, ExprKind::Ident(_)),
2302            "assign value must stay the bare identifier `Paid`, got {:?}",
2303            a.value.kind
2304        );
2305        assert!(
2306            matches!(f.body.tail.kind, ExprKind::UnitLit),
2307            "the `()` must remain the block's own tail, got {:?}",
2308            f.body.tail.kind
2309        );
2310    }
2311
2312    /// #981: the same same-line rule extends to a method call's parens — a
2313    /// `.method` immediately followed, on its own line, by a standalone `()`
2314    /// must not merge into `.method()`.
2315    #[test]
2316    fn method_reference_followed_by_unit_tail_does_not_merge_into_a_call() {
2317        let src = "commons c\n\nfn f() -> Int {\n  let y = x.field\n  ()\n}\n";
2318        let c = parse_str(src).unwrap_or_else(|e| panic!("parse failed: {e:?}"));
2319        let CommonsItem::Fn(f) = &c.items[0] else {
2320            panic!("expected fn, got {:?}", c.items[0]);
2321        };
2322        let Statement::Let(l) = &f.body.statements[0] else {
2323            panic!("expected a Let statement, got {:?}", f.body.statements[0]);
2324        };
2325        assert!(
2326            matches!(l.value.kind, ExprKind::FieldAccess { .. }),
2327            "let value must stay a field access, got {:?}",
2328            l.value.kind
2329        );
2330        assert!(
2331            matches!(f.body.tail.kind, ExprKind::UnitLit),
2332            "the `()` must remain the block's own tail, got {:?}",
2333            f.body.tail.kind
2334        );
2335    }
2336
2337    #[test]
2338    fn unparenthesised_record_in_condition_head_now_errors() {
2339        // #636 narrowing (matches Rust): a record literal in condition *head*
2340        // position must be parenthesised. Unparenthesised, `Point` reads as the
2341        // discriminant and `{ x: 1 }` as the arm list, whose first "arm" `x: 1`
2342        // is not an arm — so the parse fails. Pinned so the divergence from the
2343        // (still-accepting) tree-sitter grammar is deliberate, not a bug.
2344        assert!(
2345            !body_err("match Point { x: 1 } { p => p }").is_empty(),
2346            "unparenthesised record discriminant should not parse",
2347        );
2348        // Parenthesised, the record is the discriminant and the match parses.
2349        let ExprKind::Match { discriminant, .. } = body_tail("match (Point { x: 1 }) { p => p }")
2350        else {
2351            panic!("expected Match for the parenthesised form");
2352        };
2353        let ExprKind::Paren(inner) = &discriminant.kind else {
2354            panic!(
2355                "expected a parenthesised discriminant, got {:?}",
2356                discriminant.kind
2357            );
2358        };
2359        assert!(matches!(&inner.kind, ExprKind::RecordConstruction { .. }));
2360    }
2361}