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