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::{
11    Token, TokenKind, comment_body, doc_block_content, doc_line_text, has_blank_line_between,
12};
13use crate::span::Span;
14mod declarations;
15mod expressions;
16mod statements;
17mod types;
18
19/// Side-channel store for line-comment trivia (v1.1 LSP spec §3.5).
20///
21/// Built once up-front by [`split_trivia`] from the raw lexer token stream.
22/// Comments are removed from the token stream the parser walks; their text
23/// is filed into `leading` (comments on lines preceding a content token)
24/// and `trailing` (a single comment on the same line as a content token).
25/// The parser consumes entries through [`TriviaTable::take_leading`] and
26/// [`TriviaTable::take_trailing`] as it recognises declarations.
27#[derive(Debug, Default)]
28struct TriviaTable {
29    /// `leading[i]` holds the comments that appear immediately before content
30    /// token `i` (zero or more `--` lines, in source order, not separated from
31    /// the token by another content token). A comment on its own line is
32    /// followed by a [`Comment::Blank`] when a blank line comes after it
33    /// (#1884).
34    leading: Vec<Vec<Comment>>,
35    /// `trailing[i]` holds an optional comment on the same source line as
36    /// content token `i`. Only one trailing comment is recorded per token
37    /// because a single `--` consumes the rest of the line. A doc block or an
38    /// opening `{` never has one: see [`split_trivia`].
39    trailing: Vec<Option<String>>,
40    /// Any pending leading comments at end-of-file (no content token
41    /// followed), shaped as a `leading` entry. Used to preserve file-trailing
42    /// comments.
43    epilogue: Vec<Comment>,
44}
45
46impl TriviaTable {
47    fn take_leading(&mut self, index: usize) -> Vec<Comment> {
48        match self.leading.get_mut(index) {
49            Some(v) => std::mem::take(v),
50            None => Vec::new(),
51        }
52    }
53
54    fn take_trailing(&mut self, index: usize) -> Option<String> {
55        self.trailing.get_mut(index).and_then(|s| s.take())
56    }
57
58    fn take_epilogue(&mut self) -> Vec<Comment> {
59        std::mem::take(&mut self.epilogue)
60    }
61
62    /// True when every entry has been drained via `take_leading`/
63    /// `take_trailing`/`take_epilogue` — i.e. no comment was silently
64    /// dropped. Each harvest is already a `mem::take`, so anything still
65    /// present here is exactly the set of comments that never reached an
66    /// AST `Trivia` field.
67    ///
68    /// Deliberately **not** wired into a `debug_assert!` in the general parse
69    /// path: expressions carry no per-node trivia (§ "Comment trivia" in the
70    /// 2026-07-27 pipeline review), so an ordinary, valid program with a
71    /// comment inside a `match`/list/record/binop — a common, accepted
72    /// pattern `bynk-fmt`'s own comment-loss guard already handles
73    /// gracefully — would leave `leading`/`trailing` non-empty and trip it on
74    /// every compile, not just on formatting. Instead surfaced through
75    /// [`parse_units_with_drain_check`] (finding #66), whose one caller
76    /// (`bynk-fmt`) *does* care about exactly this signal.
77    /// [`Self::epilogue_is_empty`] is the narrower, safe-to-assert check.
78    fn is_fully_drained(&self) -> bool {
79        self.leading.iter().all(Vec::is_empty)
80            && self.trailing.iter().all(Option::is_none)
81            && self.epilogue.is_empty()
82    }
83
84    /// True when no file-trailing comment was left stranded. Unlike
85    /// [`Self::is_fully_drained`], this is safe to assert unconditionally: a
86    /// clean file's epilogue is empty by construction (nothing pending at
87    /// EOF), and the one shape that legitimately populates it — a top-level
88    /// trailing comment — is drained by every parse path that calls
89    /// `take_epilogue`. A brace-form declaration that forgets to is exactly
90    /// the bug this catches.
91    fn epilogue_is_empty(&self) -> bool {
92        self.epilogue.is_empty()
93    }
94}
95
96/// Remove `Comment` trivia tokens from `tokens` and bin them into a
97/// [`TriviaTable`] keyed against the surviving content tokens. A comment
98/// on the same source line as the preceding content token is recorded as
99/// that token's *trailing* trivia, unless that token is a doc block or an
100/// opening `{`; everything else is *leading* for the next content token.
101///
102/// A blank line after a leading comment on its own line, before the next
103/// comment or content token, is recorded as a [`Comment::Blank`] (#1884). Only
104/// a declaration that canonical style sets apart with blank lines keeps it: see
105/// [`Parser::take_leading_trivia`].
106fn split_trivia(tokens: &[Token], source: &str) -> (Vec<Token>, TriviaTable) {
107    let mut filtered: Vec<Token> = Vec::with_capacity(tokens.len());
108    let mut table = TriviaTable::default();
109    let mut pending_leading: Vec<Comment> = Vec::new();
110    let mut last_content_end: Option<usize> = None;
111    // Where the last comment in `pending_leading` ends, if it has a line to
112    // itself: the gap from there to the next token says whether a blank line
113    // follows it. A comment's span stops before its newline, so a blank line
114    // is two newlines. A comment that leads only because it sits on a `{` line
115    // has no blank line of its own: the one below it was between the `{` and
116    // the first item.
117    let mut last_leading_end: Option<usize> = None;
118    for tok in tokens {
119        if !pending_leading.is_empty()
120            && let Some(end) = last_leading_end
121            && source[end..tok.span.start].matches('\n').count() >= 2
122        {
123            pending_leading.push(Comment::Blank);
124        }
125        if tok.kind == TokenKind::Comment {
126            let body = comment_body(source, tok.span).to_string();
127            // If nothing has been buffered as leading for the next token and
128            // there is no newline between the previous content token and
129            // this comment, it trails that token. A doc block never has a
130            // trailing comment: its token ends past the newline after the
131            // closing `---`, so a `--` line directly under it would otherwise
132            // look same-line and be binned where nothing collects it (#1756).
133            // A `--|` doc line (#1888) ends past its newline the same way.
134            // Nor does an opening `{`: no parser collects trivia right after
135            // one, so its comment leads whatever follows instead, the first
136            // item or the `}` of an empty body (#1788).
137            if pending_leading.is_empty()
138                && filtered.last().is_some_and(|t| {
139                    !matches!(
140                        t.kind,
141                        TokenKind::DocBlock | TokenKind::DocLine | TokenKind::LBrace
142                    )
143                })
144                && let Some(prev_end) = last_content_end
145                && !source[prev_end..tok.span.start].contains('\n')
146            {
147                let last_idx = filtered.len() - 1;
148                // Only attach if no trailing already recorded (shouldn't
149                // happen because `--` consumes through end-of-line).
150                if table.trailing[last_idx].is_none() {
151                    table.trailing[last_idx] = Some(body);
152                    continue;
153                }
154            }
155            // A doc block's token ends past its closing line's newline, so a
156            // comment under it is on its own line with no newline between. A
157            // `--|` doc line's token does too (#1888).
158            let own_line = !pending_leading.is_empty()
159                || filtered
160                    .last()
161                    .is_none_or(|t| matches!(t.kind, TokenKind::DocBlock | TokenKind::DocLine))
162                || last_content_end.is_some_and(|end| source[end..tok.span.start].contains('\n'));
163            pending_leading.push(Comment::Line(body));
164            last_leading_end = own_line.then_some(tok.span.end);
165            continue;
166        }
167        filtered.push(*tok);
168        table.leading.push(std::mem::take(&mut pending_leading));
169        table.trailing.push(None);
170        last_content_end = Some(tok.span.end);
171        last_leading_end = None;
172    }
173    table.epilogue = pending_leading;
174    (filtered, table)
175}
176
177/// Parse a token slice into a [`Commons`] AST.
178///
179/// Accepts either form of v0.3 commons file:
180/// - Brace form: `commons name { items... }` (v0–v0.2 compatible).
181/// - Fragment form: `commons name uses... items...` to EOF (v0.3).
182pub fn parse(tokens: &[Token], source: &str) -> Result<Commons, Vec<CompileError>> {
183    parse_with_warnings(tokens, source).map(|(c, _warnings)| c)
184}
185
186/// [`parse`] with the non-fatal diagnostics threaded out alongside the AST
187/// (ADR 0117) — see [`parse_units_with_warnings`].
188pub fn parse_with_warnings(
189    tokens: &[Token],
190    source: &str,
191) -> Result<(Commons, Vec<CompileError>), Vec<CompileError>> {
192    let (unit, warnings) = parse_unit_with_warnings(tokens, source)?;
193    match unit {
194        SourceUnit::Commons(c) => Ok((c, warnings)),
195        SourceUnit::Context(ctx) => Err(vec![
196            CompileError::new(
197                "bynk.parse.unexpected_context",
198                ctx.span,
199                "expected a `commons` declaration but found a `context` declaration",
200            )
201            .with_note(
202                "contexts must be compiled as part of a project — pass the source directory, e.g. `bynkc compile --target bundle --output out src`",
203            ),
204        ]),
205        SourceUnit::Suite(t) => Err(vec![
206            CompileError::new(
207                "bynk.parse.unexpected_suite",
208                t.span,
209                "expected a `commons` declaration but found a `suite` declaration",
210            )
211            .with_note(
212                "tests must be compiled as part of a project — pass the source directory, e.g. `bynkc compile --target bundle --output out src`",
213            ),
214        ]),
215        SourceUnit::Adapter(a) => Err(vec![
216            CompileError::new(
217                "bynk.parse.unexpected_adapter",
218                a.span,
219                "expected a `commons` declaration but found an `adapter` declaration",
220            )
221            .with_note(
222                "adapters must be compiled as part of a project — pass the source directory, e.g. `bynkc compile --target bundle --output out src`",
223            ),
224        ]),
225    }
226}
227
228/// Parse a token slice into a [`SourceUnit`] with error recovery, returning a
229/// best-effort partial AST plus the full list of parse errors and warnings.
230///
231/// Used by the LSP: item-level recovery skips past a malformed declaration to
232/// the next top-level item, so multiple errors are reported per compilation
233/// rather than just the first. Compared to [`parse_unit`], this never bails;
234/// if no SourceUnit could be parsed at all (e.g. the file is empty or the
235/// header itself fails) the returned `Option` is `None`.
236///
237/// Keeps only the *first* unit — v0.113 allows more than one top-level unit
238/// per file (an atomic `commons` + `suite`, DECISION S), and every existing
239/// caller here is keyed on the primary declaration. [`parse_units_with_recovery`]
240/// is the same recovery parse without that narrowing, for the one caller
241/// (finding #29/#30) that needs every unit a file declares.
242pub fn parse_unit_with_recovery(
243    tokens: &[Token],
244    source: &str,
245) -> (Option<SourceUnit>, Vec<CompileError>) {
246    let (units, errors) = parse_units_with_recovery(tokens, source);
247    (units.into_iter().next(), errors)
248}
249
250/// [`parse_unit_with_recovery`], keeping **every** top-level unit instead of
251/// discarding all but the first (finding #29/#30). Used by the IDE's own parse
252/// entry point, which needs to see a trailing `suite` in an atomic
253/// `commons`+`suite` file, not just the primary declaration.
254pub fn parse_units_with_recovery(
255    tokens: &[Token],
256    source: &str,
257) -> (Vec<SourceUnit>, Vec<CompileError>) {
258    let recovered = parse_units_recovering(tokens, source);
259    (recovered.units, recovered.errors)
260}
261
262/// #1663: everything a recovering parse learns — the units it built, every
263/// syntax error, and the names of the top-level declarations it had to skip.
264#[derive(Debug)]
265pub struct Recovered {
266    pub units: Vec<SourceUnit>,
267    pub errors: Vec<CompileError>,
268    /// Declarations recovery dropped, by name (see `Parser::broken_decl_names`).
269    /// A caller keeps references to them from echoing as unknown names.
270    pub broken_decl_names: Vec<String>,
271}
272
273/// #1663: a strict parse's error(s) together with a recovering parse's, each
274/// reported once. The strict errors come first and always survive: some rules
275/// hold only for the strict single-unit parse (`extra_tokens`, a `commons`
276/// expected but a `context` found), which the multi-unit recovering parse
277/// accepts. A recovered error at the same position as one already listed is
278/// the same fault (the two parsers may name it differently) and is dropped.
279pub fn merge_syntax_errors(
280    strict: Vec<CompileError>,
281    recovered: Vec<CompileError>,
282) -> Vec<CompileError> {
283    let mut out = strict;
284    for e in recovered {
285        if !out.iter().any(|o| o.span.start == e.span.start) {
286            out.push(e);
287        }
288    }
289    out
290}
291
292/// [`parse_units_with_recovery`], also returning the names of the declarations
293/// recovery skipped (#1663).
294pub fn parse_units_recovering(tokens: &[Token], source: &str) -> Recovered {
295    parse_units_recovering_from(tokens, source, &mut 0)
296}
297
298/// [`parse_units_recovering`], continuing [`ExprId`] allocation from `next_id`
299/// rather than starting at 0 — see [`parse_unit_with_warnings_from`]. #1710:
300/// the project path checks a recovered file's surviving declarations
301/// alongside every other file's, so its ids must come from the same durable
302/// counter (`bynk_project::parse_cache`), or they would collide.
303pub fn parse_units_recovering_from(tokens: &[Token], source: &str, next_id: &mut u32) -> Recovered {
304    let (filtered, trivia) = split_trivia(tokens, source);
305    let mut warnings = Vec::new();
306    let mut p = Parser::new(&filtered, source, trivia, &mut warnings);
307    p.recover_mode = true;
308    p.next_expr_id = *next_id;
309    let mut units = Vec::new();
310    loop {
311        match p.parse_unit() {
312            Ok(u) => units.push(u),
313            Err(e) => {
314                p.recovered_errors.push(e);
315                break;
316            }
317        }
318        // A genuinely malformed trailing declaration is still surfaced via
319        // recovery — checked *after* each successful parse, matching
320        // `parse_unit`'s own "at least once" attempt on the first unit (an
321        // empty file must still produce its usual unexpected-EOF diagnostic,
322        // not silently yield an empty `units` with no error at all).
323        if p.peek().is_none() {
324            break;
325        }
326    }
327    let broken_decl_names = std::mem::take(&mut p.broken_decl_names);
328    *next_id = p.next_expr_id;
329    let mut all_errors = p.recovered_errors;
330    all_errors.append(&mut p.doc_errors);
331    all_errors.append(&mut warnings);
332    Recovered {
333        units,
334        errors: all_errors,
335        broken_decl_names,
336    }
337}
338
339/// Parse a token slice into a [`SourceUnit`] — either a commons or a context.
340///
341/// Each `.bynk` file is exactly one declaration of one kind.
342pub fn parse_unit(tokens: &[Token], source: &str) -> Result<SourceUnit, Vec<CompileError>> {
343    parse_unit_with_warnings(tokens, source).map(|(unit, _warnings)| unit)
344}
345
346/// [`parse_unit`] with the non-fatal diagnostics threaded out alongside the
347/// AST (ADR 0117) — see [`parse_units_with_warnings`].
348pub fn parse_unit_with_warnings(
349    tokens: &[Token],
350    source: &str,
351) -> Result<(SourceUnit, Vec<CompileError>), Vec<CompileError>> {
352    parse_unit_with_warnings_from(tokens, source, &mut 0)
353}
354
355/// [`parse_unit_with_warnings`], continuing [`ExprId`] allocation from
356/// `next_id` instead of starting at 0, and writing the id one past the last
357/// one this parse handed out back into it. T3.4 (R2.4): every top-level parse
358/// entry point in this file constructs its own `Parser` and therefore its own
359/// zero-based id space; a caller that will check two files' output together
360/// in one pass (a multi-file commons — `bynk-emit`'s `collect_unit_methods`
361/// merges a type's methods from sibling files into the file that declares the
362/// type, before one `check_record` call) must thread one counter across every
363/// file it parses, or two independently-numbered files collide on the same
364/// id in the same `expr_types` map. Every *other* caller (a single buffer, an
365/// LSP hover/completion query, a fixture test) never merges its output with
366/// another file's before checking, so starting at 0 every time is correct —
367/// [`parse_unit_with_warnings`] above is that default, unchanged.
368pub fn parse_unit_with_warnings_from(
369    tokens: &[Token],
370    source: &str,
371    next_id: &mut u32,
372) -> Result<(SourceUnit, Vec<CompileError>), Vec<CompileError>> {
373    let (filtered, trivia) = split_trivia(tokens, source);
374    let mut warnings = Vec::new();
375    let mut p = Parser::new(&filtered, source, trivia, &mut warnings);
376    p.next_expr_id = *next_id;
377    let result = match p.parse_unit() {
378        Ok(u) => {
379            if let Some(extra) = p.peek() {
380                Err(vec![
381                    CompileError::new(
382                        "bynk.parse.extra_tokens",
383                        extra.span,
384                        "unexpected token after top-level declaration",
385                    )
386                    .with_note(
387                        "a `.bynk` file contains exactly one `commons` or `context` declaration",
388                    ),
389                ])
390            } else {
391                Ok(u)
392            }
393        }
394        Err(e) => Err(vec![e]),
395    };
396    // #1885: a doc block that holds code fails the parse, after any error the
397    // parse itself hit.
398    let result = match (result, std::mem::take(&mut p.doc_errors)) {
399        (r, docs) if docs.is_empty() => r,
400        (Ok(_), docs) => Err(docs),
401        (Err(mut errs), mut docs) => {
402            errs.append(&mut docs);
403            Err(errs)
404        }
405    };
406    *next_id = p.next_expr_id;
407    // ADR 0117: warnings (e.g. orphan doc blocks) ride alongside a successful
408    // parse — severity governs gating at the caller, not here.
409    match result {
410        Ok(u) => {
411            // See `parse_units_with_warnings`: a file-trailing comment must
412            // have been drained by `take_epilogue`.
413            debug_assert!(
414                p.trivia.epilogue_is_empty(),
415                "a file-trailing comment was left undrained after a successful parse"
416            );
417            Ok((u, warnings))
418        }
419        Err(mut errs) => {
420            errs.append(&mut warnings);
421            Err(errs)
422        }
423    }
424}
425
426/// Parse a token slice into **all** the top-level [`SourceUnit`]s in one file
427/// (v0.113, testing track slice 1b). A `.bynk` file may hold more than one
428/// top-level declaration — an *atomic* file with `commons`/`context` **and** a
429/// `suite` together (DECISION S) — so the compiler parses a `Vec`, not a single
430/// unit. Test-ness is a property of each declaration, not of the file.
431///
432/// Bails on the first malformed declaration (like [`parse_unit`], not the
433/// recovering LSP path). An empty file is an error.
434pub fn parse_units(tokens: &[Token], source: &str) -> Result<Vec<SourceUnit>, Vec<CompileError>> {
435    parse_units_with_warnings(tokens, source).map(|(units, _warnings)| units)
436}
437
438/// [`parse_units`] with the non-fatal diagnostics threaded out alongside the
439/// AST (ADR 0117): a successful parse returns `Ok((units, warnings))` instead
440/// of hard-failing on a warning-severity diagnostic (an orphan doc block used
441/// to abort file discovery and throw the good AST away). A failed parse still
442/// returns every diagnostic — errors then warnings — in the `Err`.
443pub fn parse_units_with_warnings(
444    tokens: &[Token],
445    source: &str,
446) -> Result<(Vec<SourceUnit>, Vec<CompileError>), Vec<CompileError>> {
447    parse_units_with_drain_check(tokens, source)
448        .map(|(units, warnings, _drained)| (units, warnings))
449}
450
451/// [`parse_units_with_warnings`], continuing [`ExprId`] allocation from
452/// `next_id` rather than starting at 0 — see [`parse_unit_with_warnings_from`]
453/// for why this exists. The one production caller is `bynk-emit`'s per-file
454/// parse loop (`phase_parse`), which owns one counter across every file in a
455/// single project parse so two files whose methods later get merged into one
456/// `check_record` call (a multi-file commons) never collide.
457pub fn parse_units_with_warnings_from(
458    tokens: &[Token],
459    source: &str,
460    next_id: &mut u32,
461) -> Result<(Vec<SourceUnit>, Vec<CompileError>), Vec<CompileError>> {
462    parse_units_with_drain_check_from(tokens, source, next_id)
463        .map(|(units, warnings, _drained)| (units, warnings))
464}
465
466/// [`parse_units_with_warnings`] plus whether every comment's trivia was
467/// drained into the AST (`TriviaTable::is_fully_drained`). Finding #66:
468/// `bynk-fmt`'s comment-preservation guard re-tokenized its own rendered
469/// output just to diff comment bodies against the input — wasted work in the
470/// overwhelming common case where nothing was left behind. That guard uses
471/// this drain signal, computed from the same parse it already needs for
472/// rendering, as a fast-path: `true` means every comment landed in the AST,
473/// so re-checking the output can be skipped outright. No other caller needs
474/// the signal, so it rides its own entry point rather than widening
475/// [`parse_units_with_warnings`].
476pub fn parse_units_with_drain_check(
477    tokens: &[Token],
478    source: &str,
479) -> Result<(Vec<SourceUnit>, Vec<CompileError>, bool), Vec<CompileError>> {
480    parse_units_with_drain_check_from(tokens, source, &mut 0)
481}
482
483/// [`parse_units_with_drain_check`], continuing [`ExprId`] allocation from
484/// `next_id` — see [`parse_unit_with_warnings_from`].
485pub fn parse_units_with_drain_check_from(
486    tokens: &[Token],
487    source: &str,
488    next_id: &mut u32,
489) -> Result<(Vec<SourceUnit>, Vec<CompileError>, bool), Vec<CompileError>> {
490    let (filtered, trivia) = split_trivia(tokens, source);
491    let mut warnings = Vec::new();
492    let mut p = Parser::new(&filtered, source, trivia, &mut warnings);
493    p.next_expr_id = *next_id;
494    let mut units = Vec::new();
495    let mut errors: Vec<CompileError> = Vec::new();
496    while p.peek().is_some() {
497        match p.parse_unit() {
498            Ok(u) => units.push(u),
499            Err(e) => {
500                errors.push(e);
501                break;
502            }
503        }
504    }
505    // #1885: a doc block that holds code fails the parse.
506    errors.append(&mut p.doc_errors);
507    *next_id = p.next_expr_id;
508    let eof = p.eof_span();
509    let fully_drained = p.trivia.is_fully_drained();
510    // `p` (and thus its `&mut warnings` borrow) is no longer used past here, so
511    // the local `warnings` are readable again.
512    if !errors.is_empty() {
513        errors.append(&mut warnings);
514        return Err(errors);
515    }
516    if units.is_empty() {
517        return Err(vec![CompileError::new(
518            "bynk.parse.unexpected_eof",
519            eof,
520            "expected `commons`, `context`, or `suite` to start the file, found end of file",
521        )]);
522    }
523    // A file-trailing comment must have been drained by `take_epilogue` — the
524    // one brace-form declarations forgot to call (a live comment-loss bug,
525    // not the fundamentally-unfixed expression-interior case: expressions
526    // carry no trivia at all, so asserting full drainage here would fire on
527    // any ordinary program with a comment inside a `match`/list/record, which
528    // `bynk-fmt`'s own comment-loss guard already handles gracefully rather
529    // than as a hard failure).
530    debug_assert!(
531        p.trivia.epilogue_is_empty(),
532        "a file-trailing comment was left undrained after a successful parse"
533    );
534    Ok((units, warnings, fully_drained))
535}
536
537/// A signed numeric literal in refinement-bound position (v0.21): `InRange`
538/// bounds are either both `Int` or both `Float`.
539enum SignedNumLit {
540    Int(IntBound),
541    Float(FloatBound),
542}
543
544struct Parser<'a> {
545    tokens: &'a [Token],
546    source: &'a str,
547    pos: usize,
548    /// Accumulated non-fatal diagnostics. v0.3 uses this for orphan-doc
549    /// warnings, which are emitted as errors with a distinguishable category.
550    warnings: &'a mut Vec<CompileError>,
551    /// When true, the item-level loops catch errors from individual item
552    /// parses, push them into `recovered_errors`, and skip forward to the
553    /// next top-level item boundary instead of bailing. Used by the LSP via
554    /// [`parse_unit_with_recovery`]; disabled in the normal `parse` path so
555    /// existing single-error behaviour is preserved.
556    recover_mode: bool,
557    /// Errors collected during recovery-mode parsing. Only populated when
558    /// `recover_mode` is true.
559    recovered_errors: Vec<CompileError>,
560    /// #1663: the token index where the current top-level item starts, set by
561    /// each top-level item loop. Read once by [`Self::handle_item_err`] to name
562    /// the declaration a recovery skips.
563    pub(crate) item_start: Option<usize>,
564    /// #1663: the names of top-level declarations recovery skipped (a `type`,
565    /// `fn`, method, `event`, `capability`, `service`, `agent` or `actor`).
566    /// They are known names whose declaration is broken, so a caller can keep
567    /// references to them from echoing as unknown. Recovery mode only.
568    broken_decl_names: Vec<String>,
569    /// Line-comment trivia separated from the token stream. See
570    /// [`TriviaTable`].
571    trivia: TriviaTable,
572    /// Live recursion depth of the three self-recursive parse entry points
573    /// (`parse_expr`, `parse_type_ref`, `parse_pattern`). Incremented on entry
574    /// and decremented on exit by [`Parser::enter_recursion`] so it tracks the
575    /// current stack depth; when it exceeds [`crate::MAX_NESTING_DEPTH`] the
576    /// parser reports a bounded-depth diagnostic instead of overflowing its
577    /// stack (#713).
578    depth: usize,
579    /// When true, a bare `ident {` on the *spine* of the current expression is
580    /// an identifier followed by an unrelated block, never a record
581    /// construction — so an `if`/`match` condition that ends in a bare
582    /// identifier does not swallow the branch/arm block as `Ident { field }`
583    /// (#636). Set only around the condition parse (see [`parse_cond_expr`]);
584    /// `parse_expr` clears it, so the restriction is lifted inside any
585    /// delimited sub-expression (parentheses, call arguments, list, record
586    /// field). Mirrors Rust's `NO_STRUCT_LITERAL` restriction.
587    no_record_literal: bool,
588    /// Running count of unclosed `{` seen so far — maintained solely by
589    /// [`Self::bump`] (the one primitive that advances `self.pos`), so it
590    /// always reflects the true nesting depth no matter which parse function
591    /// is on the call stack. Finding #27/#30: `recover_to_top_item` reads it
592    /// against [`Self::item_loop_baseline`] to tell "the enclosing item
593    /// loop's own closing brace" apart from a still-unclosed nested
594    /// construct's — without it, a sync scan that started partway through
595    /// such a construct (an error deep inside a function body) stopped at the
596    /// first `}` it saw, however deeply nested, and the enclosing item loop
597    /// mistook that for its own body's end.
598    brace_depth: usize,
599    /// Stack of `brace_depth` snapshots, one per active item-loop body
600    /// (commons/context/adapter/suite) — pushed right after that body's own
601    /// `{` is consumed (or at loop entry, for a brace-free fragment form),
602    /// popped at the loop's normal exit. `recover_to_top_item` treats its top
603    /// entry as the depth an `}` must return to before it counts as the
604    /// enclosing body's own closing brace rather than a nested construct's.
605    item_loop_baseline: Vec<usize>,
606    /// T3.4 (R2.4): next [`ExprId`] to hand out — monotonic, incremented by
607    /// [`Self::alloc_expr_id`], the sole allocation point every `Expr`
608    /// construction site in this parser calls.
609    next_expr_id: u32,
610    /// #1885: `bynk.parse.doc_block_contains_code` errors found by
611    /// [`Self::take_doc_block`]. Kept apart from the item `Result` path so a
612    /// doc block, which every item loop reads before its recovery point, never
613    /// aborts the body it sits in; each entry point turns them into errors.
614    doc_errors: Vec<CompileError>,
615    /// #1885: true for the parser [`doc_content_is_code`] runs over a doc
616    /// block's content. It skips the code check on doc blocks inside that
617    /// content, so the probe stays a plain parse.
618    doc_probe: bool,
619}
620
621impl<'a> Parser<'a> {
622    fn new(
623        tokens: &'a [Token],
624        source: &'a str,
625        trivia: TriviaTable,
626        warnings: &'a mut Vec<CompileError>,
627    ) -> Self {
628        Self {
629            tokens,
630            source,
631            pos: 0,
632            warnings,
633            recover_mode: false,
634            recovered_errors: Vec::new(),
635            item_start: None,
636            broken_decl_names: Vec::new(),
637            trivia,
638            depth: 0,
639            no_record_literal: false,
640            brace_depth: 0,
641            item_loop_baseline: Vec::new(),
642            next_expr_id: 0,
643            doc_errors: Vec::new(),
644            doc_probe: false,
645        }
646    }
647
648    /// T3.4 (R2.4): allocate the next [`ExprId`]. The sole allocation point —
649    /// every `Expr { id: self.alloc_expr_id(), .. }` construction in this
650    /// parser calls it exactly once, so two nodes never share an id and every
651    /// id a caller holds was actually handed out here.
652    fn alloc_expr_id(&mut self) -> ExprId {
653        let id = ExprId(self.next_expr_id);
654        self.next_expr_id += 1;
655        id
656    }
657
658    /// Enter a self-recursive parse step, bumping the live recursion depth and
659    /// failing with a bounded-depth diagnostic if it would exceed
660    /// [`crate::MAX_NESTING_DEPTH`]. The caller pairs a successful entry with a
661    /// matching `self.depth -= 1` on the way out (see `parse_expr` /
662    /// `parse_type_ref`); on the error path the depth is restored here so a
663    /// recovering caller is not left mis-counted. `what` names the construct
664    /// for the message (e.g. "this expression", "this type"). See #713.
665    fn enter_recursion(&mut self, what: &str) -> Result<(), CompileError> {
666        self.depth += 1;
667        if self.depth > crate::MAX_NESTING_DEPTH {
668            self.depth -= 1;
669            let span = self
670                .peek()
671                .map(|t| t.span)
672                .unwrap_or_else(|| self.eof_span());
673            return Err(self.nesting_too_deep(span, what));
674        }
675        Ok(())
676    }
677
678    /// The bounded-depth diagnostic shared by [`enter_recursion`] and
679    /// [`enter_chain_fold`].
680    fn nesting_too_deep(&self, span: Span, what: &str) -> CompileError {
681        CompileError::new(
682            "bynk.parse.nesting_too_deep",
683            span,
684            format!(
685                "{what} nests more than {} levels deep",
686                crate::MAX_NESTING_DEPTH
687            ),
688        )
689        .with_note(
690            "deeply nested source is rejected to keep the parser from overflowing its \
691             stack and aborting; flatten or split the construct",
692        )
693    }
694
695    /// The bounded-depth diagnostic for the *iteratively*-built spines —
696    /// associative operator chains ([`enter_chain_fold`]) and postfix receiver
697    /// chains ([`deepen_spine`]). Same code as [`nesting_too_deep`] (one budget,
698    /// one diagnostic) but phrased for a flat chain, which is long rather than
699    /// *nested*, and points at the idiomatic fix.
700    fn expression_too_long(&self, span: Span) -> CompileError {
701        CompileError::new(
702            "bynk.parse.nesting_too_deep",
703            span,
704            format!(
705                "this expression is more than {} levels deep",
706                crate::MAX_NESTING_DEPTH
707            ),
708        )
709        .with_note(
710            "a long operator or member chain is rejected to keep the compiler from overflowing \
711             its stack; split it across `let` bindings, or reduce a sequence with \
712             `.sum()`/`.fold(...)`",
713        )
714    }
715
716    /// Count one more operand folded onto an associative operator chain against
717    /// the same recursion budget as [`enter_recursion`] (#714).
718    ///
719    /// Associative chains (`+`, `*`, `&&`, `||`) are built *iteratively* in the
720    /// precedence ladder, so — unlike parentheses, calls, or `implies` — they
721    /// never re-enter `parse_expr` and thus slip past the `enter_recursion`
722    /// guard. Yet each fold deepens the left-nested `Expr` tree by one level,
723    /// and a long flat chain (`1 + 1 + … + 1`) overflows every *recursive*
724    /// consumer of that tree downstream — the checker's `type_of`, the
725    /// formatter, the emitter, and the AST's own recursive `Drop` — exactly as
726    /// deeply nested source overflows the parser. Counting each fold on the
727    /// shared `depth` budget bounds the whole expression's height, and because
728    /// it is the *same* budget it composes with the ambient nesting depth, so a
729    /// chain buried inside deeply nested source cannot exceed the bound either.
730    ///
731    /// The caller accumulates `folds` and subtracts them from `depth` before it
732    /// returns, so the live count unwinds as a recursive descent would; on the
733    /// overflow path the whole chain's contribution is restored here so a
734    /// recovering caller is not left mis-counted.
735    fn enter_chain_fold(&mut self, folds: &mut usize, span: Span) -> Result<(), CompileError> {
736        self.depth += 1;
737        *folds += 1;
738        if self.depth > crate::MAX_NESTING_DEPTH {
739            self.depth -= *folds;
740            *folds = 0;
741            return Err(self.expression_too_long(span));
742        }
743        Ok(())
744    }
745
746    /// Count one more level of an iteratively-built postfix receiver spine
747    /// (`a.b.c…`, `f()?.g()…`) against the shared budget (#714). Like
748    /// [`enter_chain_fold`], postfix loops rather than recurses, so a long spine
749    /// escapes [`enter_recursion`] yet grows an arbitrarily deep receiver tree
750    /// that the downstream walks recurse through. `parse_postfix` restores
751    /// `depth` wholesale on the way out (its many error paths make a
752    /// save/restore wrapper cleaner than per-fold unwinding), so this only bumps
753    /// and checks.
754    fn deepen_spine(&mut self, span: Span) -> Result<(), CompileError> {
755        self.depth += 1;
756        if self.depth > crate::MAX_NESTING_DEPTH {
757            return Err(self.expression_too_long(span));
758        }
759        Ok(())
760    }
761
762    /// Comments immediately preceding the current peek position. Consumed
763    /// (the table entry is cleared) so the same comments are not attached
764    /// to two nodes.
765    ///
766    /// For a slot in a body: canonical style has no blank lines there, so a
767    /// [`Comment::Blank`] is dropped and the comments stay attached to what
768    /// follows. A declaration set apart by blank lines (one at unit level, or a
769    /// service's or agent's section or handler) keeps it, through
770    /// [`Self::take_unit_leading_trivia`].
771    fn take_leading_trivia(&mut self) -> Vec<Comment> {
772        let mut comments = self.take_unit_leading_trivia();
773        comments.retain(|c| *c != Comment::Blank);
774        comments
775    }
776
777    /// [`Self::take_leading_trivia`] for a slot set apart by blank lines, keeping each
778    /// [`Comment::Blank`] (#1884).
779    fn take_unit_leading_trivia(&mut self) -> Vec<Comment> {
780        self.trivia.take_leading(self.pos)
781    }
782
783    /// The comments after the last token, which close the file at unit level,
784    /// so a [`Comment::Blank`] between them is kept.
785    fn take_epilogue_trivia(&mut self) -> Vec<Comment> {
786        self.trivia.take_epilogue()
787    }
788
789    /// Trailing comment, if any, on the same source line as the most
790    /// recently consumed content token. Call AFTER finishing a declaration
791    /// or statement, while `self.pos` points one past its last token.
792    fn take_trailing_trivia(&mut self) -> Option<String> {
793        if self.pos == 0 {
794            return None;
795        }
796        self.trivia.take_trailing(self.pos - 1)
797    }
798
799    /// Handle a per-item parse error. In recovery mode, record the error and
800    /// advance to the next sync point so the item loop can continue; otherwise
801    /// propagate as a hard failure.
802    fn handle_item_err(&mut self, e: CompileError) -> Result<(), CompileError> {
803        if self.recover_mode {
804            self.recovered_errors.push(e);
805            if let Some(start) = self.item_start.take() {
806                self.broken_decl_names.extend(self.decl_names_at(start));
807            }
808            let before = self.pos;
809            self.recover_to_top_item();
810            // The sync target may be the very token that produced the error —
811            // a context-only keyword (`capability`, `service`, …) at item
812            // position in a commons errors *without consuming it*, and it is
813            // itself a sync point. Recovery must always make progress, or the
814            // item loop re-reports the same error until memory runs out
815            // (found by the `parse` fuzz target on a seed input).
816            // #1663: unless the cursor is on the `}` that closes this item
817            // loop's own body (an error raised at the end of the last item):
818            // consuming it would make the body look unclosed, a follow-on
819            // `unexpected_eof`. The loop ends on that `}` itself. A fragment-
820            // form loop has no closing brace (baseline 0), so a stray `}`
821            // there is still consumed.
822            let baseline = self.item_loop_baseline.last().copied().unwrap_or(0);
823            let closes_body = baseline > 0
824                && self.brace_depth == baseline
825                && self.peek_kind() == Some(TokenKind::RBrace);
826            if self.pos == before && !closes_body {
827                self.bump();
828                // #1663: and skip the rest of the rejected item too (`agent`
829                // in a commons: its name and `{ … }` body). Otherwise the next
830                // loop iteration misreads its name as a malformed item and
831                // reports a second, follow-on syntax error.
832                self.recover_to_top_item();
833            }
834            Ok(())
835        } else {
836            Err(e)
837        }
838    }
839
840    /// #1663: the name(s) a declaration starting at token `start` declares:
841    /// `type T`, `fn f`, `fn T.m` (recorded as `T.m`), `event E`,
842    /// `capability C`, `service S`, `agent A`, `actor A`. Empty when the item
843    /// is not one of those or its name never parsed (`fn ( -> Int`).
844    fn decl_names_at(&self, start: usize) -> Vec<String> {
845        let kind_at = |i: usize| self.tokens.get(i).map(|t| t.kind);
846        let ident_at = |i: usize| {
847            self.tokens
848                .get(i)
849                .filter(|t| t.kind == TokenKind::Ident)
850                .map(|t| self.slice(t.span).to_string())
851        };
852        match kind_at(start) {
853            Some(
854                TokenKind::Type
855                | TokenKind::Event
856                | TokenKind::Capability
857                | TokenKind::Service
858                | TokenKind::Agent
859                | TokenKind::Actor,
860            ) => ident_at(start + 1).into_iter().collect(),
861            Some(TokenKind::Fn) => match (ident_at(start + 1), kind_at(start + 2)) {
862                // A method is recorded qualified, `T.m`, so a broken method
863                // is never mistaken for a free `fn m` of the same name.
864                (Some(owner), Some(TokenKind::Dot)) => match ident_at(start + 3) {
865                    Some(method) => vec![format!("{owner}.{method}")],
866                    None => Vec::new(),
867                },
868                (Some(name), _) => vec![name],
869                (None, _) => Vec::new(),
870            },
871            _ => Vec::new(),
872        }
873    }
874
875    /// Skip forward to the next top-level item boundary: either an
876    /// [`is_item_start`] keyword at the enclosing item loop's own nesting
877    /// depth, a closing brace that returns to that depth, or end-of-input.
878    /// Used only in recovery mode.
879    ///
880    /// Finding #27/#30: brace-depth-gated against
881    /// [`Self::item_loop_baseline`], so a `}` deep inside a still-unclosed
882    /// nested construct (an error partway through a function body, itself
883    /// inside a `match` arm) is skipped over rather than mistaken for the
884    /// enclosing body's own closing brace — the old flat scan stopped at
885    /// literally the first `}` it saw, however deep, handing the item loop a
886    /// brace that did not belong to it and making it return with zero items.
887    fn recover_to_top_item(&mut self) {
888        let baseline = self.item_loop_baseline.last().copied().unwrap_or(0);
889        while let Some(t) = self.peek() {
890            match t.kind {
891                TokenKind::RBrace if self.brace_depth == baseline => return,
892                _ if self.brace_depth == baseline && is_item_start(t.kind) => return,
893                _ => {
894                    self.bump();
895                }
896            }
897        }
898    }
899
900    /// Mark the start of a top-level item loop (`declarations.rs`'s
901    /// `parse_commons_brace`/`_fragment`, `parse_context_brace`/`_fragment`,
902    /// `parse_test_brace`/`_fragment`, `parse_adapter_body`) — called right
903    /// after that body's own `{` is consumed (brace form) or at the loop's
904    /// own entry (fragment form, which has no enclosing brace of its own).
905    /// Paired with [`Self::exit_item_loop`] at the loop's normal exit.
906    fn enter_item_loop(&mut self) {
907        self.item_loop_baseline.push(self.brace_depth);
908    }
909
910    /// Pair of [`Self::enter_item_loop`].
911    fn exit_item_loop(&mut self) {
912        self.item_loop_baseline.pop();
913    }
914
915    fn peek(&self) -> Option<Token> {
916        self.tokens.get(self.pos).copied()
917    }
918
919    fn peek_kind(&self) -> Option<TokenKind> {
920        self.peek().map(|t| t.kind)
921    }
922
923    /// The token `n` positions ahead of the cursor (`nth(0)` == `peek()`).
924    fn nth(&self, n: usize) -> Option<Token> {
925        self.tokens.get(self.pos + n).copied()
926    }
927
928    fn nth_kind(&self, n: usize) -> Option<TokenKind> {
929        self.nth(n).map(|t| t.kind)
930    }
931
932    /// The source text of the token `n` positions ahead, or `""` if none.
933    fn nth_text(&self, n: usize) -> &'a str {
934        self.nth(n).map(|t| self.slice(t.span)).unwrap_or("")
935    }
936
937    /// The span of the most recently consumed token (`self.pos - 1`). Falls back
938    /// to the current token's span when nothing has been consumed yet.
939    fn prev_span(&self) -> Span {
940        self.tokens
941            .get(self.pos.wrapping_sub(1))
942            .or_else(|| self.peek_ref())
943            .map(|t| t.span)
944            .unwrap_or_default()
945    }
946
947    fn peek_ref(&self) -> Option<&Token> {
948        self.tokens.get(self.pos)
949    }
950
951    fn bump(&mut self) -> Option<Token> {
952        let t = self.peek();
953        if let Some(t) = t {
954            match t.kind {
955                TokenKind::LBrace => self.brace_depth += 1,
956                TokenKind::RBrace => self.brace_depth = self.brace_depth.saturating_sub(1),
957                _ => {}
958            }
959            self.pos += 1;
960        }
961        t
962    }
963
964    fn eat(&mut self, kind: TokenKind) -> Option<Token> {
965        if self.peek_kind() == Some(kind) {
966            self.bump()
967        } else {
968            None
969        }
970    }
971
972    fn slice(&self, span: Span) -> &'a str {
973        &self.source[span.range()]
974    }
975
976    /// True when the next token sits on a later line than `prev`. Used to
977    /// keep a `[` that opens a new line out of the postfix type-application
978    /// form: `f` followed by `[1, 2]` on the next line is an identifier and
979    /// a list literal, not `f[…]` (v0.20b).
980    fn next_token_on_new_line(&self, prev: Span) -> bool {
981        match self.peek() {
982            Some(t) if prev.end <= t.span.start => {
983                self.source[prev.end..t.span.start].contains('\n')
984            }
985            _ => false,
986        }
987    }
988
989    /// Span pointing at the end of input — used for "unexpected EOF" reports.
990    /// The start backs up to the **start of the final char**, not `len - 1`, so
991    /// the span never splits a multibyte codepoint (an unterminated construct
992    /// whose last line ends in non-ASCII — e.g. a `--` comment ending in `→`).
993    fn eof_span(&self) -> Span {
994        let end = self.source.len();
995        let start = (0..end)
996            .rev()
997            .find(|&i| self.source.is_char_boundary(i))
998            .unwrap_or(0);
999        Span::new(start, end)
1000    }
1001
1002    fn expect(&mut self, kind: TokenKind, ctx: &str) -> Result<Token, CompileError> {
1003        match self.peek() {
1004            Some(t) if t.kind == kind => {
1005                self.bump();
1006                Ok(t)
1007            }
1008            Some(t) => Err(CompileError::new(
1009                "bynk.parse.expected_token",
1010                t.span,
1011                format!(
1012                    "expected {} {ctx}, found {}",
1013                    kind.describe(),
1014                    t.kind.describe()
1015                ),
1016            )),
1017            None => Err(CompileError::new(
1018                "bynk.parse.unexpected_eof",
1019                self.eof_span(),
1020                format!("expected {} {ctx}, found end of file", kind.describe()),
1021            )),
1022        }
1023    }
1024
1025    fn expect_ident(&mut self, ctx: &str) -> Result<Ident, CompileError> {
1026        match self.peek() {
1027            Some(t) if t.kind == TokenKind::Ident => {
1028                self.bump();
1029                Ok(Ident {
1030                    name: self.slice(t.span).to_string(),
1031                    span: t.span,
1032                })
1033            }
1034            // v0.5 contextual keyword `on` doubles as an identifier in
1035            // expression / field-access positions so users can name fields and
1036            // parameters using it. It retains its keyword meaning only at
1037            // handler-decl-level (`on call(...)`).
1038            //
1039            // v0.7 / v0.112: `suite` and `case` are contextual too — they
1040            // introduce the suite declaration and its cases, but are perfectly
1041            // valid commons/context/field names otherwise.
1042            //
1043            // The tier is single-sourced in `keywords::RESERVED_CONTEXTUAL`:
1044            // this arm defers to it rather than hardcoding the token kinds, so
1045            // extending that list is enough to admit a new contextual keyword
1046            // here. Each of these words lexes only to its own token, so matching
1047            // the source text is equivalent to matching the kind.
1048            Some(t) if crate::keywords::is_reserved_contextual(self.slice(t.span)) => {
1049                self.bump();
1050                Ok(Ident {
1051                    name: self.slice(t.span).to_string(),
1052                    span: t.span,
1053                })
1054            }
1055            Some(t) if is_reserved_keyword(t.kind) => Err(CompileError::new(
1056                "bynk.parse.reserved_keyword",
1057                t.span,
1058                format!(
1059                    "expected identifier {ctx}, but `{}` is a reserved keyword",
1060                    self.slice(t.span)
1061                ),
1062            )
1063            .with_note("rename the identifier to something that is not a keyword")),
1064            Some(t) => Err(CompileError::new(
1065                "bynk.parse.expected_token",
1066                t.span,
1067                format!("expected identifier {ctx}, found {}", t.kind.describe()),
1068            )),
1069            None => Err(CompileError::new(
1070                "bynk.parse.unexpected_eof",
1071                self.eof_span(),
1072                format!("expected identifier {ctx}, found end of file"),
1073            )),
1074        }
1075    }
1076
1077    // -- top level --
1078
1079    /// Consume an optional doc at the current position, in either form,
1080    /// returning the doc and its span (through the end of its last line).
1081    /// Returns None if the next token is not a doc.
1082    ///
1083    /// #1885: a doc block whose content is code (see [`doc_content_is_code`])
1084    /// records `bynk.parse.doc_block_contains_code` in [`Self::doc_errors`];
1085    /// the block is still returned, so parsing carries on around it. A run of
1086    /// `--|` lines is not probed: each line is marked as a doc on purpose, so
1087    /// it cannot swallow code by accident the way a pair of dividers can.
1088    ///
1089    /// #1888: a doc of one form directly followed by a doc of the other (no
1090    /// blank line between) records `bynk.parse.doc_forms_mixed` the same way;
1091    /// both are consumed and the first is returned.
1092    fn take_doc_block(&mut self) -> Option<(Doc, Span)> {
1093        let (doc, span) = self.take_one_doc()?;
1094        if let Some(next) = self.peek()
1095            && next.kind != doc_token_kind(doc.form)
1096            && matches!(next.kind, TokenKind::DocBlock | TokenKind::DocLine)
1097            && !has_blank_line_between(self.source, span.end, next.span.start)
1098        {
1099            let (_, other) = self.take_one_doc().expect("a doc token was peeked");
1100            if !self.doc_probe {
1101                self.doc_errors.push(doc_forms_mixed(span, other));
1102            }
1103        }
1104        Some((doc, span))
1105    }
1106
1107    /// One doc of one form: a `---` block, or a run of `--|` lines, each on
1108    /// the line right after the last (#1888, DECISION B). The run's text keeps
1109    /// its line breaks, so a bare `--|` separates Markdown paragraphs.
1110    fn take_one_doc(&mut self) -> Option<(Doc, Span)> {
1111        match self.peek_kind()? {
1112            TokenKind::DocBlock => {
1113                let t = self.bump().unwrap();
1114                let body = doc_block_content(self.source, t.span);
1115                if !self.doc_probe && doc_content_is_code(&body) {
1116                    self.doc_errors.push(doc_block_contains_code(t.span));
1117                }
1118                Some((Doc::new(body, DocForm::Block), t.span))
1119            }
1120            TokenKind::DocLine => {
1121                let first = self.bump().unwrap();
1122                let mut span = first.span;
1123                let mut lines = vec![doc_line_text(self.source, first.span)];
1124                // A doc line's span ends past its newline, so the next line of
1125                // the run starts after indentation alone.
1126                while let Some(next) = self.peek()
1127                    && next.kind == TokenKind::DocLine
1128                    && self.source[span.end..next.span.start]
1129                        .bytes()
1130                        .all(|b| matches!(b, b' ' | b'\t' | b'\r'))
1131                {
1132                    let t = self.bump().unwrap();
1133                    lines.push(doc_line_text(self.source, t.span));
1134                    span = span.merge(t.span);
1135                }
1136                Some((Doc::new(lines.join("\n"), DocForm::Lines), span))
1137            }
1138            _ => None,
1139        }
1140    }
1141
1142    /// Collect all line-comment trivia leading the next declaration plus
1143    /// the optional doc block. Comments may appear both *before* and
1144    /// *between* the doc and the declaration; the spec canonicalises both
1145    /// groups above the doc, so we concatenate them.
1146    ///
1147    /// The doc comes back as a [`DocLead`], which records where it sat among
1148    /// the comments, so an orphan can be kept in place ([`keep_orphan`]).
1149    ///
1150    /// For a declaration set apart by blank lines (a unit-level one, or a
1151    /// service's or agent's section or handler), which keeps a blank line after
1152    /// a comment (#1884); [`Self::collect_body_item_lead`] is the form for a
1153    /// body whose items have no blank lines between them.
1154    fn collect_item_lead(&mut self) -> (Vec<Comment>, Option<DocLead>) {
1155        self.collect_lead(Self::take_unit_leading_trivia)
1156    }
1157
1158    /// [`Self::collect_item_lead`] for a declaration in a body, which drops a
1159    /// [`Comment::Blank`] as [`Self::take_leading_trivia`] does.
1160    fn collect_body_item_lead(&mut self) -> (Vec<Comment>, Option<DocLead>) {
1161        self.collect_lead(Self::take_leading_trivia)
1162    }
1163
1164    fn collect_lead(
1165        &mut self,
1166        take: fn(&mut Self) -> Vec<Comment>,
1167    ) -> (Vec<Comment>, Option<DocLead>) {
1168        let mut leading = take(self);
1169        let doc = self.take_doc_block().map(|(content, span)| DocLead {
1170            content,
1171            span,
1172            at: leading.len(),
1173        });
1174        if doc.is_some() {
1175            leading.extend(take(self));
1176        }
1177        (leading, doc)
1178    }
1179
1180    /// #1888: the comments and doc above a member: a record field, a variant,
1181    /// a variant's payload field or a message entry. As
1182    /// [`Self::collect_body_item_lead`] and [`Self::finalize_doc`] together, so
1183    /// a blank line orphans a member's doc as it does a declaration's, and a
1184    /// [`Comment::Blank`] is dropped, as in any body (#1884). A doc with
1185    /// `close` (the body's closing token) next documents nothing: it is warned
1186    /// about and kept among the comments, which the caller then takes as the
1187    /// body's closing comments.
1188    fn collect_member_lead(&mut self, close: TokenKind) -> (Vec<Comment>, Option<Doc>) {
1189        let (mut leading, doc) = self.collect_body_item_lead();
1190        let Some(doc) = doc else {
1191            return (leading, None);
1192        };
1193        match self.peek() {
1194            Some(t) if t.kind != close => {
1195                let doc = self.finalize_doc(Some(doc), t.span, &mut leading);
1196                (leading, doc)
1197            }
1198            _ => {
1199                self.warnings.push(CompileError::new(
1200                    "bynk.parse.orphan_doc_block",
1201                    doc.span,
1202                    "documentation has no following member to attach to",
1203                ));
1204                keep_orphan(&mut leading, doc);
1205                (leading, None)
1206            }
1207        }
1208    }
1209
1210    /// #1888: the kind of the first token after any doc tokens at the cursor,
1211    /// so a member loop can tell a doc on its next member from a doc on the
1212    /// declaration after the body (a pipe-form sum has no closing token).
1213    fn peek_past_docs(&self) -> Option<TokenKind> {
1214        self.tokens[self.pos.min(self.tokens.len())..]
1215            .iter()
1216            .map(|t| t.kind)
1217            .find(|k| !matches!(k, TokenKind::DocBlock | TokenKind::DocLine))
1218    }
1219
1220    /// Attach a parsed doc block to a following declaration unless a blank
1221    /// line separates them, in which case the doc is orphaned: a warning, and
1222    /// the block is kept in `leading` where it sat (#1756).
1223    fn finalize_doc(
1224        &mut self,
1225        doc: Option<DocLead>,
1226        next_span: Span,
1227        leading: &mut Vec<Comment>,
1228    ) -> Option<Doc> {
1229        let doc = doc?;
1230        let doc_span = doc.span;
1231        // A blank line between the doc and the next decl orphans the doc.
1232        if has_blank_line_between(self.source, doc_span.end, next_span.start) {
1233            self.warnings.push(
1234                CompileError::new(
1235                    "bynk.parse.orphan_doc_block",
1236                    doc_span,
1237                    "documentation block is separated from the following declaration by a blank line; it will not be attached",
1238                )
1239                .with_note(
1240                    "remove the blank line to attach the doc to the next declaration, \
1241                     or remove the doc block if it is not meant to document anything",
1242                ),
1243            );
1244            keep_orphan(leading, doc);
1245            return None;
1246        }
1247        Some(doc.content)
1248    }
1249}
1250
1251/// A doc block read ahead of a declaration, before it is known to attach: its
1252/// normalised content, its span, and its index among the leading comments
1253/// collected with it.
1254pub(crate) struct DocLead {
1255    pub(crate) content: Doc,
1256    pub(crate) span: Span,
1257    pub(crate) at: usize,
1258}
1259
1260/// #1756: keep an orphaned doc block in `leading`, at the place it was written
1261/// among those comments, so the formatter prints it where it was rather than
1262/// dropping it.
1263fn keep_orphan(leading: &mut Vec<Comment>, doc: DocLead) {
1264    let at = doc.at.min(leading.len());
1265    leading.insert(at, Comment::OrphanDoc(doc.content));
1266}
1267
1268/// #1888: the token kind a doc of `form` is written with.
1269fn doc_token_kind(form: DocForm) -> TokenKind {
1270    match form {
1271        DocForm::Block => TokenKind::DocBlock,
1272        DocForm::Lines => TokenKind::DocLine,
1273    }
1274}
1275
1276/// #1888 (DECISION C): the `bynk.parse.doc_forms_mixed` error for a doc at
1277/// `first` directly followed by one of the other form at `second`.
1278fn doc_forms_mixed(first: Span, second: Span) -> CompileError {
1279    CompileError::new(
1280        "bynk.parse.doc_forms_mixed",
1281        second,
1282        "this declaration or member has a doc in both forms, a `---` block and `--|` lines",
1283    )
1284    .with_label(first, "the other doc is here")
1285    .with_note(
1286        "write a doc in one form: `--|` lines for a sentence or two, a `---` block for \
1287         anything with structure (lists, code, headings)",
1288    )
1289}
1290
1291/// #1885: the `bynk.parse.doc_block_contains_code` error for the doc block at
1292/// `span`.
1293fn doc_block_contains_code(span: Span) -> CompileError {
1294    CompileError::new(
1295        "bynk.parse.doc_block_contains_code",
1296        span,
1297        "this documentation block contains declarations, so they are not compiled",
1298    )
1299    .with_note(
1300        "a line of three or more dashes opens or closes a doc block, so two dash \
1301         dividers turn the code between them into documentation; for a divider use a \
1302         line comment with text, such as `-- Helpers --`, and to show code in a doc \
1303         block put it in a Markdown code fence (```)",
1304    )
1305}
1306
1307/// The keywords that open a declaration in a unit body. A doc block's content
1308/// is probed as code only when it lexes and holds one of these, so prose (which
1309/// usually fails to lex, or has none) costs one tokenize at most.
1310const DECLARATION_KEYWORDS: &[TokenKind] = &[
1311    TokenKind::Type,
1312    TokenKind::Fn,
1313    TokenKind::Capability,
1314    TokenKind::Provides,
1315    TokenKind::Service,
1316    TokenKind::Agent,
1317    TokenKind::Actor,
1318    TokenKind::Messages,
1319    TokenKind::Event,
1320    TokenKind::Asset,
1321    TokenKind::Uses,
1322    TokenKind::Consumes,
1323    TokenKind::Exports,
1324    TokenKind::Binding,
1325];
1326
1327/// #1885 (ADR: doc-fence-matched-length): true when a doc block's `content`,
1328/// with its Markdown code fences removed, parses as one or more declarations.
1329///
1330/// The probe is an `adapter` body in fragment form: an adapter accepts every
1331/// item a context or commons does, plus `binding`, and leaves placement to the
1332/// checker. Success means the whole content parsed and declared something; a
1333/// content that only holds another doc block (front matter, say) is prose.
1334///
1335/// Nesting is legal since #1885, so code can hide one level down: a doc block
1336/// inside the content lexes to one opaque token, and its content is probed in
1337/// turn (up to [`DOC_PROBE_MAX_DEPTH`] levels), the outer block reporting what
1338/// any level finds. The probe parser itself does not report on those blocks
1339/// (`doc_probe`). Content that does not lex because it holds a lone `---` (a
1340/// Markdown rule) is probed again with its marker lines blanked out.
1341fn doc_content_is_code(content: &str) -> bool {
1342    doc_content_is_code_at(content, 0)
1343}
1344
1345/// How many levels of nested doc blocks [`doc_content_is_code`] looks into.
1346/// Doc blocks nested this deep are written on purpose; the bound only keeps
1347/// adversarial input from costing a parse per level without end.
1348const DOC_PROBE_MAX_DEPTH: usize = 4;
1349
1350fn doc_content_is_code_at(content: &str, depth: usize) -> bool {
1351    let stripped = strip_code_fences(content);
1352    let tokens = match crate::lexer::tokenize(&stripped) {
1353        Ok(tokens) => tokens,
1354        Err(_) => {
1355            // A lone `---` rule leaves the content unlexable; without the
1356            // marker lines it may still be nothing but declarations.
1357            let unmarked = blank_marker_lines(&stripped);
1358            if unmarked == stripped {
1359                return false;
1360            }
1361            return doc_content_is_code_at(&unmarked, depth);
1362        }
1363    };
1364    if depth < DOC_PROBE_MAX_DEPTH
1365        && tokens.iter().any(|t| {
1366            t.kind == TokenKind::DocBlock
1367                && doc_content_is_code_at(&doc_block_content(&stripped, t.span), depth + 1)
1368        })
1369    {
1370        return true;
1371    }
1372    if !tokens
1373        .iter()
1374        .any(|t| DECLARATION_KEYWORDS.contains(&t.kind))
1375    {
1376        return false;
1377    }
1378    let (filtered, trivia) = split_trivia(&tokens, &stripped);
1379    let mut warnings = Vec::new();
1380    let mut p = Parser::new(&filtered, &stripped, trivia, &mut warnings);
1381    p.doc_probe = true;
1382    let at = Span::new(0, 0);
1383    let name = QualifiedName {
1384        parts: Vec::new(),
1385        span: at,
1386    };
1387    match p.parse_adapter_body(at, name, None, false) {
1388        Ok(a) => {
1389            p.peek().is_none()
1390                && (!a.items.is_empty()
1391                    || !a.uses.is_empty()
1392                    || !a.consumes.is_empty()
1393                    || !a.exports.is_empty()
1394                    || a.binding.is_some())
1395        }
1396        Err(_) => false,
1397    }
1398}
1399
1400/// `content` with every doc-block marker line (three or more dashes alone on
1401/// the line) blanked out, line for line.
1402fn blank_marker_lines(content: &str) -> String {
1403    let mut out = String::with_capacity(content.len());
1404    for line in content.lines() {
1405        let trimmed = line.trim();
1406        if !(trimmed.len() >= 3 && trimmed.bytes().all(|b| b == b'-')) {
1407            out.push_str(line);
1408        }
1409        out.push('\n');
1410    }
1411    out
1412}
1413
1414/// `content` with every Markdown fenced code block (```` ``` ```` or `~~~`,
1415/// three or more, closed by a fence of the same character at least as long)
1416/// blanked out, line for line. An unclosed fence runs to the end, as in
1417/// Markdown. Code shown in a fence is an example, not swallowed code.
1418fn strip_code_fences(content: &str) -> String {
1419    let mut out = String::with_capacity(content.len());
1420    let mut open: Option<(char, usize)> = None;
1421    for line in content.lines() {
1422        let trimmed = line.trim();
1423        let fence = ['`', '~'].into_iter().find_map(|c| {
1424            let n = trimmed.chars().take_while(|&x| x == c).count();
1425            (n >= 3).then_some((c, n))
1426        });
1427        match (open, fence) {
1428            (None, Some(f)) => open = Some(f),
1429            (Some((c, n)), Some((fc, fnum)))
1430                if fc == c && fnum >= n && trimmed.len() == fnum * c.len_utf8() =>
1431            {
1432                open = None;
1433            }
1434            (None, None) => out.push_str(line),
1435            (Some(_), _) => {}
1436        }
1437        out.push('\n');
1438    }
1439    out
1440}
1441
1442/// Parse the body of a lexed double-quoted string literal (the lexeme,
1443/// including surrounding quotes), applying the v0 escape rules.
1444fn parse_string_literal(lexeme: &str, span: Span) -> Result<String, CompileError> {
1445    let bytes = lexeme.as_bytes();
1446    debug_assert!(bytes.first() == Some(&b'"') && bytes.last() == Some(&b'"'));
1447    let inner = &lexeme[1..lexeme.len() - 1];
1448    let mut out = String::with_capacity(inner.len());
1449    let mut chars = inner.chars();
1450    while let Some(c) = chars.next() {
1451        if c == '\\' {
1452            match chars.next() {
1453                Some('n') => out.push('\n'),
1454                Some('t') => out.push('\t'),
1455                Some('"') => out.push('"'),
1456                Some('\\') => out.push('\\'),
1457                other => {
1458                    return Err(CompileError::new(
1459                        "bynk.lex.bad_escape",
1460                        span,
1461                        format!(
1462                            "invalid escape sequence `\\{}` in string literal",
1463                            other.map(|c| c.to_string()).unwrap_or_default()
1464                        ),
1465                    )
1466                    .with_note("supported escapes: \\n \\t \\\" \\\\"));
1467                }
1468            }
1469        } else {
1470            out.push(c);
1471        }
1472    }
1473    Ok(out)
1474}
1475
1476fn is_reserved_keyword(kind: TokenKind) -> bool {
1477    use TokenKind::*;
1478    matches!(
1479        kind,
1480        Commons
1481            | Type
1482            | Fn
1483            | Where
1484            | True
1485            | False
1486            | Int
1487            | String
1488            | Bool
1489            | Let
1490            | If
1491            | Else
1492            | Ok
1493            | Err
1494            | Result
1495            | ValidationError
1496            | Enum
1497            | Match
1498            | Option
1499            | Record
1500            | Self_
1501            | Some
1502            | None
1503            | Is
1504            | Opaque
1505            | Uses
1506            | Context
1507            | Consumes
1508            | Exports
1509            | Transparent
1510            | Agent
1511            | As
1512            | Capability
1513            | Effect
1514            | Do
1515            | Given
1516            | On
1517            | Http
1518            | Provides
1519            | Stub
1520            | Service
1521            | Actor
1522            | By
1523            | Expect
1524            | Suite
1525            | Case
1526            | Float
1527            | Duration
1528            | Instant
1529            | Bytes
1530            | JsonError
1531            | Property
1532            | Adapter
1533            | Binding
1534            | Cron
1535            | Queue
1536            | From
1537            | Protocol
1538            | Invariant
1539            | Implies
1540            | Requires
1541            | Ensures
1542            | Transition
1543    )
1544}
1545
1546/// True when `kind` starts a top-level unit (`commons`/`context`/`adapter`/
1547/// `suite`) or an item within one of their bodies — every keyword any of
1548/// `parse_commons_brace`/`_fragment`, `parse_context_brace`/`_fragment`,
1549/// `parse_test_brace`/`_fragment`, or `parse_adapter_body` dispatches on
1550/// (`declarations.rs`). The single set [`Parser::recover_to_top_item`]'s sync
1551/// scan checks against — finding #27/#30: that scan had drifted from what the
1552/// item loops actually recognise (`Property`, `Actor`, `Event`, `Binding`,
1553/// and the `adapter` unit keyword itself were all missing), so an error
1554/// recovery sync could walk past a real item/unit boundary instead of
1555/// stopping there.
1556fn is_item_start(kind: TokenKind) -> bool {
1557    use TokenKind::*;
1558    matches!(
1559        kind,
1560        // Top-level unit keywords.
1561        Commons | Context | Adapter | Suite
1562        // Body items shared across commons/context/adapter.
1563        | Type | Fn | Messages | Event | Asset | Uses
1564        // Context/adapter-only body items.
1565        | Consumes | Exports | Capability | Provides | Service | Agent | Actor
1566        // Adapter-only.
1567        | Binding
1568        // Suite/test-only body items.
1569        | Stub | Case | Property
1570    )
1571}
1572
1573#[cfg(test)]
1574mod tests {
1575    use super::*;
1576    use crate::lexer::tokenize;
1577
1578    fn parse_str(src: &str) -> Result<Commons, Vec<CompileError>> {
1579        let toks = tokenize(src).map_err(|e| vec![e])?;
1580        parse(&toks, src)
1581    }
1582
1583    fn parse_recover_str(src: &str) -> (Option<SourceUnit>, Vec<CompileError>) {
1584        let toks = match tokenize(src) {
1585            Ok(t) => t,
1586            Err(e) => return (None, vec![e]),
1587        };
1588        parse_unit_with_recovery(&toks, src)
1589    }
1590
1591    /// Finding #29/#30: `parse_units_with_recovery` keeps every top-level unit
1592    /// an atomic `commons`+`suite` file declares (v0.113, DECISION S), where
1593    /// `parse_unit_with_recovery` keeps only the first — the defect the IDE's
1594    /// old single-unit parse entry point had (it silently discarded the
1595    /// trailing `suite`).
1596    #[test]
1597    fn parse_units_with_recovery_keeps_every_top_level_unit() {
1598        let src =
1599            "commons m {\n  fn f() -> Int { 1 }\n}\n\nsuite m\n\ncase \"c\" {\n  expect true\n}\n";
1600        let toks = tokenize(src).unwrap();
1601        let (units, errors) = parse_units_with_recovery(&toks, src);
1602        assert!(errors.is_empty(), "{errors:?}");
1603        assert_eq!(units.len(), 2, "expected both units, got {units:?}");
1604        assert!(matches!(units[0], SourceUnit::Commons(_)));
1605        assert!(matches!(units[1], SourceUnit::Suite(_)));
1606
1607        // The singular wrapper still narrows to just the first, unchanged.
1608        let (unit, errors) = parse_unit_with_recovery(&toks, src);
1609        assert!(errors.is_empty(), "{errors:?}");
1610        assert!(matches!(unit, Some(SourceUnit::Commons(_))));
1611    }
1612
1613    /// `parse_unit_with_recovery` always attempts at least one parse, even on
1614    /// empty input — `parse_units_with_recovery`'s loop must preserve that
1615    /// (its `while`-style peek check alone would skip the body entirely and
1616    /// silently return no error), since 16+ existing callers rely on an empty
1617    /// file still producing its usual diagnostic rather than a silent `None`
1618    /// with no error at all.
1619    #[test]
1620    fn empty_input_still_reports_an_error_through_the_plural_entry_point() {
1621        let toks = tokenize("").unwrap();
1622        let (units, errors) = parse_units_with_recovery(&toks, "");
1623        assert!(units.is_empty());
1624        assert!(
1625            !errors.is_empty(),
1626            "an empty file must still produce a diagnostic, not silently no units and no error"
1627        );
1628
1629        let (unit, unit_errors) = parse_unit_with_recovery(&toks, "");
1630        assert!(unit.is_none());
1631        assert_eq!(
1632            errors.len(),
1633            unit_errors.len(),
1634            "the singular wrapper must see the same error(s) as the plural entry point"
1635        );
1636    }
1637
1638    /// Finding #66: `parse_units_with_drain_check`'s `fully_drained` flag is
1639    /// `bynk-fmt`'s signal for whether its comment-loss guard can skip a
1640    /// re-tokenize-and-diff of its own output. It must be `true` for an
1641    /// ordinary file (every comment sits before a declaration/statement or
1642    /// trails one) and `false` the moment a comment sits inside an expression
1643    /// subtree, where `TriviaTable` has no field to attach it to.
1644    #[test]
1645    fn drain_check_reports_expression_interior_comments_as_undrained() {
1646        let ordinary = "commons x {\n-- note\ntype T = Int where Positive\n}\n";
1647        let toks = tokenize(ordinary).unwrap();
1648        let (_, _, drained) = parse_units_with_drain_check(&toks, ordinary).unwrap();
1649        assert!(
1650            drained,
1651            "a declaration-leading comment must be fully drained"
1652        );
1653
1654        let lossy = "commons x {\n  fn f() -> Int {\n    1 + -- note\n    2\n  }\n}\n";
1655        let toks = tokenize(lossy).unwrap();
1656        let (_, _, drained) = parse_units_with_drain_check(&toks, lossy).unwrap();
1657        assert!(
1658            !drained,
1659            "a comment inside a binop expression must be reported as undrained"
1660        );
1661    }
1662
1663    #[test]
1664    fn eof_span_never_splits_a_multibyte_codepoint() {
1665        // An unterminated construct whose final line ends in a non-ASCII char
1666        // (here a `--` comment ending in `→`) once produced an `unexpected_eof`
1667        // span of `len - 1 .. len`, landing on the arrow's last continuation
1668        // byte. Every reported span must sit on char boundaries.
1669        for src in [
1670            "commons x {\n  -- ends with an arrow →",
1671            "agent A {\n  key k: String\n  -- note 🦀",
1672            "commons y {\n  type T = é",
1673        ] {
1674            let (_unit, errors) = parse_recover_str(src);
1675            for e in &errors {
1676                assert!(
1677                    src.is_char_boundary(e.span.start) && src.is_char_boundary(e.span.end),
1678                    "span {:?} splits a codepoint in {src:?}",
1679                    e.span,
1680                );
1681            }
1682        }
1683    }
1684
1685    #[test]
1686    fn reserved_contextual_keywords_readable_in_expression_position() {
1687        // Events track, slice 0 (#939): `expect_ident`'s `RESERVED_CONTEXTUAL`
1688        // exemption (`keywords::RESERVED_CONTEXTUAL`: case/event/messages/on/
1689        // suite) covers *declaring* a binding with one of these names — a
1690        // parameter, a `let` — but the primary-expression parser previously
1691        // only admitted plain `TokenKind::Ident` when *reading one back*.
1692        // Latent since messages/on/case/suite shipped (no fixture happened to
1693        // name a binding after one of them and read it back inside the body);
1694        // surfaced concretely when `event` joined the tier and collided with
1695        // `examples/event-log`'s pre-existing `add(event: Event)` handler.
1696        for kw in ["asset", "case", "event", "messages", "on", "suite"] {
1697            let src = format!("commons x\n\nfn f({kw}: Int) -> Int {{\n  {kw}\n}}\n");
1698            let result = parse_str(&src);
1699            assert!(
1700                result.is_ok(),
1701                "a parameter named `{kw}` must be readable in expression position: {:?}",
1702                result.err()
1703            );
1704        }
1705    }
1706
1707    #[test]
1708    fn recovery_skips_garbage_between_decls() {
1709        // Two `type` declarations separated by garbage. Recovery should
1710        // accept both and report one error for the garbage between them.
1711        let src = "commons x {\n\
1712                   type A = Int where NonNegative\n\
1713                   ??? !!!\n\
1714                   type B = String where NonEmpty\n\
1715                   }";
1716        let (unit, errors) = parse_recover_str(src);
1717        let unit = unit.expect("recovery should produce a partial AST");
1718        let SourceUnit::Commons(c) = unit else {
1719            panic!("expected commons")
1720        };
1721        // Both type decls should have been collected despite the garbage.
1722        let names: Vec<_> = c
1723            .items
1724            .iter()
1725            .map(|i| match i {
1726                CommonsItem::Type(t) => t.name.name.clone(),
1727                _ => panic!("expected only types"),
1728            })
1729            .collect();
1730        assert!(
1731            names.contains(&"A".to_string()) && names.contains(&"B".to_string()),
1732            "expected both A and B; got {names:?}",
1733        );
1734        assert!(!errors.is_empty(), "expected at least one parse error");
1735    }
1736
1737    #[test]
1738    fn recovery_handles_bad_first_decl_then_good_second() {
1739        // First decl is malformed (missing `=`); second is well-formed.
1740        let src = "commons x {\n\
1741                   type A Int where NonNegative\n\
1742                   type B = String where NonEmpty\n\
1743                   }";
1744        let (unit, errors) = parse_recover_str(src);
1745        let unit = unit.expect("recovery should produce a partial AST");
1746        let SourceUnit::Commons(c) = unit else {
1747            panic!("expected commons")
1748        };
1749        let names: Vec<_> = c
1750            .items
1751            .iter()
1752            .filter_map(|i| match i {
1753                CommonsItem::Type(t) => Some(t.name.name.clone()),
1754                _ => None,
1755            })
1756            .collect();
1757        assert!(
1758            names.contains(&"B".to_string()),
1759            "B should be parsed after A's failure; got {names:?}"
1760        );
1761        assert!(!errors.is_empty(), "expected at least one parse error");
1762    }
1763
1764    /// Finding #27/#30: an error two levels deep inside `f`'s body (a
1765    /// `match` arm's own block) used to make `recover_to_top_item`'s flat,
1766    /// depth-blind scan stop at the *first* `}` it saw — the arm block's own,
1767    /// not `f`'s. Two more `}` (the match's, then `f`'s) then got consumed one
1768    /// at a time across repeated recovery re-entries, and the outer item loop
1769    /// eventually mistook the commons's *own* closing `}` for having arrived
1770    /// early, returning zero items and a spurious second
1771    /// `bynk.parse.expected_unit_header` error. With brace-depth tracking, `g`
1772    /// is recovered as the sole item and only `f`'s own error is reported.
1773    /// #1663: an item that ends without its body (`fn f() -> Int` then the
1774    /// commons' own `}`) raises its error *on* that `}`, so recovery makes no
1775    /// progress. The no-progress step must not consume the brace that closes
1776    /// the body (brace form): doing so made the body look unclosed, a
1777    /// follow-on `unexpected_eof`.
1778    #[test]
1779    fn recovery_keeps_the_bodys_closing_brace_after_a_bodiless_item() {
1780        let src = "commons m {\n  fn f() -> Int\n}\n";
1781        let (unit, errors) = parse_recover_str(src);
1782        assert!(unit.is_some(), "recovery should produce a partial AST");
1783        let categories: Vec<_> = errors.iter().map(|e| e.category).collect();
1784        assert_eq!(
1785            categories.len(),
1786            1,
1787            "one syntax error, no follow-on: {categories:?}"
1788        );
1789        assert!(
1790            !categories.contains(&"bynk.parse.unexpected_eof"),
1791            "the commons' closing brace must survive: {categories:?}"
1792        );
1793    }
1794
1795    /// #1663: an item keyword illegal at this position (`agent` in a commons)
1796    /// is skipped *with* its name and `{ … }` body, not one token at a time —
1797    /// otherwise the item loop misreads the agent's name as a malformed item,
1798    /// a second, follow-on `expected_item`. The next item still parses.
1799    #[test]
1800    fn recovery_skips_a_rejected_item_whole() {
1801        let src = "commons m\n\nagent Counter {\n  key id: String\n}\n\nfn g() -> Int { 2 }\n";
1802        let recovered = parse_units_recovering(&crate::lexer::tokenize(src).unwrap(), src);
1803        assert_eq!(
1804            recovered.errors.len(),
1805            1,
1806            "one syntax error, no follow-on: {:?}",
1807            recovered
1808                .errors
1809                .iter()
1810                .map(|e| e.category)
1811                .collect::<Vec<_>>()
1812        );
1813        let Some(SourceUnit::Commons(c)) = recovered.units.first() else {
1814            panic!("expected commons")
1815        };
1816        assert!(
1817            c.items.iter().any(|i| matches!(i, CommonsItem::Fn(_))),
1818            "the `fn g` after the skipped agent must still parse"
1819        );
1820        assert_eq!(recovered.broken_decl_names, ["Counter"]);
1821    }
1822
1823    #[test]
1824    fn recovery_skips_a_nested_blocks_own_closing_brace() {
1825        let src = "commons m {\n  \
1826                   fn f() -> Int {\n    \
1827                   match 1 {\n      \
1828                   is 1 -> { let z = }\n      \
1829                   is _ -> 2\n    \
1830                   }\n  \
1831                   }\n  \
1832                   fn g() -> Int { 2 }\n\
1833                   }\n";
1834        let (unit, errors) = parse_recover_str(src);
1835        let unit = unit.expect("recovery should produce a partial AST");
1836        let SourceUnit::Commons(c) = unit else {
1837            panic!("expected commons")
1838        };
1839        let names: Vec<_> = c
1840            .items
1841            .iter()
1842            .filter_map(|i| match i {
1843                CommonsItem::Fn(f) => match &f.name {
1844                    FnName::Free(id) => Some(id.name.clone()),
1845                    _ => None,
1846                },
1847                _ => None,
1848            })
1849            .collect();
1850        assert_eq!(
1851            names,
1852            vec!["g".to_string()],
1853            "g must still be recovered as an item; got {names:?}"
1854        );
1855        assert!(
1856            !errors
1857                .iter()
1858                .any(|e| e.category == "bynk.parse.expected_unit_header"),
1859            "the outer body's own closing brace must not be mistaken for \
1860             end-of-file: {errors:?}"
1861        );
1862    }
1863
1864    #[test]
1865    fn doc_block_attaches_to_type() {
1866        let c =
1867            parse_str("commons x {\n---\nA descriptive doc.\n---\ntype T = Int where Positive\n}")
1868                .unwrap();
1869        let CommonsItem::Type(t) = &c.items[0] else {
1870            panic!()
1871        };
1872        assert!(t.documentation.is_some());
1873        assert!(
1874            t.documentation
1875                .as_ref()
1876                .unwrap()
1877                .contains("A descriptive doc.")
1878        );
1879    }
1880
1881    #[test]
1882    fn interpolated_string_parses_into_parts() {
1883        // v0.43: `"Hi, \(name)!"` splits into chunk / hole / chunk.
1884        let c = parse_str("commons x\n\nfn f(name: String) -> String {\n  \"Hi, \\(name)!\"\n}\n")
1885            .unwrap();
1886        let CommonsItem::Fn(f) = &c.items[0] else {
1887            panic!("expected fn")
1888        };
1889        let ExprKind::InterpStr(parts) = &f.body.tail.kind else {
1890            panic!("expected InterpStr, got {:?}", f.body.tail.kind)
1891        };
1892        assert_eq!(parts.len(), 3);
1893        assert!(matches!(&parts[0], InterpPart::Chunk(s) if s == "Hi, "));
1894        assert!(
1895            matches!(&parts[1], InterpPart::Hole(h) if matches!(&h.kind, ExprKind::Ident(id) if id.name == "name"))
1896        );
1897        assert!(matches!(&parts[2], InterpPart::Chunk(s) if s == "!"));
1898    }
1899
1900    #[test]
1901    fn interpolated_hole_parses_a_full_expression() {
1902        // A hole holds an arbitrary expression, not just an identifier.
1903        let c =
1904            parse_str("commons x\n\nfn f(a: Int, b: Int) -> String {\n  \"sum = \\(a + b)\"\n}\n")
1905                .unwrap();
1906        let CommonsItem::Fn(f) = &c.items[0] else {
1907            panic!("expected fn")
1908        };
1909        let ExprKind::InterpStr(parts) = &f.body.tail.kind else {
1910            panic!("expected InterpStr")
1911        };
1912        assert!(matches!(&parts[1], InterpPart::Hole(h) if matches!(&h.kind, ExprKind::BinOp(..))));
1913    }
1914
1915    #[test]
1916    fn empty_interpolation_hole_is_rejected() {
1917        let errs = parse_str("commons x\n\nfn f() -> String {\n  \"\\()\"\n}\n").unwrap_err();
1918        assert!(
1919            errs.iter()
1920                .any(|e| e.category == "bynk.parse.empty_interpolation"),
1921            "expected empty_interpolation; got {errs:?}"
1922        );
1923    }
1924
1925    #[test]
1926    fn interpolation_hole_lex_error_span_is_rebased() {
1927        // #716: a lex error inside a `\(…)` hole once carried a span relative to
1928        // the hole substring — never rebased by `hole.start` — so it pointed at
1929        // the file's opening bytes and could split a multibyte char, tripping
1930        // the char-boundary invariant. The error must land on the offending
1931        // bytes within the hole and stay on char boundaries.
1932        let cases = [
1933            // `$` is not a valid token; the error should point at it, not byte 0.
1934            "commons x\n\nfn f() -> String {\n  \"a \\($)\"\n}\n",
1935            // Integer overflow — the reported span must cover the literal itself.
1936            "commons x\n\nfn f() -> String {\n  \"n = \\(99999999999999999999)\"\n}\n",
1937            // A multibyte char before the hole means an un-rebased span could
1938            // land inside the `é`; the rebased span must not.
1939            "commons x\n\nfn f() -> String {\n  \"é \\($)\"\n}\n",
1940        ];
1941        for src in cases {
1942            let errs = parse_str(src).unwrap_err();
1943            assert!(!errs.is_empty(), "expected a lex error for {src:?}");
1944            for e in &errs {
1945                assert!(
1946                    src.is_char_boundary(e.span.start) && src.is_char_boundary(e.span.end),
1947                    "span {:?} splits a codepoint in {src:?}",
1948                    e.span,
1949                );
1950                // The error must point inside the interpolation hole, not at the
1951                // header text that precedes it.
1952                let hole_start = src.find("\\(").expect("case has a hole") + 2;
1953                assert!(
1954                    e.span.start >= hole_start,
1955                    "span {:?} precedes the hole (starts at {hole_start}) in {src:?}",
1956                    e.span,
1957                );
1958            }
1959        }
1960    }
1961
1962    #[test]
1963    fn fragment_form_parses() {
1964        let c = parse_str("commons x.y\n\ntype T = Int where NonNegative\n").unwrap();
1965        assert_eq!(c.form, CommonsForm::Fragment);
1966        assert_eq!(c.items.len(), 1);
1967    }
1968
1969    #[test]
1970    fn uses_parses() {
1971        let c = parse_str("commons x\n\nuses other.lib\n").unwrap();
1972        assert_eq!(c.uses.len(), 1);
1973        assert_eq!(c.uses[0].target.joined(), "other.lib");
1974    }
1975
1976    fn parse_unit_str(src: &str) -> Result<SourceUnit, Vec<CompileError>> {
1977        let toks = tokenize(src).map_err(|e| vec![e])?;
1978        parse_unit(&toks, src)
1979    }
1980
1981    #[test]
1982    fn minimal_context_parses() {
1983        let u = parse_unit_str("context commerce.orders {}").unwrap();
1984        let SourceUnit::Context(c) = u else {
1985            panic!("expected context");
1986        };
1987        assert_eq!(c.name.joined(), "commerce.orders");
1988        assert!(c.items.is_empty());
1989    }
1990
1991    #[test]
1992    fn context_consumes_and_exports_parse() {
1993        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}";
1994        let u = parse_unit_str(src).unwrap();
1995        let SourceUnit::Context(c) = u else { panic!() };
1996        assert_eq!(c.uses.len(), 1);
1997        assert_eq!(c.consumes.len(), 1);
1998        assert_eq!(c.exports.len(), 2);
1999        assert_eq!(c.exports[0].kind, ExportKind::Type(Visibility::Opaque));
2000        assert_eq!(c.exports[1].kind, ExportKind::Type(Visibility::Transparent));
2001    }
2002
2003    #[test]
2004    fn context_fragment_form_parses() {
2005        let src = "context x.y\n\nuses other.lib\nconsumes other.ctx\nexports opaque { T }\n\ntype T = Int where NonNegative\n";
2006        let u = parse_unit_str(src).unwrap();
2007        let SourceUnit::Context(c) = u else { panic!() };
2008        assert_eq!(c.form, CommonsForm::Fragment);
2009        assert_eq!(c.uses.len(), 1);
2010        assert_eq!(c.consumes.len(), 1);
2011        assert_eq!(c.exports.len(), 1);
2012    }
2013
2014    #[test]
2015    fn opaque_type_parses() {
2016        let c = parse_str("commons x { type T = opaque Int where NonNegative }").unwrap();
2017        let CommonsItem::Type(t) = &c.items[0] else {
2018            panic!()
2019        };
2020        assert!(matches!(t.body, TypeBody::Opaque { .. }));
2021    }
2022
2023    #[test]
2024    fn empty_commons() {
2025        let c = parse_str("commons fitness.units {}").unwrap();
2026        assert_eq!(c.name.joined(), "fitness.units");
2027        assert!(c.items.is_empty());
2028    }
2029
2030    #[test]
2031    fn one_type_decl() {
2032        let c = parse_str("commons x { type Metres = Int where NonNegative }").unwrap();
2033        assert_eq!(c.items.len(), 1);
2034        let CommonsItem::Type(t) = &c.items[0] else {
2035            panic!()
2036        };
2037        assert_eq!(t.name.name, "Metres");
2038        match &t.body {
2039            TypeBody::Refined {
2040                base, refinement, ..
2041            } => {
2042                assert_eq!(*base, BaseType::Int);
2043                assert!(refinement.is_some());
2044            }
2045            _ => panic!("expected refined body"),
2046        }
2047    }
2048
2049    #[test]
2050    fn function_decl() {
2051        let c = parse_str("commons x { fn add(a: Int, b: Int) -> Int { a + b } }").unwrap();
2052        let CommonsItem::Fn(f) = &c.items[0] else {
2053            panic!()
2054        };
2055        assert_eq!(f.name.ident().name, "add");
2056        assert_eq!(f.params.len(), 2);
2057    }
2058
2059    #[test]
2060    fn chained_comparison_is_error() {
2061        let errs = parse_str("commons x { fn f(a: Int, b: Int, c: Int) -> Bool { a < b < c } }")
2062            .unwrap_err();
2063        assert_eq!(errs[0].category, "bynk.parse.non_associative");
2064    }
2065
2066    #[test]
2067    fn chained_equality_is_error() {
2068        let errs = parse_str("commons x { fn f(a: Int, b: Int, c: Int) -> Bool { a == b == c } }")
2069            .unwrap_err();
2070        assert_eq!(errs[0].category, "bynk.parse.non_associative");
2071    }
2072
2073    /// Run `f` on a thread with a generous stack. The depth-guard tests build
2074    /// source that, *without* the guard, overflows — so if the guard ever
2075    /// regressed we want a clean assertion failure, not a `SIGABRT` that takes
2076    /// the whole test binary down. A large stack also absorbs the fat frames a
2077    /// debug build spends per recursion level (production release frames are
2078    /// ~9 KB/level, so `MAX_NESTING_DEPTH = 64` sits well inside a 1 MB stack;
2079    /// a debug frame is several times larger and would overflow libtest's
2080    /// default 2 MB test thread near the limit even though the guard fires).
2081    fn on_big_stack<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
2082        std::thread::Builder::new()
2083            .stack_size(64 * 1024 * 1024)
2084            .spawn(f)
2085            .unwrap()
2086            .join()
2087            .unwrap()
2088    }
2089
2090    #[test]
2091    fn deeply_nested_parens_are_bounded_not_overflowed() {
2092        // Without a depth guard the parenthesised-expression recursion
2093        // (`parse_primary` -> `parse_expr` -> …) overflows the stack and aborts
2094        // the process (#713). Well past the limit it must instead report a
2095        // bounded-depth diagnostic. The nesting is left open so the guard, not
2096        // a later `)`, is what stops the descent.
2097        let errs = on_big_stack(|| {
2098            let depth = crate::MAX_NESTING_DEPTH + 8;
2099            let src = format!(
2100                "commons x {{ fn f() -> Int {{ {}0{} }} }}",
2101                "(".repeat(depth),
2102                ")".repeat(depth),
2103            );
2104            parse_str(&src).unwrap_err()
2105        });
2106        assert_eq!(errs[0].category, "bynk.parse.nesting_too_deep");
2107    }
2108
2109    #[test]
2110    fn deeply_nested_types_are_bounded_not_overflowed() {
2111        // The type parser self-recurses through generic type arguments
2112        // (`parse_type_ref` -> `parse_type_atom` -> `parse_type_ref`); the same
2113        // guard bounds it (#713). A right-nested `Result[Int, …]` in parameter
2114        // position drives that recursion.
2115        let errs = on_big_stack(|| {
2116            let depth = crate::MAX_NESTING_DEPTH + 8;
2117            let src = format!(
2118                "commons x {{ fn f(x: {}Int{}) -> Int {{ 0 }} }}",
2119                "Result[Int, ".repeat(depth),
2120                "]".repeat(depth),
2121            );
2122            parse_str(&src).unwrap_err()
2123        });
2124        assert_eq!(errs[0].category, "bynk.parse.nesting_too_deep");
2125    }
2126
2127    #[test]
2128    fn deeply_nested_patterns_are_bounded_not_overflowed() {
2129        // Variant patterns are a third self-recursive descent (`parse_pattern`
2130        // -> `parse_pattern_binding` -> `parse_pattern`) that routes through
2131        // neither `parse_expr` nor `parse_type_ref`; without its own guard a
2132        // nested `Ok(Ok(…))` match arm reproduces the #713 crash.
2133        let errs = on_big_stack(|| {
2134            let depth = crate::MAX_NESTING_DEPTH + 8;
2135            let src = format!(
2136                "commons x {{ fn f(n: Int) -> Int {{ match n {{ {}n{} => 0 }} }} }}",
2137                "Ok(".repeat(depth),
2138                ")".repeat(depth),
2139            );
2140            parse_str(&src).unwrap_err()
2141        });
2142        assert_eq!(errs[0].category, "bynk.parse.nesting_too_deep");
2143    }
2144
2145    #[test]
2146    fn nesting_below_the_limit_still_parses() {
2147        // The guard must not reject ordinary well-nested source: a paren-nested
2148        // expression comfortably under the limit still parses cleanly.
2149        let ok = on_big_stack(|| {
2150            let depth = crate::MAX_NESTING_DEPTH - 8;
2151            let src = format!(
2152                "commons x {{ fn f() -> Int {{ {}0{} }} }}",
2153                "(".repeat(depth),
2154                ")".repeat(depth),
2155            );
2156            parse_str(&src).is_ok()
2157        });
2158        assert!(ok, "well-nested source under the limit should parse");
2159    }
2160
2161    #[test]
2162    fn let_statement_parses() {
2163        let c = parse_str("commons x { fn f(n: Int) -> Int { let y = n + 1\n y } }").unwrap();
2164        let CommonsItem::Fn(f) = &c.items[0] else {
2165            panic!()
2166        };
2167        assert_eq!(f.body.statements.len(), 1);
2168        match &f.body.statements[0] {
2169            Statement::Let(l) => {
2170                assert_eq!(l.name.name, "y");
2171                assert!(l.type_annot.is_none());
2172            }
2173            _ => panic!("expected a pure `let` statement"),
2174        }
2175    }
2176
2177    #[test]
2178    fn let_with_annotation() {
2179        let c = parse_str("commons x { fn f(n: Int) -> Int { let y: Int = n\n y } }").unwrap();
2180        let CommonsItem::Fn(f) = &c.items[0] else {
2181            panic!()
2182        };
2183        match &f.body.statements[0] {
2184            Statement::Let(l) => assert!(l.type_annot.is_some()),
2185            _ => panic!("expected a pure `let` statement"),
2186        }
2187    }
2188
2189    #[test]
2190    fn if_else_parses_as_expression() {
2191        let c = parse_str("commons x { fn f(b: Bool) -> Int { if b { 1 } else { 0 } } }").unwrap();
2192        let CommonsItem::Fn(f) = &c.items[0] else {
2193            panic!()
2194        };
2195        assert!(matches!(f.body.tail.kind, ExprKind::If { .. }));
2196    }
2197
2198    #[test]
2199    fn else_if_chain_parses() {
2200        let c = parse_str(
2201            "commons x { fn f(n: Int) -> Int { if n < 0 { -1 } else if n == 0 { 0 } else { 1 } } }",
2202        )
2203        .unwrap();
2204        let CommonsItem::Fn(f) = &c.items[0] else {
2205            panic!()
2206        };
2207        let ExprKind::If { else_block, .. } = &f.body.tail.kind else {
2208            panic!()
2209        };
2210        // The else-branch is a block whose tail is another `If`.
2211        assert!(else_block.statements.is_empty());
2212        assert!(matches!(else_block.tail.kind, ExprKind::If { .. }));
2213    }
2214
2215    #[test]
2216    fn ok_and_err_parse_as_expressions() {
2217        let c = parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Ok(n) } }").unwrap();
2218        let CommonsItem::Fn(f) = &c.items[0] else {
2219            panic!()
2220        };
2221        assert!(matches!(f.body.tail.kind, ExprKind::Ok(_)));
2222
2223        let c =
2224            parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Err(\"x\") } }").unwrap();
2225        let CommonsItem::Fn(f) = &c.items[0] else {
2226            panic!()
2227        };
2228        assert!(matches!(f.body.tail.kind, ExprKind::Err(_)));
2229    }
2230
2231    #[test]
2232    fn question_postfix_parses() {
2233        let c = parse_str(
2234            "commons x { type T = Int where Positive\n fn f(n: Int) -> Result[T, ValidationError] { let x = T.of(n)?\n Ok(x) } }",
2235        )
2236        .unwrap();
2237        let CommonsItem::Fn(f) = &c.items[1] else {
2238            panic!()
2239        };
2240        let Statement::Let(l) = &f.body.statements[0] else {
2241            panic!("expected a pure `let` statement");
2242        };
2243        assert!(matches!(l.value.kind, ExprKind::Question(_)));
2244    }
2245
2246    #[test]
2247    fn constructor_call_parses() {
2248        let c = parse_str(
2249            "commons x { type T = Int where Positive\n fn f(n: Int) -> Result[T, ValidationError] { T.of(n) } }",
2250        )
2251        .unwrap();
2252        let CommonsItem::Fn(f) = &c.items[1] else {
2253            panic!()
2254        };
2255        // v0.2: T.of(n) parses as a MethodCall with receiver Ident("T"); the
2256        // checker reinterprets it as a static call by noticing T is a type.
2257        let ExprKind::MethodCall {
2258            receiver, method, ..
2259        } = &f.body.tail.kind
2260        else {
2261            panic!("expected MethodCall, got {:?}", f.body.tail.kind)
2262        };
2263        let ExprKind::Ident(id) = &receiver.kind else {
2264            panic!("expected receiver Ident");
2265        };
2266        assert_eq!(id.name, "T");
2267        assert_eq!(method.name, "of");
2268    }
2269
2270    #[test]
2271    fn result_type_ref_parses() {
2272        let c = parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Ok(n) } }").unwrap();
2273        let CommonsItem::Fn(f) = &c.items[0] else {
2274            panic!()
2275        };
2276        assert!(matches!(f.return_type, TypeRef::Result(_, _, _)));
2277    }
2278
2279    #[test]
2280    fn result_missing_arg_count_errors() {
2281        let errs = parse_str("commons x { fn f(n: Int) -> Result[Int] { Ok(n) } }").unwrap_err();
2282        assert_eq!(errs[0].category, "bynk.parse.generic_arg_count");
2283    }
2284
2285    #[test]
2286    fn field_access_parses_in_v0_2() {
2287        // v0.2: field access is supported (the type checker validates the
2288        // field exists on the receiver's type). Parser-level acceptance:
2289        let c =
2290            parse_str("commons x { type R = { foo: Int }\n fn f(r: R) -> Int { r.foo } }").unwrap();
2291        let CommonsItem::Fn(f) = &c.items[1] else {
2292            panic!()
2293        };
2294        assert!(matches!(f.body.tail.kind, ExprKind::FieldAccess { .. }));
2295    }
2296
2297    // -- v1.1 trivia attachment --
2298
2299    #[test]
2300    fn leading_line_comment_attaches_to_next_decl() {
2301        let src = "commons x {\n-- explain the type\ntype T = Int where NonNegative\n}";
2302        let c = parse_str(src).unwrap();
2303        let CommonsItem::Type(t) = &c.items[0] else {
2304            panic!()
2305        };
2306        assert_eq!(
2307            t.trivia.leading,
2308            vec![Comment::Line(" explain the type".to_string())]
2309        );
2310        assert!(t.trivia.trailing.is_none());
2311    }
2312
2313    #[test]
2314    fn trailing_line_comment_attaches_to_prev_decl() {
2315        let src = "commons x {\ntype T = Int where NonNegative  -- trailing note\n}";
2316        let c = parse_str(src).unwrap();
2317        let CommonsItem::Type(t) = &c.items[0] else {
2318            panic!()
2319        };
2320        assert!(t.trivia.leading.is_empty());
2321        assert_eq!(t.trivia.trailing.as_deref(), Some(" trailing note"));
2322    }
2323
2324    #[test]
2325    fn grouped_leading_comments_attach_together() {
2326        let src = "commons x {\n-- one\n-- two\n-- three\ntype T = Int where Positive\n}";
2327        let c = parse_str(src).unwrap();
2328        let CommonsItem::Type(t) = &c.items[0] else {
2329            panic!()
2330        };
2331        assert_eq!(
2332            t.trivia.leading,
2333            vec![
2334                Comment::Line(" one".to_string()),
2335                Comment::Line(" two".to_string()),
2336                Comment::Line(" three".to_string())
2337            ],
2338        );
2339    }
2340
2341    #[test]
2342    fn comment_with_doc_block_keeps_both() {
2343        // Both `-- intro` and the doc block should attach to the type decl.
2344        let src = "commons x {\n-- intro\n---\ndocs\n---\ntype T = Int where Positive\n}";
2345        let c = parse_str(src).unwrap();
2346        let CommonsItem::Type(t) = &c.items[0] else {
2347            panic!()
2348        };
2349        assert_eq!(t.trivia.leading, vec![Comment::Line(" intro".to_string())]);
2350        assert_eq!(t.documentation.as_deref(), Some("docs"));
2351    }
2352
2353    #[test]
2354    fn messages_keyword_does_not_collide_with_a_commons_name_segment() {
2355        // `messages` is RESERVED_CONTEXTUAL (like `case`/`on`/`suite`), not a
2356        // hard keyword: `commons app.messages { ... }` — the design's own
2357        // natural naming choice for a bundle commons — must still parse.
2358        // (Caught during slice-1 implementation: a first pass made `messages`
2359        // a plain hard keyword and this exact name broke.)
2360        let src = "commons app.messages {\ntype T = Int where Positive\n}";
2361        let c = parse_str(src).unwrap();
2362        assert_eq!(c.name.joined(), "app.messages");
2363    }
2364
2365    #[test]
2366    fn messages_decl_parses_tag_annotation_and_entries() {
2367        // message-bundles slice 1 (#859): the construct + doc/trivia wiring.
2368        let src = "commons app.messages {\n\
2369                   -- intro\n\
2370                   ---\n\
2371                   docs\n\
2372                   ---\n\
2373                   messages \"en\" @reference {\n\
2374                   \"greeting\" => \"Hello, {name}!\"\n\
2375                   \"farewell\" => \"Bye\"\n\
2376                   } -- trailing\n\
2377                   }";
2378        let c = parse_str(src).unwrap();
2379        let CommonsItem::Messages(m) = &c.items[0] else {
2380            panic!("expected a messages item, got {:?}", c.items[0]);
2381        };
2382        assert_eq!(m.tag, "en");
2383        assert_eq!(m.annotations.len(), 1);
2384        assert_eq!(m.annotations[0].name.name, "reference");
2385        assert!(m.annotations[0].args.is_empty());
2386        assert_eq!(m.entries.len(), 2);
2387        assert_eq!(m.entries[0].code, "greeting");
2388        assert_eq!(m.entries[0].template, "Hello, {name}!");
2389        assert_eq!(m.entries[1].code, "farewell");
2390        assert_eq!(m.entries[1].template, "Bye");
2391        assert_eq!(m.trivia.leading, vec![Comment::Line(" intro".to_string())]);
2392        assert_eq!(m.documentation.as_deref(), Some("docs"));
2393        assert_eq!(m.trivia.trailing.as_deref(), Some(" trailing"));
2394    }
2395
2396    #[test]
2397    fn messages_decl_parses_with_no_annotation_and_no_entries() {
2398        // The parser stays permissive on annotation cardinality (zero-or-more)
2399        // — "exactly one `@reference`" is a checker concern (validate.rs), not
2400        // a parse error.
2401        let src = "commons app.messages {\nmessages \"en\" {\n}\n}";
2402        let c = parse_str(src).unwrap();
2403        let CommonsItem::Messages(m) = &c.items[0] else {
2404            panic!("expected a messages item, got {:?}", c.items[0]);
2405        };
2406        assert_eq!(m.tag, "en");
2407        assert!(m.annotations.is_empty());
2408        assert!(m.entries.is_empty());
2409    }
2410
2411    #[test]
2412    fn messages_decl_parses_syntactically_inside_a_context_too() {
2413        // Commons-only legality is a checker concern (bynk.messages.outside_commons
2414        // in bynk-emit's project validation), not a parser rejection — mirrors
2415        // how `service`/`agent` already parse syntactically inside `adapter`
2416        // bodies for the same reason.
2417        let src = "context app.svc {\nmessages \"en\" @reference {\n\"a\" => \"b\"\n}\n}";
2418        let toks = tokenize(src).unwrap();
2419        let (unit, errors) = parse_unit_with_recovery(&toks, src);
2420        assert!(errors.is_empty(), "unexpected parse errors: {errors:?}");
2421        let Some(SourceUnit::Context(ctx)) = unit else {
2422            panic!("expected a context")
2423        };
2424        let CommonsItem::Messages(m) = &ctx.items[0] else {
2425            panic!("expected a messages item, got {:?}", ctx.items[0]);
2426        };
2427        assert_eq!(m.tag, "en");
2428    }
2429
2430    #[test]
2431    fn comment_before_let_statement_attaches() {
2432        let src = "commons x {\nfn f(n: Int) -> Int {\n-- pick a value\nlet y = n + 1\ny\n}\n}";
2433        let c = parse_str(src).unwrap();
2434        let CommonsItem::Fn(f) = &c.items[0] else {
2435            panic!()
2436        };
2437        let Statement::Let(l) = &f.body.statements[0] else {
2438            panic!()
2439        };
2440        assert_eq!(
2441            l.trivia.leading,
2442            vec![Comment::Line(" pick a value".to_string())]
2443        );
2444    }
2445
2446    #[test]
2447    fn comment_before_tail_attaches_to_block_tail() {
2448        let src = "commons x {\nfn f(n: Int) -> Int {\nlet y = n + 1\n-- result\ny\n}\n}";
2449        let c = parse_str(src).unwrap();
2450        let CommonsItem::Fn(f) = &c.items[0] else {
2451            panic!()
2452        };
2453        assert_eq!(
2454            f.body.tail_leading_comments,
2455            vec![Comment::Line(" result".to_string())],
2456        );
2457    }
2458
2459    /// #637 Gap A: the contextual keywords `on` / `suite` / `case` are lexer
2460    /// tokens but `expect_ident` admits them as identifiers outside their one
2461    /// keyword position, so they are valid record-field and parameter names.
2462    /// The keyword reference now renders them as a distinct "contextual" tier
2463    /// rather than claiming (falsely) that they cannot be used as identifiers.
2464    #[test]
2465    fn contextual_keywords_are_valid_identifiers() {
2466        // Record field names.
2467        let c = parse_str("commons demo {\n  type R = { on: Int, suite: String, case: Bool }\n}")
2468            .expect("`on`/`suite`/`case` are valid field names");
2469        let CommonsItem::Type(_) = &c.items[0] else {
2470            panic!("expected a type decl")
2471        };
2472
2473        // Function parameter names (the other `expect_ident` position).
2474        parse_str("commons demo {\n  fn f(on: Int, case: Int) -> Int { 0 }\n}")
2475            .expect("`on`/`case` are valid parameter names");
2476
2477        // `suite` too, as a field name.
2478        parse_str("commons demo {\n  type R = { suite: Int }\n}")
2479            .expect("`suite` is a valid field name");
2480    }
2481
2482    /// Drift guard: every alphabetic keyword the lexer declares must be
2483    /// classified by `is_reserved_keyword`, or be one of the *contextual*
2484    /// keywords `expect_ident` deliberately admits as identifiers
2485    /// (`on`/`suite`/`case`). Everything else in this codebase that can
2486    /// drift has a guard; this predicate had silently fallen 17 keywords
2487    /// behind, degrading the reserved-keyword diagnostic to the generic
2488    /// expected-token one.
2489    #[test]
2490    fn is_reserved_keyword_covers_every_lexer_keyword() {
2491        let lexer_src = include_str!("lexer.rs");
2492        let mut words = Vec::new();
2493        for line in lexer_src.lines() {
2494            let t = line.trim();
2495            if let Some(rest) = t.strip_prefix("#[token(\"")
2496                && let Some(word) = rest.split('"').next()
2497                && word.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
2498                && word.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
2499            {
2500                words.push(word.to_string());
2501            }
2502        }
2503        assert!(
2504            words.len() > 30,
2505            "keyword extraction looks broken: only {} words",
2506            words.len()
2507        );
2508        // Contextual keywords double as identifiers (see `expect_ident`); the
2509        // tier is single-sourced in `keywords::RESERVED_CONTEXTUAL`.
2510        use crate::keywords::RESERVED_CONTEXTUAL;
2511        let mut unclassified = Vec::new();
2512        for word in &words {
2513            let tokens = crate::lexer::tokenize(word).expect("keyword lexes");
2514            let kind = tokens.first().expect("keyword yields a token").kind;
2515            if !is_reserved_keyword(kind) && !RESERVED_CONTEXTUAL.contains(&word.as_str()) {
2516                unclassified.push(word.clone());
2517            }
2518        }
2519        assert!(
2520            unclassified.is_empty(),
2521            "keywords missing from is_reserved_keyword (add them, or document \
2522             them as contextual): {unclassified:?}"
2523        );
2524    }
2525
2526    /// Finding #27/#30: pins `is_item_start` to exactly the keyword set
2527    /// `declarations.rs`'s six item loops (`parse_commons_brace`/`_fragment`,
2528    /// `parse_context_brace`/`_fragment`, `parse_test_brace`/`_fragment`) plus
2529    /// `parse_adapter_body` dispatch on, as of this writing — the drift this
2530    /// finding fixed (`Property`, `Actor`, `Event`, `Binding`, and the
2531    /// `adapter` unit keyword itself were all missing from
2532    /// `recover_to_top_item`'s old hand-written sync list, even though every
2533    /// one of them is a real item/unit start). Adding a new item keyword to
2534    /// any of those loops should mean deliberately updating this list too,
2535    /// not silently leaving recovery unable to resync at it.
2536    #[test]
2537    fn is_item_start_matches_the_pinned_keyword_set() {
2538        use TokenKind::*;
2539        let expected_true = [
2540            Commons, Context, Adapter, Suite, Type, Fn, Messages, Event, Asset, Uses, Consumes,
2541            Exports, Capability, Provides, Service, Agent, Actor, Binding, Stub, Case, Property,
2542        ];
2543        for kind in expected_true {
2544            assert!(is_item_start(kind), "{kind:?} must be an item start");
2545        }
2546        let expected_false = [
2547            Ident, Plus, Minus, Colon, Dot, Eq, LBrace, RBrace, LParen, RParen, If, Else, Let,
2548            Where, True, False, Match, Is, On, Given,
2549        ];
2550        for kind in expected_false {
2551            assert!(!is_item_start(kind), "{kind:?} must not be an item start");
2552        }
2553    }
2554
2555    /// Fuzz-found (#516): a context-only keyword at item position in a
2556    /// commons errors without consuming the token, and the recovery sync
2557    /// stops at exactly that keyword — without a progress guard the item
2558    /// loop re-reported the same error until memory ran out.
2559    #[test]
2560    fn recovery_makes_progress_on_context_only_keyword_in_commons() {
2561        let src = "commons demo\n\ncapability Logger {\n  fn log(m: String) -> Effect[()]\n}\n";
2562        let tokens = crate::lexer::tokenize(src).unwrap();
2563        let (unit, errors) = parse_unit_with_recovery(&tokens, src);
2564        assert!(unit.is_some(), "the commons header still parses");
2565        assert!(
2566            errors
2567                .iter()
2568                .any(|e| e.category == "bynk.capability.outside_context"),
2569            "the misplaced capability is reported: {errors:?}"
2570        );
2571        // Termination is the real assertion (this used to OOM); a bounded,
2572        // non-repeating error list is the observable proxy.
2573        assert!(errors.len() < 10, "recovery repeated itself: {errors:?}");
2574    }
2575
2576    #[test]
2577    fn trailing_file_comment_becomes_unit_trailing() {
2578        // A comment after the last item but before EOF (fragment form)
2579        // becomes the commons body's trailing comments so the formatter
2580        // can preserve it.
2581        let src = "commons x\n\ntype T = Int where Positive\n-- afterword\n";
2582        let c = parse_str(src).unwrap();
2583        assert_eq!(
2584            c.trailing_comments,
2585            vec![Comment::Line(" afterword".to_string())]
2586        );
2587    }
2588
2589    #[test]
2590    fn trailing_file_comment_after_a_brace_form_commons_is_not_dropped() {
2591        // Regression: the brace form's item loop exits on `RBrace`, never
2592        // reaching the fragment form's end-of-input case that drains the
2593        // trivia table's epilogue — so a comment after the closing `}` was
2594        // silently discarded (and, per `epilogue_is_empty`'s debug_assert,
2595        // would panic a debug build instead of round-tripping through
2596        // `bynk-fmt`).
2597        let src = "commons x {\n  type T = Int where Positive\n}\n-- afterword\n";
2598        let c = parse_str(src).unwrap();
2599        assert_eq!(
2600            c.trailing_comments,
2601            vec![Comment::Line(" afterword".to_string())]
2602        );
2603    }
2604
2605    #[test]
2606    fn trailing_file_comment_after_a_brace_form_context_is_not_dropped() {
2607        // Same regression as the commons case, for `parse_context_brace`.
2608        let src = "context x {\n  type T = Int where Positive\n}\n-- afterword\n";
2609        let SourceUnit::Context(c) = parse_unit_str(src).unwrap() else {
2610            panic!("expected context");
2611        };
2612        assert_eq!(
2613            c.trailing_comments,
2614            vec![Comment::Line(" afterword".to_string())]
2615        );
2616    }
2617
2618    #[test]
2619    fn trailing_file_comment_after_a_brace_form_suite_is_not_dropped() {
2620        // Same regression as the commons case, for `parse_test_brace`.
2621        let src = "suite x {\n  case \"c\" {\n    expect 1 == 1\n  }\n}\n-- afterword\n";
2622        let SourceUnit::Suite(s) = parse_unit_str(src).unwrap() else {
2623            panic!("expected suite");
2624        };
2625        assert_eq!(
2626            s.trailing_comments,
2627            vec![Comment::Line(" afterword".to_string())]
2628        );
2629    }
2630
2631    /// Finding #30: unlike the three regressions just above,
2632    /// `parse_adapter_body`'s brace-closing path never called
2633    /// `take_epilogue` at all (not a regression from a shared pattern — it
2634    /// simply never had the call), so a comment after a brace-form adapter's
2635    /// closing `}` was silently dropped. The fragment form (no braces) was
2636    /// already correct.
2637    #[test]
2638    fn trailing_file_comment_after_a_brace_form_adapter_is_not_dropped() {
2639        let src = "adapter x {\n  binding \"./x.ts\"\n}\n-- afterword\n";
2640        let SourceUnit::Adapter(a) = parse_unit_str(src).unwrap() else {
2641            panic!("expected adapter");
2642        };
2643        assert_eq!(
2644            a.trailing_comments,
2645            vec![Comment::Line(" afterword".to_string())]
2646        );
2647    }
2648
2649    // -- Six-fold unification (review Part 3): the fragment-only ordering
2650    // restrictions declarations.rs's brace/fragment pairs preserve, now that
2651    // they share one function each behind `brace: bool`. None of these had
2652    // any prior test coverage at all. --
2653
2654    /// Fragment-form commons: `uses` must precede every `type`/`fn`.
2655    #[test]
2656    fn commons_fragment_rejects_uses_after_a_decl() {
2657        let src = "commons x\n\ntype T = Int where Positive\nuses bynk.list\n";
2658        let errs = parse_str(src).unwrap_err();
2659        assert!(
2660            errs.iter()
2661                .any(|e| e.category == "bynk.parse.uses_after_decls"),
2662            "{errs:?}"
2663        );
2664    }
2665
2666    /// The same ordering is NOT enforced in brace form — `uses` may appear
2667    /// anywhere in the body.
2668    #[test]
2669    fn commons_brace_allows_uses_after_a_decl() {
2670        let src = "commons x {\n  type T = Int where Positive\n  uses bynk.list\n}\n";
2671        parse_str(src).expect("brace form must not enforce fragment's uses-ordering rule");
2672    }
2673
2674    /// Fragment-form context: `consumes` must precede every `type`/`fn`/etc.
2675    #[test]
2676    fn context_fragment_rejects_consumes_after_a_decl() {
2677        let src = "context x\n\ntype T = Int where Positive\nconsumes bynk\n";
2678        let errs = parse_unit_str(src).unwrap_err();
2679        assert!(
2680            errs.iter()
2681                .any(|e| e.category == "bynk.parse.consumes_after_decls"),
2682            "{errs:?}"
2683        );
2684    }
2685
2686    /// Fragment-form context: `exports` must precede every `type`/`fn`/etc.
2687    #[test]
2688    fn context_fragment_rejects_exports_after_a_decl() {
2689        let src = "context x\n\ntype T = Int where Positive\nexports opaque { T }\n";
2690        let errs = parse_unit_str(src).unwrap_err();
2691        assert!(
2692            errs.iter()
2693                .any(|e| e.category == "bynk.parse.exports_after_decls"),
2694            "{errs:?}"
2695        );
2696    }
2697
2698    /// Brace-form context enforces none of the three orderings.
2699    #[test]
2700    fn context_brace_allows_consumes_and_exports_after_a_decl() {
2701        let src = "context x {\n  type T = Int where Positive\n  consumes bynk\n  exports opaque { T }\n}\n";
2702        parse_unit_str(src)
2703            .expect("brace form must not enforce fragment's consumes/exports-ordering rules");
2704    }
2705
2706    /// Fragment-form suite/test: `uses` must precede every `stub`/`case`/`property`.
2707    #[test]
2708    fn test_fragment_rejects_uses_after_a_decl() {
2709        let src = "suite m\n\ncase \"c\" {\n  expect true\n}\nuses bynk.list\n";
2710        let errs = parse_unit_str(src).unwrap_err();
2711        assert!(
2712            errs.iter()
2713                .any(|e| e.category == "bynk.parse.uses_after_decls"),
2714            "{errs:?}"
2715        );
2716    }
2717
2718    /// Brace-form suite/test allows `uses` anywhere.
2719    #[test]
2720    fn test_brace_allows_uses_after_a_decl() {
2721        let src = "suite m {\n  case \"c\" {\n    expect true\n  }\n  uses bynk.list\n}\n";
2722        parse_unit_str(src).expect("brace form must not enforce fragment's uses-ordering rule");
2723    }
2724
2725    // ---- #636: `if`/`match` condition vs record construction ----
2726
2727    /// Parse `body` as the tail expression of a fn and return its kind.
2728    fn body_tail(body: &str) -> ExprKind {
2729        let src = format!("commons x\n\nfn f() -> Int {{\n  {body}\n}}\n");
2730        let c = parse_str(&src).unwrap_or_else(|e| panic!("parse failed for {body:?}: {e:?}"));
2731        let CommonsItem::Fn(f) = &c.items[0] else {
2732            panic!("expected fn, got {:?}", c.items[0]);
2733        };
2734        f.body.tail.kind.clone()
2735    }
2736
2737    fn body_err(body: &str) -> Vec<CompileError> {
2738        let src = format!("commons x\n\nfn f() -> Int {{\n  {body}\n}}\n");
2739        parse_str(&src).expect_err(&format!("expected a parse error for {body:?}"))
2740    }
2741
2742    #[test]
2743    fn if_condition_ending_in_ident_does_not_swallow_a_single_ident_branch() {
2744        // #636: `ready { result }` shares its shape with a shorthand-field
2745        // record construction. In condition position the branch must win.
2746        for src in [
2747            "if ready { result } else { fallback }",
2748            "if ready { fallback } else { result }",
2749            "if !ready { result } else { fallback }",
2750            "if a == b { result } else { fallback }",
2751            "if a && b { result } else { fallback }",
2752        ] {
2753            let ExprKind::If {
2754                then_block,
2755                else_block,
2756                ..
2757            } = body_tail(src)
2758            else {
2759                panic!("expected If for {src:?}, got {:?}", body_tail(src));
2760            };
2761            // Both branches carry a bare-identifier tail — proof the `{ … }`
2762            // was read as a block, not consumed as a record by the condition.
2763            assert!(
2764                matches!(&then_block.tail.kind, ExprKind::Ident(_)),
2765                "then-branch tail not an ident for {src:?}: {:?}",
2766                then_block.tail.kind,
2767            );
2768            assert!(
2769                matches!(&else_block.tail.kind, ExprKind::Ident(_)),
2770                "else-branch tail not an ident for {src:?}: {:?}",
2771                else_block.tail.kind,
2772            );
2773        }
2774    }
2775
2776    #[test]
2777    fn else_less_if_with_single_ident_branch_parses() {
2778        // The no-`else` reproduction: previously errored `found `}``.
2779        let ExprKind::If { then_block, .. } = body_tail("if ready { result }") else {
2780            panic!("expected If");
2781        };
2782        assert!(matches!(&then_block.tail.kind, ExprKind::Ident(_)));
2783    }
2784
2785    #[test]
2786    fn record_construction_still_parses_in_value_position() {
2787        // The restriction is confined to condition spines — an ordinary value
2788        // position still constructs records, including the shorthand tail form.
2789        assert!(matches!(
2790            body_tail("Point { x }"),
2791            ExprKind::RecordConstruction { .. }
2792        ));
2793        assert!(matches!(
2794            body_tail("Point { x: 1, y: 2 }"),
2795            ExprKind::RecordConstruction { .. }
2796        ));
2797        assert!(matches!(
2798            body_tail("Empty {}"),
2799            ExprKind::RecordConstruction { .. }
2800        ));
2801    }
2802
2803    #[test]
2804    fn parenthesised_record_is_allowed_in_condition_head() {
2805        // A delimiter lifts the restriction: `(ready { result })` constructs a
2806        // record even in condition position (mirrors Rust's paren escape).
2807        let ExprKind::If { cond, .. } =
2808            body_tail("if (ready { result }) { branch } else { other }")
2809        else {
2810            panic!("expected If");
2811        };
2812        let ExprKind::Paren(inner) = &cond.kind else {
2813            panic!("expected a parenthesised condition, got {:?}", cond.kind);
2814        };
2815        assert!(
2816            matches!(&inner.kind, ExprKind::RecordConstruction { .. }),
2817            "parenthesised record in condition head should still construct: {:?}",
2818            inner.kind,
2819        );
2820    }
2821
2822    #[test]
2823    fn record_in_call_arg_within_condition_still_constructs() {
2824        // The restriction is lifted through a call-argument delimiter, so a
2825        // record literal passed to a predicate in the condition still parses.
2826        let ExprKind::If { cond, .. } = body_tail("if check(Point { x: 1 }) { a } else { b }")
2827        else {
2828            panic!("expected If");
2829        };
2830        let ExprKind::Call { args, .. } = &cond.kind else {
2831            panic!("expected Call in condition, got {:?}", cond.kind);
2832        };
2833        assert!(matches!(&args[0].kind, ExprKind::RecordConstruction { .. }));
2834    }
2835
2836    #[test]
2837    fn safe_condition_shapes_are_unaffected() {
2838        // Cases the issue lists as already-safe must stay safe.
2839        assert!(matches!(
2840            body_tail("if ready == true { result } else { fallback }"),
2841            ExprKind::If { .. }
2842        ));
2843        assert!(matches!(
2844            body_tail("if (ready) { result } else { fallback }"),
2845            ExprKind::If { .. }
2846        ));
2847        assert!(matches!(
2848            body_tail("if ready { \"a\" } else { \"b\" }"),
2849            ExprKind::If { .. }
2850        ));
2851    }
2852
2853    fn body_block(body: &str) -> Block {
2854        let src = format!("commons x\n\nfn f() -> Int {{\n  {body}\n}}\n");
2855        let c = parse_str(&src).unwrap_or_else(|e| panic!("parse failed for {body:?}: {e:?}"));
2856        let CommonsItem::Fn(f) = &c.items[0] else {
2857            panic!("expected fn, got {:?}", c.items[0]);
2858        };
2859        f.body.clone()
2860    }
2861
2862    fn has_error(errs: &[CompileError], code: &str) -> bool {
2863        errs.iter().any(|e| e.category == code)
2864    }
2865
2866    #[test]
2867    fn let_else_desugars_to_a_hidden_binding_and_a_match() {
2868        // #2010: `let P = e else E; rest` is `let __v = e` then
2869        // `match __v { P => { rest }, _ => E }`.
2870        let b = body_block("let x = 1\n  let Some(n) = find(x) else { 0 }\n  n + x");
2871        assert_eq!(
2872            b.statements.len(),
2873            2,
2874            "the plain let and the hidden binding"
2875        );
2876        let Statement::Let(hidden) = &b.statements[1] else {
2877            panic!("expected a pure hidden binding, got {:?}", b.statements[1]);
2878        };
2879        assert!(
2880            is_generated_binding(&hidden.name.name),
2881            "{}",
2882            hidden.name.name
2883        );
2884        assert_eq!(
2885            hidden.name.span.start, hidden.name.span.end,
2886            "the binder has no extent"
2887        );
2888        let le = hidden
2889            .let_else
2890            .as_ref()
2891            .expect("the binding records the sugar");
2892        assert_eq!(le.form, LetElseForm::Block);
2893        let ExprKind::Match { discriminant, arms } = &b.tail.kind else {
2894            panic!("expected a match tail, got {:?}", b.tail.kind);
2895        };
2896        assert!(matches!(&discriminant.kind, ExprKind::Ident(i) if i.name == hidden.name.name));
2897        assert_eq!(arms.len(), 2);
2898        assert!(matches!(arms[0].pattern, Pattern::Variant { .. }));
2899        let MatchBody::Block(rest) = &arms[0].body else {
2900            panic!("the pattern's arm runs the rest of the block");
2901        };
2902        assert!(rest.statements.is_empty());
2903        assert!(
2904            !matches!(rest.tail.kind, ExprKind::UnitLit),
2905            "{:?}",
2906            rest.tail.kind
2907        );
2908        assert!(matches!(arms[1].pattern, Pattern::Wildcard(_)));
2909    }
2910
2911    #[test]
2912    fn effectful_let_else_binds_with_an_arrow() {
2913        let b = body_block("let Ok(v) <- load() else { Effect.pure(0) }\n  v");
2914        assert!(matches!(&b.statements[0], Statement::EffectLet(l) if l.let_else.is_some()));
2915    }
2916
2917    #[test]
2918    fn let_else_chain_nests_each_step_in_the_previous_arm() {
2919        // Three steps in one flat block: each later step lives in the earlier
2920        // one's pattern arm, and the plain `let` between them stays in order.
2921        let b = body_block(
2922            "let Ok(a) = one() else { 1 }\n  let y = a\n  let Ok(b) = two(y) else { 2 }\n  let Ok(c) = three(b) else { 3 }\n  c",
2923        );
2924        let ExprKind::Match { arms, .. } = &b.tail.kind else {
2925            panic!("expected a match tail");
2926        };
2927        let MatchBody::Block(second) = &arms[0].body else {
2928            panic!("expected a block arm");
2929        };
2930        assert_eq!(
2931            second.statements.len(),
2932            2,
2933            "`let y` and the second hidden binding"
2934        );
2935        assert!(matches!(&second.statements[0], Statement::Let(l) if l.name.name == "y"));
2936        let ExprKind::Match { arms, .. } = &second.tail.kind else {
2937            panic!("expected the second step's match");
2938        };
2939        let MatchBody::Block(third) = &arms[0].body else {
2940            panic!("expected a block arm");
2941        };
2942        let ExprKind::Match { arms, .. } = &third.tail.kind else {
2943            panic!("expected the third step's match");
2944        };
2945        let MatchBody::Block(last) = &arms[0].body else {
2946            panic!("expected a block arm");
2947        };
2948        assert!(matches!(&last.tail.kind, ExprKind::Ident(i) if i.name == "c"));
2949    }
2950
2951    #[test]
2952    fn else_match_arms_follow_the_patterns_arm() {
2953        let b = body_block("let Ok(v) = r else match { Err(e) => 0 }\n  v");
2954        let Statement::Let(l) = &b.statements[0] else {
2955            panic!("expected the hidden binding");
2956        };
2957        assert_eq!(l.let_else.as_ref().unwrap().form, LetElseForm::Match);
2958        let ExprKind::Match { arms, .. } = &b.tail.kind else {
2959            panic!("expected a match tail");
2960        };
2961        assert_eq!(arms.len(), 2);
2962        assert!(
2963            matches!(&arms[1].pattern, Pattern::Variant { variant, .. } if variant.name == "Err")
2964        );
2965    }
2966
2967    #[test]
2968    fn let_else_as_the_last_statement_leaves_a_unit_rest() {
2969        let b = body_block("let Some(n) = find(1) else { () }");
2970        let ExprKind::Match { arms, .. } = &b.tail.kind else {
2971            panic!("expected a match tail");
2972        };
2973        let MatchBody::Block(rest) = &arms[0].body else {
2974            panic!("expected a block arm");
2975        };
2976        assert!(rest.implicit_tail && rest.statements.is_empty());
2977    }
2978
2979    #[test]
2980    fn let_pattern_forms_are_recognised() {
2981        for src in [
2982            "let None = o else { 0 }\n  1",
2983            "let Status.Done = s else { 0 }\n  1",
2984            "let Shipped(at) = s else { 0 }\n  at",
2985            "let 0 = n else { 1 }\n  0",
2986            "let -1 = n else { 1 }\n  0",
2987            "let \"a\" = s else { 1 }\n  0",
2988            "let (Ok(x) | Err(x)) = r else { 1 }\n  x",
2989        ] {
2990            let b = body_block(src);
2991            assert!(
2992                matches!(&b.statements[0], Statement::Let(l) if l.let_else.is_some()),
2993                "{src:?} should parse as a let-else"
2994            );
2995        }
2996        // A bare name, even capitalised, is still a binding.
2997        let b = body_block("let Total = 1\n  Total");
2998        assert!(
2999            matches!(&b.statements[0], Statement::Let(l) if l.let_else.is_none() && l.name.name == "Total")
3000        );
3001    }
3002
3003    #[test]
3004    fn let_pattern_without_else_is_refused() {
3005        assert!(has_error(
3006            &body_err("let Ok(v) = r\n  v"),
3007            "bynk.parse.let_pattern_without_else"
3008        ));
3009        assert!(has_error(
3010            &body_err("let Ok(v) <- r()\n  v"),
3011            "bynk.parse.let_pattern_without_else"
3012        ));
3013    }
3014
3015    #[test]
3016    fn else_on_a_plain_binding_is_refused() {
3017        assert!(has_error(
3018            &body_err("let v = r else { 0 }\n  v"),
3019            "bynk.parse.let_else_on_binding"
3020        ));
3021        assert!(has_error(
3022            &body_err("let v <- r() else { 0 }\n  v"),
3023            "bynk.parse.let_else_on_binding"
3024        ));
3025    }
3026
3027    #[test]
3028    fn annotated_let_pattern_is_refused() {
3029        assert!(has_error(
3030            &body_err("let Ok(v): Int = r else { 0 }\n  v"),
3031            "bynk.parse.let_pattern_annotation"
3032        ));
3033    }
3034
3035    #[test]
3036    fn empty_else_match_is_refused() {
3037        assert!(has_error(
3038            &body_err("let Ok(v) = r else match { }\n  v"),
3039            "bynk.parse.empty_match"
3040        ));
3041    }
3042
3043    #[test]
3044    fn let_else_steps_count_against_the_nesting_budget() {
3045        // Each step nests the rest of the block one level deeper, exactly as
3046        // the hand-written `match` it stands for, so a very long chain is
3047        // refused with the nesting diagnostic rather than overflowing later.
3048        let chain = |n: usize| -> String {
3049            let steps: String = (0..n)
3050                .map(|i| format!("let Ok(v{i}) = step() else {{ {i} }}\n  "))
3051                .collect();
3052            format!("{steps}0")
3053        };
3054        body_block(&chain(40));
3055        let errs = body_err(&chain(400));
3056        assert!(
3057            errs.iter()
3058                .any(|e| e.category == "bynk.parse.nesting_too_deep"),
3059            "{errs:?}"
3060        );
3061    }
3062
3063    #[test]
3064    fn empty_match_reports_its_own_diagnostic() {
3065        // #636: `match result {}` once parsed `result {}` as an empty record,
3066        // masking `bynk.parse.empty_match`. The intended diagnostic is now
3067        // reachable.
3068        let errs = body_err("match result {}");
3069        assert!(
3070            errs.iter().any(|e| e.category == "bynk.parse.empty_match"),
3071            "expected empty_match; got {errs:?}",
3072        );
3073    }
3074
3075    #[test]
3076    fn match_discriminant_ending_in_ident_parses() {
3077        // A `match` over a bare-identifier discriminant reaches its arm list.
3078        assert!(matches!(
3079            body_tail("match ready { x => x }"),
3080            ExprKind::Match { .. }
3081        ));
3082    }
3083
3084    /// #981: an identifier statement immediately followed, on its own line, by
3085    /// a standalone `()` must stay two separate constructs — not merge into a
3086    /// zero-arg call. `status := Paid` / `()` is an Assign statement whose
3087    /// value is the bare identifier `Paid`, then a unit tail; it must never
3088    /// parse as a single `status := Paid()` (a call). The call-parens rule
3089    /// mirrors the v0.20b same-line `[` rule already applied to type
3090    /// arguments: a postfix opener that begins a new line does not continue
3091    /// the previous token.
3092    #[test]
3093    fn identifier_statement_followed_by_unit_tail_does_not_merge_into_a_call() {
3094        let src = "commons c\n\nfn f() -> Int {\n  status := Paid\n  ()\n}\n";
3095        let c = parse_str(src).unwrap_or_else(|e| panic!("parse failed: {e:?}"));
3096        let CommonsItem::Fn(f) = &c.items[0] else {
3097            panic!("expected fn, got {:?}", c.items[0]);
3098        };
3099        assert_eq!(
3100            f.body.statements.len(),
3101            1,
3102            "expected exactly one Assign statement, got {:?}",
3103            f.body.statements
3104        );
3105        let Statement::Assign(a) = &f.body.statements[0] else {
3106            panic!(
3107                "expected an Assign statement, got {:?}",
3108                f.body.statements[0]
3109            );
3110        };
3111        assert!(
3112            matches!(a.value.kind, ExprKind::Ident(_)),
3113            "assign value must stay the bare identifier `Paid`, got {:?}",
3114            a.value.kind
3115        );
3116        assert!(
3117            matches!(f.body.tail.kind, ExprKind::UnitLit),
3118            "the `()` must remain the block's own tail, got {:?}",
3119            f.body.tail.kind
3120        );
3121    }
3122
3123    /// #1858: a variant pattern's `(` must sit on the variant's line too. An
3124    /// `is` test ending in a nullary variant, above a `()` tail on its own
3125    /// line, is a pattern and a unit, not `Paid()`/`None()`: `fmt` writes that
3126    /// tail after a comment, and its reparse must not absorb it.
3127    #[test]
3128    fn variant_pattern_followed_by_unit_tail_does_not_take_it_as_bindings() {
3129        for (name, pat) in [("user variant", "Paid"), ("built-in variant", "None")] {
3130            let src = format!("commons c\n\nfn f() -> () {{\n  let b = r is {pat}\n  ()\n}}\n");
3131            let c = parse_str(&src).unwrap_or_else(|e| panic!("{name}: parse failed: {e:?}"));
3132            let CommonsItem::Fn(f) = &c.items[0] else {
3133                panic!("expected fn, got {:?}", c.items[0]);
3134            };
3135            let Statement::Let(l) = &f.body.statements[0] else {
3136                panic!("{name}: expected a Let, got {:?}", f.body.statements[0]);
3137            };
3138            let ExprKind::Is { pattern, .. } = &l.value.kind else {
3139                panic!("{name}: expected an `is` test, got {:?}", l.value.kind);
3140            };
3141            let Pattern::Variant { variant, span, .. } = pattern.as_ref() else {
3142                panic!("{name}: expected a variant pattern, got {pattern:?}");
3143            };
3144            assert_eq!(
3145                span.end, variant.span.end,
3146                "{name}: the pattern must end at `{pat}`, not take the `()` below it"
3147            );
3148            assert!(
3149                matches!(f.body.tail.kind, ExprKind::UnitLit),
3150                "{name}: the `()` must remain the block's own tail, got {:?}",
3151                f.body.tail.kind
3152            );
3153        }
3154    }
3155
3156    /// #1858's boundary: a `(` on the variant's line still opens its payload
3157    /// list, and the list itself may span lines.
3158    #[test]
3159    fn variant_pattern_payload_on_the_same_line_still_binds() {
3160        for (name, pat) in [
3161            ("user variant", "Paid(\n    x\n  )"),
3162            ("built-in variant", "Some(x)"),
3163        ] {
3164            let src = format!("commons c\n\nfn f() -> Bool {{\n  r is {pat}\n}}\n");
3165            let c = parse_str(&src).unwrap_or_else(|e| panic!("{name}: parse failed: {e:?}"));
3166            let CommonsItem::Fn(f) = &c.items[0] else {
3167                panic!("expected fn, got {:?}", c.items[0]);
3168            };
3169            let ExprKind::Is { pattern, .. } = &f.body.tail.kind else {
3170                panic!("{name}: expected an `is` tail, got {:?}", f.body.tail.kind);
3171            };
3172            assert!(
3173                matches!(pattern.as_ref(), Pattern::Variant { bindings, .. } if bindings.len() == 1),
3174                "{name}: expected one binding, got {pattern:?}"
3175            );
3176        }
3177    }
3178
3179    /// #1858, chosen uniformly: a match arm's pattern is followed only by `if`
3180    /// or `=>`, so a next-line `(` there could not be the next statement, but
3181    /// the same-line rule holds in every pattern position anyway. One lexical
3182    /// rule, wherever a pattern appears, is the one a reader can apply without
3183    /// knowing the position. The arm is rejected, not misparsed.
3184    #[test]
3185    fn match_arm_pattern_payload_on_a_later_line_is_rejected() {
3186        let src = "commons c\n\nfn f() -> Int {\n  match m {\n    Settled\n      (amount) => 1,\n    _ => 0,\n  }\n}\n";
3187        assert!(
3188            parse_str(src).is_err(),
3189            "a next-line `(` after an arm's variant must not open its payload list"
3190        );
3191        let same_line = src.replace("Settled\n      (amount)", "Settled(amount)");
3192        parse_str(&same_line).unwrap_or_else(|e| panic!("same-line form must parse: {e:?}"));
3193    }
3194
3195    /// #981: the same same-line rule extends to a method call's parens — a
3196    /// `.method` immediately followed, on its own line, by a standalone `()`
3197    /// must not merge into `.method()`.
3198    #[test]
3199    fn method_reference_followed_by_unit_tail_does_not_merge_into_a_call() {
3200        let src = "commons c\n\nfn f() -> Int {\n  let y = x.field\n  ()\n}\n";
3201        let c = parse_str(src).unwrap_or_else(|e| panic!("parse failed: {e:?}"));
3202        let CommonsItem::Fn(f) = &c.items[0] else {
3203            panic!("expected fn, got {:?}", c.items[0]);
3204        };
3205        let Statement::Let(l) = &f.body.statements[0] else {
3206            panic!("expected a Let statement, got {:?}", f.body.statements[0]);
3207        };
3208        assert!(
3209            matches!(l.value.kind, ExprKind::FieldAccess { .. }),
3210            "let value must stay a field access, got {:?}",
3211            l.value.kind
3212        );
3213        assert!(
3214            matches!(f.body.tail.kind, ExprKind::UnitLit),
3215            "the `()` must remain the block's own tail, got {:?}",
3216            f.body.tail.kind
3217        );
3218    }
3219
3220    #[test]
3221    fn unparenthesised_record_in_condition_head_now_errors() {
3222        // #636 narrowing (matches Rust): a record literal in condition *head*
3223        // position must be parenthesised. Unparenthesised, `Point` reads as the
3224        // discriminant and `{ x: 1 }` as the arm list, whose first "arm" `x: 1`
3225        // is not an arm — so the parse fails. Pinned so the divergence from the
3226        // (still-accepting) tree-sitter grammar is deliberate, not a bug.
3227        assert!(
3228            !body_err("match Point { x: 1 } { p => p }").is_empty(),
3229            "unparenthesised record discriminant should not parse",
3230        );
3231        // Parenthesised, the record is the discriminant and the match parses.
3232        let ExprKind::Match { discriminant, .. } = body_tail("match (Point { x: 1 }) { p => p }")
3233        else {
3234            panic!("expected Match for the parenthesised form");
3235        };
3236        let ExprKind::Paren(inner) = &discriminant.kind else {
3237            panic!(
3238                "expected a parenthesised discriminant, got {:?}",
3239                discriminant.kind
3240            );
3241        };
3242        assert!(matches!(&inner.kind, ExprKind::RecordConstruction { .. }));
3243    }
3244
3245    /// The codes a strict multi-unit parse (the build's path) reports.
3246    fn strict_codes(src: &str) -> Vec<&'static str> {
3247        let toks = tokenize(src).unwrap();
3248        match parse_units_with_warnings(&toks, src) {
3249            Ok((_, warnings)) => warnings.iter().map(|e| e.category).collect(),
3250            Err(errs) => errs.iter().map(|e| e.category).collect(),
3251        }
3252    }
3253
3254    /// #1885 (DECISION B): a divider pair around a declaration, attached to
3255    /// the next one, swallowed `helper` silently. Every parse entry point now
3256    /// reports it.
3257    #[test]
3258    fn a_divider_pair_around_code_is_doc_block_contains_code() {
3259        let src =
3260            "commons m\n\n--------\nfn helper() -> Int { 1 }\n--------\nfn f() -> Int { 2 }\n";
3261        assert_eq!(strict_codes(src), ["bynk.parse.doc_block_contains_code"]);
3262        let errs = parse_str(src).unwrap_err();
3263        assert_eq!(errs[0].category, "bynk.parse.doc_block_contains_code");
3264        // The span is the whole doc block.
3265        assert_eq!(
3266            &src[errs[0].span.range()],
3267            "--------\nfn helper() -> Int { 1 }\n--------\n"
3268        );
3269        let (unit, errs) = parse_recover_str(src);
3270        assert!(unit.is_some(), "recovery keeps the unit");
3271        assert!(
3272            errs.iter()
3273                .any(|e| e.category == "bynk.parse.doc_block_contains_code"),
3274            "{errs:?}"
3275        );
3276    }
3277
3278    /// The orphan shape (a blank line before `fn f`) is the same error, beside
3279    /// the orphan warning; and so is a pair with nothing after it.
3280    #[test]
3281    fn an_orphaned_or_trailing_divider_pair_is_doc_block_contains_code() {
3282        let orphan = "commons m\n\n---\nfn helper() -> Int { 1 }\n---\n\nfn f() -> Int { 2 }\n";
3283        let codes = strict_codes(orphan);
3284        assert!(
3285            codes.contains(&"bynk.parse.doc_block_contains_code"),
3286            "{codes:?}"
3287        );
3288        let trailing = "commons m\n\nfn f() -> Int { 2 }\n\n---\ntype Id = Int\n---\n";
3289        let codes = strict_codes(trailing);
3290        assert!(
3291            codes.contains(&"bynk.parse.doc_block_contains_code"),
3292            "{codes:?}"
3293        );
3294    }
3295
3296    /// Prose that only starts like a declaration, a doc whose code sits in a
3297    /// Markdown fence, and a doc that holds only a nested doc example are all
3298    /// documentation.
3299    #[test]
3300    fn near_miss_prose_and_fenced_code_are_not_code() {
3301        for doc in [
3302            "type of the thing",
3303            "fn is used here",
3304            "The type Id is an Int.",
3305            "uses the clock",
3306            "Event handler.\nfn helper() -> Int { 1 }",
3307            "Example:\n```bynk\nfn helper() -> Int { 1 }\n```",
3308            "~~~~\ntype Id = Int\n~~~~",
3309            "---\nA nested doc.\n---",
3310            "",
3311        ] {
3312            assert!(!doc_content_is_code(doc), "{doc:?} read as code");
3313        }
3314        for doc in [
3315            "fn helper() -> Int { 1 }",
3316            "type Id = Int\nfn f() -> Int { 1 }",
3317            "```\nan example\n```\nfn helper() -> Int { 1 }",
3318            "---\nA nested doc.\n---\nfn helper() -> Int { 1 }",
3319        ] {
3320            assert!(doc_content_is_code(doc), "{doc:?} not read as code");
3321        }
3322    }
3323
3324    /// #1894 review: code one level down, inside a nested doc block, or
3325    /// beside a lone `---` rule that keeps the content from lexing, is still
3326    /// code; prose at either level is not.
3327    #[test]
3328    fn code_inside_a_nested_doc_or_beside_a_rule_is_code() {
3329        let nested =
3330            "commons m\n\n----\n---\nfn helper() -> Int { 1 }\n---\n----\nfn f() -> Int { 2 }\n";
3331        assert_eq!(strict_codes(nested), ["bynk.parse.doc_block_contains_code"]);
3332        let ruled = "commons m\n\n----\n---\nfn helper() -> Int { 1 }\n----\nfn f() -> Int { 2 }\n";
3333        assert_eq!(strict_codes(ruled), ["bynk.parse.doc_block_contains_code"]);
3334        // Two levels down.
3335        assert!(doc_content_is_code(
3336            "----\n---\nfn helper() -> Int { 1 }\n---\n----"
3337        ));
3338        for doc in [
3339            "---\nAn inner doc.\n---",
3340            "Intro.\n\n---\n\nfn is used here",
3341            "---\n```\nfn helper() -> Int { 1 }\n```\n---",
3342        ] {
3343            assert!(!doc_content_is_code(doc), "{doc:?} read as code");
3344        }
3345        // Deeper than the bound, the probe stops looking.
3346        let mut deep = "fn helper() -> Int { 1 }".to_string();
3347        for n in (3..3 + DOC_PROBE_MAX_DEPTH + 1).rev() {
3348            let fence = "-".repeat(n);
3349            deep = format!("{fence}\n{deep}\n{fence}");
3350        }
3351        assert!(!doc_content_is_code(&deep));
3352    }
3353
3354    /// A `----` doc holding a nested doc example in a code fence attaches to
3355    /// its declaration with no diagnostic.
3356    #[test]
3357    fn a_fenced_nested_doc_example_parses_clean() {
3358        let src = "commons m\n\n----\nShows a doc.\n\n```bynk\n---\nInner.\n---\nfn example() -> Int { 1 }\n```\n----\nfn f() -> Int { 2 }\n";
3359        assert!(strict_codes(src).is_empty(), "{:?}", strict_codes(src));
3360        let c = parse_str(src).unwrap();
3361        let CommonsItem::Fn(f) = &c.items[0] else {
3362            panic!("expected fn")
3363        };
3364        assert!(
3365            f.documentation
3366                .as_deref()
3367                .is_some_and(|d| d.contains("---\nInner.\n---")),
3368            "{:?}",
3369            f.documentation
3370        );
3371    }
3372
3373    /// The `TypeDecl` at `items[i]`.
3374    fn type_at(c: &Commons, i: usize) -> &TypeDecl {
3375        let CommonsItem::Type(t) = &c.items[i] else {
3376            panic!("expected a type at {i}")
3377        };
3378        t
3379    }
3380
3381    fn doc_of(d: &Option<Doc>) -> Option<(&str, DocForm)> {
3382        // An indented `---` block keeps a trailing newline (its closing
3383        // marker's indent survives the marker trim); it prints the same.
3384        d.as_ref().map(|d| (d.as_str().trim_end(), d.form))
3385    }
3386
3387    /// #1888 (DECISIONS A, B): `--|` lines document a declaration; a run
3388    /// joins with its line breaks kept, and a bare `--|` is a blank line.
3389    #[test]
3390    fn doc_lines_document_a_declaration() {
3391        let src = "commons m\n\n--| A commit SHA.\ntype Commit = String\n\n--| First.\n--|\n--|   Indented.\nfn f() -> Int { 1 }\n";
3392        let c = parse_str(src).unwrap();
3393        assert_eq!(
3394            doc_of(&type_at(&c, 0).documentation),
3395            Some(("A commit SHA.", DocForm::Lines))
3396        );
3397        let CommonsItem::Fn(f) = &c.items[1] else {
3398            panic!("expected fn")
3399        };
3400        assert_eq!(
3401            doc_of(&f.documentation),
3402            Some(("First.\n\n  Indented.", DocForm::Lines))
3403        );
3404        assert!(strict_codes(src).is_empty(), "{:?}", strict_codes(src));
3405    }
3406
3407    /// #1888 (DECISION D): record fields, both sum forms' variants, payload
3408    /// fields and message entries take a doc in either form; a `--` comment
3409    /// above a field stays a comment, and so does a `--|` after code on the
3410    /// line (DECISION E).
3411    #[test]
3412    fn members_take_docs_in_either_form() {
3413        let src = "commons m\n\ntype Run = {\n  --| The repository.\n  repo: String,\n  ---\n  The version.\n  ---\n  version: String,\n  -- a note\n  sha: String, --| trailing\n}\n\ntype Outcome = enum {\n  --| Passed.\n  Pass,\n  Fail,\n}\n\ntype Shape =\n  --| Round.\n  | Circle(\n    --| The radius.\n    r: Int,\n  )\n  | Dot\n\n--| After the sum.\nfn f() -> Int { 1 }\n\nmessages \"en\" @reference {\n  --| Greets.\n  \"hi\" => \"Hello\"\n}\n";
3414        assert!(strict_codes(src).is_empty(), "{:?}", strict_codes(src));
3415        let c = parse_str(src).unwrap();
3416        let TypeBody::Record(r) = &type_at(&c, 0).body else {
3417            panic!("record")
3418        };
3419        assert_eq!(
3420            doc_of(&r.fields[0].documentation),
3421            Some(("The repository.", DocForm::Lines))
3422        );
3423        assert_eq!(
3424            doc_of(&r.fields[1].documentation),
3425            Some(("The version.", DocForm::Block))
3426        );
3427        assert_eq!(r.fields[2].documentation, None);
3428        assert_eq!(
3429            r.fields[2].trivia.leading,
3430            vec![Comment::Line(" a note".into())]
3431        );
3432        assert_eq!(r.fields[2].trivia.trailing.as_deref(), Some("| trailing"));
3433        let TypeBody::Sum(e) = &type_at(&c, 1).body else {
3434            panic!("enum")
3435        };
3436        assert_eq!(
3437            doc_of(&e.variants[0].documentation),
3438            Some(("Passed.", DocForm::Lines))
3439        );
3440        assert_eq!(e.variants[1].documentation, None);
3441        let TypeBody::Sum(p) = &type_at(&c, 2).body else {
3442            panic!("sum")
3443        };
3444        assert_eq!(
3445            doc_of(&p.variants[0].documentation),
3446            Some(("Round.", DocForm::Lines))
3447        );
3448        assert_eq!(
3449            doc_of(&p.variants[0].payload[0].documentation),
3450            Some(("The radius.", DocForm::Lines))
3451        );
3452        assert_eq!(p.variants[1].documentation, None);
3453        // The doc after the last variant is the next declaration's.
3454        let CommonsItem::Fn(f) = &c.items[3] else {
3455            panic!("expected fn")
3456        };
3457        assert_eq!(
3458            doc_of(&f.documentation),
3459            Some(("After the sum.", DocForm::Lines))
3460        );
3461        let CommonsItem::Messages(m) = &c.items[4] else {
3462            panic!("expected messages")
3463        };
3464        assert_eq!(
3465            doc_of(&m.entries[0].documentation),
3466            Some(("Greets.", DocForm::Lines))
3467        );
3468    }
3469
3470    /// #1888 (DECISION C): a doc in one form directly followed by one in the
3471    /// other is `bynk.parse.doc_forms_mixed`, on a declaration and on a member,
3472    /// in either order; the recovering parse keeps the unit.
3473    #[test]
3474    fn both_doc_forms_on_one_target_is_doc_forms_mixed() {
3475        for src in [
3476            "commons m\n\n---\nBlock.\n---\n--| Lines.\nfn f() -> Int { 1 }\n",
3477            "commons m\n\n--| Lines.\n---\nBlock.\n---\nfn f() -> Int { 1 }\n",
3478            "commons m\n\ntype R = {\n  --| Lines.\n  ---\n  Block.\n  ---\n  a: Int,\n}\n",
3479        ] {
3480            assert_eq!(strict_codes(src), ["bynk.parse.doc_forms_mixed"], "{src}");
3481            let (unit, errs) = parse_recover_str(src);
3482            assert!(unit.is_some());
3483            assert!(
3484                errs.iter()
3485                    .any(|e| e.category == "bynk.parse.doc_forms_mixed")
3486            );
3487        }
3488    }
3489
3490    /// #1888 review: a doc with only a payload's `)` after it is an orphan
3491    /// warning, and the variant still parses with its field.
3492    #[test]
3493    fn a_dangling_doc_before_a_payload_close_is_only_an_orphan() {
3494        let src =
3495            "commons m\n\ntype S =\n  | Circle(\n    r: Int,\n    --| dangling\n  )\n  | Dot\n";
3496        assert_eq!(strict_codes(src), ["bynk.parse.orphan_doc_block"]);
3497        let c = parse_str(src).unwrap();
3498        let TypeBody::Sum(s) = &type_at(&c, 0).body else {
3499            panic!("sum")
3500        };
3501        assert_eq!(s.variants[0].payload.len(), 1);
3502        assert_eq!(s.variants[0].payload[0].documentation, None);
3503    }
3504
3505    /// #1888: a member doc follows the declaration rules: a blank line
3506    /// orphans it (a warning, kept in place), as does the body's end.
3507    #[test]
3508    fn an_unattached_member_doc_is_an_orphan() {
3509        let src = "commons m\n\ntype R = {\n  --| Spaced.\n\n  a: Int,\n  --| Dangling.\n}\n";
3510        assert_eq!(
3511            strict_codes(src),
3512            ["bynk.parse.orphan_doc_block", "bynk.parse.orphan_doc_block"]
3513        );
3514        let c = parse_str(src).unwrap();
3515        let TypeBody::Record(r) = &type_at(&c, 0).body else {
3516            panic!("record")
3517        };
3518        assert_eq!(r.fields[0].documentation, None);
3519        assert!(matches!(
3520            &r.fields[0].trivia.leading[..],
3521            [Comment::OrphanDoc(d)] if d.as_str() == "Spaced." && d.form == DocForm::Lines
3522        ));
3523        assert!(matches!(
3524            &r.trailing_comments[..],
3525            [Comment::OrphanDoc(d)] if d.as_str() == "Dangling."
3526        ));
3527    }
3528}