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::Embed,
1374    TokenKind::Uses,
1375    TokenKind::Consumes,
1376    TokenKind::Exports,
1377    TokenKind::Binding,
1378];
1379
1380/// #1885 (ADR: doc-fence-matched-length): true when a doc block's `content`,
1381/// with its Markdown code fences removed, parses as one or more declarations.
1382///
1383/// The probe is an `adapter` body in fragment form: an adapter accepts every
1384/// item a context or commons does, plus `binding`, and leaves placement to the
1385/// checker. Success means the whole content parsed and declared something; a
1386/// content that only holds another doc block (front matter, say) is prose.
1387///
1388/// Nesting is legal since #1885, so code can hide one level down: a doc block
1389/// inside the content lexes to one opaque token, and its content is probed in
1390/// turn (up to [`DOC_PROBE_MAX_DEPTH`] levels), the outer block reporting what
1391/// any level finds. The probe parser itself does not report on those blocks
1392/// (`doc_probe`). Content that does not lex because it holds a lone `---` (a
1393/// Markdown rule) is probed again with its marker lines blanked out.
1394fn doc_content_is_code(content: &str) -> bool {
1395    doc_content_is_code_at(content, 0)
1396}
1397
1398/// How many levels of nested doc blocks [`doc_content_is_code`] looks into.
1399/// Doc blocks nested this deep are written on purpose; the bound only keeps
1400/// adversarial input from costing a parse per level without end.
1401const DOC_PROBE_MAX_DEPTH: usize = 4;
1402
1403fn doc_content_is_code_at(content: &str, depth: usize) -> bool {
1404    let stripped = strip_code_fences(content);
1405    let tokens = match crate::lexer::tokenize(&stripped) {
1406        Ok(tokens) => tokens,
1407        Err(_) => {
1408            // A lone `---` rule leaves the content unlexable; without the
1409            // marker lines it may still be nothing but declarations.
1410            let unmarked = blank_marker_lines(&stripped);
1411            if unmarked == stripped {
1412                return false;
1413            }
1414            return doc_content_is_code_at(&unmarked, depth);
1415        }
1416    };
1417    if depth < DOC_PROBE_MAX_DEPTH
1418        && tokens.iter().any(|t| {
1419            t.kind == TokenKind::DocBlock
1420                && doc_content_is_code_at(&doc_block_content(&stripped, t.span), depth + 1)
1421        })
1422    {
1423        return true;
1424    }
1425    if !tokens
1426        .iter()
1427        .any(|t| DECLARATION_KEYWORDS.contains(&t.kind))
1428    {
1429        return false;
1430    }
1431    let (filtered, trivia) = split_trivia(&tokens, &stripped);
1432    let mut warnings = Vec::new();
1433    let mut p = Parser::new(&filtered, &stripped, trivia, &mut warnings);
1434    p.doc_probe = true;
1435    let at = Span::new(0, 0);
1436    let name = QualifiedName {
1437        parts: Vec::new(),
1438        span: at,
1439    };
1440    match p.parse_adapter_body(at, name, None, false) {
1441        Ok(a) => {
1442            p.peek().is_none()
1443                && (!a.items.is_empty()
1444                    || !a.uses.is_empty()
1445                    || !a.consumes.is_empty()
1446                    || !a.exports.is_empty()
1447                    || a.binding.is_some())
1448        }
1449        Err(_) => false,
1450    }
1451}
1452
1453/// `content` with every doc-block marker line (three or more dashes alone on
1454/// the line) blanked out, line for line.
1455fn blank_marker_lines(content: &str) -> String {
1456    let mut out = String::with_capacity(content.len());
1457    for line in content.lines() {
1458        let trimmed = line.trim();
1459        if !(trimmed.len() >= 3 && trimmed.bytes().all(|b| b == b'-')) {
1460            out.push_str(line);
1461        }
1462        out.push('\n');
1463    }
1464    out
1465}
1466
1467/// `content` with every Markdown fenced code block (```` ``` ```` or `~~~`,
1468/// three or more, closed by a fence of the same character at least as long)
1469/// blanked out, line for line. An unclosed fence runs to the end, as in
1470/// Markdown. Code shown in a fence is an example, not swallowed code.
1471fn strip_code_fences(content: &str) -> String {
1472    let mut out = String::with_capacity(content.len());
1473    let mut open: Option<(char, usize)> = None;
1474    for line in content.lines() {
1475        let trimmed = line.trim();
1476        let fence = ['`', '~'].into_iter().find_map(|c| {
1477            let n = trimmed.chars().take_while(|&x| x == c).count();
1478            (n >= 3).then_some((c, n))
1479        });
1480        match (open, fence) {
1481            (None, Some(f)) => open = Some(f),
1482            (Some((c, n)), Some((fc, fnum)))
1483                if fc == c && fnum >= n && trimmed.len() == fnum * c.len_utf8() =>
1484            {
1485                open = None;
1486            }
1487            (None, None) => out.push_str(line),
1488            (Some(_), _) => {}
1489        }
1490        out.push('\n');
1491    }
1492    out
1493}
1494
1495/// Parse the body of a lexed double-quoted string literal (the lexeme,
1496/// including surrounding quotes), applying the v0 escape rules.
1497fn parse_string_literal(lexeme: &str, span: Span) -> Result<String, CompileError> {
1498    let bytes = lexeme.as_bytes();
1499    debug_assert!(bytes.first() == Some(&b'"') && bytes.last() == Some(&b'"'));
1500    let inner = &lexeme[1..lexeme.len() - 1];
1501    let mut out = String::with_capacity(inner.len());
1502    let mut chars = inner.chars();
1503    while let Some(c) = chars.next() {
1504        if c == '\\' {
1505            match chars.next() {
1506                Some('n') => out.push('\n'),
1507                Some('t') => out.push('\t'),
1508                Some('"') => out.push('"'),
1509                Some('\\') => out.push('\\'),
1510                other => {
1511                    return Err(CompileError::new(
1512                        "bynk.lex.bad_escape",
1513                        span,
1514                        format!(
1515                            "invalid escape sequence `\\{}` in string literal",
1516                            other.map(|c| c.to_string()).unwrap_or_default()
1517                        ),
1518                    )
1519                    .with_note("supported escapes: \\n \\t \\\" \\\\"));
1520                }
1521            }
1522        } else {
1523            out.push(c);
1524        }
1525    }
1526    Ok(out)
1527}
1528
1529fn is_reserved_keyword(kind: TokenKind) -> bool {
1530    use TokenKind::*;
1531    matches!(
1532        kind,
1533        Commons
1534            | Type
1535            | Fn
1536            | Where
1537            | True
1538            | False
1539            | Int
1540            | String
1541            | Bool
1542            | Let
1543            | If
1544            | Else
1545            | Ok
1546            | Err
1547            | Result
1548            | ValidationError
1549            | Enum
1550            | Match
1551            | Option
1552            | Record
1553            | Self_
1554            | Some
1555            | None
1556            | Is
1557            | Opaque
1558            | Uses
1559            | Context
1560            | Consumes
1561            | Exports
1562            | Transparent
1563            | Agent
1564            | As
1565            | Capability
1566            | Effect
1567            | Do
1568            | Given
1569            | On
1570            | Http
1571            | Provides
1572            | Stub
1573            | Service
1574            | Actor
1575            | By
1576            | Expect
1577            | Suite
1578            | Case
1579            | Float
1580            | Duration
1581            | Instant
1582            | Bytes
1583            | JsonError
1584            | Property
1585            | Adapter
1586            | Binding
1587            | Cron
1588            | Queue
1589            | From
1590            | Protocol
1591            | Invariant
1592            | Implies
1593            | Requires
1594            | Ensures
1595            | Transition
1596    )
1597}
1598
1599/// True when `kind` starts a top-level unit (`commons`/`context`/`adapter`/
1600/// `suite`) or an item within one of their bodies — every keyword any of
1601/// `parse_commons_brace`/`_fragment`, `parse_context_brace`/`_fragment`,
1602/// `parse_test_brace`/`_fragment`, or `parse_adapter_body` dispatches on
1603/// (`declarations.rs`). The single set [`Parser::recover_to_top_item`]'s sync
1604/// scan checks against — finding #27/#30: that scan had drifted from what the
1605/// item loops actually recognise (`Property`, `Actor`, `Event`, `Binding`,
1606/// and the `adapter` unit keyword itself were all missing), so an error
1607/// recovery sync could walk past a real item/unit boundary instead of
1608/// stopping there.
1609fn is_item_start(kind: TokenKind) -> bool {
1610    use TokenKind::*;
1611    matches!(
1612        kind,
1613        // Top-level unit keywords.
1614        Commons | Context | Adapter | Suite
1615        // Body items shared across commons/context/adapter.
1616        | Type | Fn | Messages | Event | Asset | Embed | Uses
1617        // Context/adapter-only body items.
1618        | Consumes | Exports | Capability | Provides | Service | Agent | Actor
1619        // Adapter-only.
1620        | Binding
1621        // Suite/test-only body items.
1622        | Stub | Case | Property
1623    )
1624}
1625
1626#[cfg(test)]
1627mod tests {
1628    use super::*;
1629    use crate::lexer::tokenize;
1630
1631    fn parse_str(src: &str) -> Result<Commons, Vec<CompileError>> {
1632        let toks = tokenize(src).map_err(|e| vec![e])?;
1633        parse(&toks, src)
1634    }
1635
1636    fn parse_recover_str(src: &str) -> (Option<SourceUnit>, Vec<CompileError>) {
1637        let toks = match tokenize(src) {
1638            Ok(t) => t,
1639            Err(e) => return (None, vec![e]),
1640        };
1641        parse_unit_with_recovery(&toks, src)
1642    }
1643
1644    /// Finding #29/#30: `parse_units_with_recovery` keeps every top-level unit
1645    /// an atomic `commons`+`suite` file declares (v0.113, DECISION S), where
1646    /// `parse_unit_with_recovery` keeps only the first — the defect the IDE's
1647    /// old single-unit parse entry point had (it silently discarded the
1648    /// trailing `suite`).
1649    #[test]
1650    fn parse_units_with_recovery_keeps_every_top_level_unit() {
1651        let src =
1652            "commons m {\n  fn f() -> Int { 1 }\n}\n\nsuite m\n\ncase \"c\" {\n  expect true\n}\n";
1653        let toks = tokenize(src).unwrap();
1654        let (units, errors) = parse_units_with_recovery(&toks, src);
1655        assert!(errors.is_empty(), "{errors:?}");
1656        assert_eq!(units.len(), 2, "expected both units, got {units:?}");
1657        assert!(matches!(units[0], SourceUnit::Commons(_)));
1658        assert!(matches!(units[1], SourceUnit::Suite(_)));
1659
1660        // The singular wrapper still narrows to just the first, unchanged.
1661        let (unit, errors) = parse_unit_with_recovery(&toks, src);
1662        assert!(errors.is_empty(), "{errors:?}");
1663        assert!(matches!(unit, Some(SourceUnit::Commons(_))));
1664    }
1665
1666    /// `parse_unit_with_recovery` always attempts at least one parse, even on
1667    /// empty input — `parse_units_with_recovery`'s loop must preserve that
1668    /// (its `while`-style peek check alone would skip the body entirely and
1669    /// silently return no error), since 16+ existing callers rely on an empty
1670    /// file still producing its usual diagnostic rather than a silent `None`
1671    /// with no error at all.
1672    #[test]
1673    fn empty_input_still_reports_an_error_through_the_plural_entry_point() {
1674        let toks = tokenize("").unwrap();
1675        let (units, errors) = parse_units_with_recovery(&toks, "");
1676        assert!(units.is_empty());
1677        assert!(
1678            !errors.is_empty(),
1679            "an empty file must still produce a diagnostic, not silently no units and no error"
1680        );
1681
1682        let (unit, unit_errors) = parse_unit_with_recovery(&toks, "");
1683        assert!(unit.is_none());
1684        assert_eq!(
1685            errors.len(),
1686            unit_errors.len(),
1687            "the singular wrapper must see the same error(s) as the plural entry point"
1688        );
1689    }
1690
1691    /// Finding #66: `parse_units_with_drain_check`'s `fully_drained` flag is
1692    /// `bynk-fmt`'s signal for whether its comment-loss guard can skip a
1693    /// re-tokenize-and-diff of its own output. It must be `true` for an
1694    /// ordinary file (every comment sits before a declaration/statement or
1695    /// trails one) and `false` the moment a comment sits inside an expression
1696    /// subtree, where `TriviaTable` has no field to attach it to.
1697    #[test]
1698    fn drain_check_reports_expression_interior_comments_as_undrained() {
1699        let ordinary = "commons x {\n// note\ntype T = Int where Positive\n}\n";
1700        let toks = tokenize(ordinary).unwrap();
1701        let (_, _, drained) = parse_units_with_drain_check(&toks, ordinary).unwrap();
1702        assert!(
1703            drained,
1704            "a declaration-leading comment must be fully drained"
1705        );
1706
1707        let lossy = "commons x {\n  fn f() -> Int {\n    1 + // note\n    2\n  }\n}\n";
1708        let toks = tokenize(lossy).unwrap();
1709        let (_, _, drained) = parse_units_with_drain_check(&toks, lossy).unwrap();
1710        assert!(
1711            !drained,
1712            "a comment inside a binop expression must be reported as undrained"
1713        );
1714    }
1715
1716    #[test]
1717    fn eof_span_never_splits_a_multibyte_codepoint() {
1718        // An unterminated construct whose final line ends in a non-ASCII char
1719        // (here a `--` comment ending in `→`) once produced an `unexpected_eof`
1720        // span of `len - 1 .. len`, landing on the arrow's last continuation
1721        // byte. Every reported span must sit on char boundaries.
1722        for src in [
1723            "commons x {\n  // ends with an arrow →",
1724            "agent A {\n  key k: String\n  // note 🦀",
1725            "commons y {\n  type T = é",
1726        ] {
1727            let (_unit, errors) = parse_recover_str(src);
1728            for e in &errors {
1729                assert!(
1730                    src.is_char_boundary(e.span.start) && src.is_char_boundary(e.span.end),
1731                    "span {:?} splits a codepoint in {src:?}",
1732                    e.span,
1733                );
1734            }
1735        }
1736    }
1737
1738    #[test]
1739    fn reserved_contextual_keywords_readable_in_expression_position() {
1740        // Events track, slice 0 (#939): `expect_ident`'s `RESERVED_CONTEXTUAL`
1741        // exemption (`keywords::RESERVED_CONTEXTUAL`: case/event/messages/on/
1742        // suite) covers *declaring* a binding with one of these names — a
1743        // parameter, a `let` — but the primary-expression parser previously
1744        // only admitted plain `TokenKind::Ident` when *reading one back*.
1745        // Latent since messages/on/case/suite shipped (no fixture happened to
1746        // name a binding after one of them and read it back inside the body);
1747        // surfaced concretely when `event` joined the tier and collided with
1748        // `examples/event-log`'s pre-existing `add(event: Event)` handler.
1749        for kw in ["asset", "case", "embed", "event", "messages", "on", "suite"] {
1750            let src = format!("commons x\n\nfn f({kw}: Int) -> Int {{\n  {kw}\n}}\n");
1751            let result = parse_str(&src);
1752            assert!(
1753                result.is_ok(),
1754                "a parameter named `{kw}` must be readable in expression position: {:?}",
1755                result.err()
1756            );
1757        }
1758    }
1759
1760    #[test]
1761    fn recovery_skips_garbage_between_decls() {
1762        // Two `type` declarations separated by garbage. Recovery should
1763        // accept both and report one error for the garbage between them.
1764        let src = "commons x {\n\
1765                   type A = Int where NonNegative\n\
1766                   ??? !!!\n\
1767                   type B = String where NonEmpty\n\
1768                   }";
1769        let (unit, errors) = parse_recover_str(src);
1770        let unit = unit.expect("recovery should produce a partial AST");
1771        let SourceUnit::Commons(c) = unit else {
1772            panic!("expected commons")
1773        };
1774        // Both type decls should have been collected despite the garbage.
1775        let names: Vec<_> = c
1776            .items
1777            .iter()
1778            .map(|i| match i {
1779                CommonsItem::Type(t) => t.name.name.clone(),
1780                _ => panic!("expected only types"),
1781            })
1782            .collect();
1783        assert!(
1784            names.contains(&"A".to_string()) && names.contains(&"B".to_string()),
1785            "expected both A and B; got {names:?}",
1786        );
1787        assert!(!errors.is_empty(), "expected at least one parse error");
1788    }
1789
1790    #[test]
1791    fn recovery_handles_bad_first_decl_then_good_second() {
1792        // First decl is malformed (missing `=`); second is well-formed.
1793        let src = "commons x {\n\
1794                   type A Int where NonNegative\n\
1795                   type B = String where NonEmpty\n\
1796                   }";
1797        let (unit, errors) = parse_recover_str(src);
1798        let unit = unit.expect("recovery should produce a partial AST");
1799        let SourceUnit::Commons(c) = unit else {
1800            panic!("expected commons")
1801        };
1802        let names: Vec<_> = c
1803            .items
1804            .iter()
1805            .filter_map(|i| match i {
1806                CommonsItem::Type(t) => Some(t.name.name.clone()),
1807                _ => None,
1808            })
1809            .collect();
1810        assert!(
1811            names.contains(&"B".to_string()),
1812            "B should be parsed after A's failure; got {names:?}"
1813        );
1814        assert!(!errors.is_empty(), "expected at least one parse error");
1815    }
1816
1817    /// Finding #27/#30: an error two levels deep inside `f`'s body (a
1818    /// `match` arm's own block) used to make `recover_to_top_item`'s flat,
1819    /// depth-blind scan stop at the *first* `}` it saw — the arm block's own,
1820    /// not `f`'s. Two more `}` (the match's, then `f`'s) then got consumed one
1821    /// at a time across repeated recovery re-entries, and the outer item loop
1822    /// eventually mistook the commons's *own* closing `}` for having arrived
1823    /// early, returning zero items and a spurious second
1824    /// `bynk.parse.expected_unit_header` error. With brace-depth tracking, `g`
1825    /// is recovered as the sole item and only `f`'s own error is reported.
1826    /// #1663: an item that ends without its body (`fn f() -> Int` then the
1827    /// commons' own `}`) raises its error *on* that `}`, so recovery makes no
1828    /// progress. The no-progress step must not consume the brace that closes
1829    /// the body (brace form): doing so made the body look unclosed, a
1830    /// follow-on `unexpected_eof`.
1831    #[test]
1832    fn recovery_keeps_the_bodys_closing_brace_after_a_bodiless_item() {
1833        let src = "commons m {\n  fn f() -> Int\n}\n";
1834        let (unit, errors) = parse_recover_str(src);
1835        assert!(unit.is_some(), "recovery should produce a partial AST");
1836        let categories: Vec<_> = errors.iter().map(|e| e.category).collect();
1837        assert_eq!(
1838            categories.len(),
1839            1,
1840            "one syntax error, no follow-on: {categories:?}"
1841        );
1842        assert!(
1843            !categories.contains(&"bynk.parse.unexpected_eof"),
1844            "the commons' closing brace must survive: {categories:?}"
1845        );
1846    }
1847
1848    /// #1663: an item keyword illegal at this position (`agent` in a commons)
1849    /// is skipped *with* its name and `{ … }` body, not one token at a time —
1850    /// otherwise the item loop misreads the agent's name as a malformed item,
1851    /// a second, follow-on `expected_item`. The next item still parses.
1852    #[test]
1853    fn recovery_skips_a_rejected_item_whole() {
1854        let src = "commons m\n\nagent Counter {\n  key id: String\n}\n\nfn g() -> Int { 2 }\n";
1855        let recovered = parse_units_recovering(&crate::lexer::tokenize(src).unwrap(), src);
1856        assert_eq!(
1857            recovered.errors.len(),
1858            1,
1859            "one syntax error, no follow-on: {:?}",
1860            recovered
1861                .errors
1862                .iter()
1863                .map(|e| e.category)
1864                .collect::<Vec<_>>()
1865        );
1866        let Some(SourceUnit::Commons(c)) = recovered.units.first() else {
1867            panic!("expected commons")
1868        };
1869        assert!(
1870            c.items.iter().any(|i| matches!(i, CommonsItem::Fn(_))),
1871            "the `fn g` after the skipped agent must still parse"
1872        );
1873        assert_eq!(recovered.broken_decl_names, ["Counter"]);
1874    }
1875
1876    #[test]
1877    fn recovery_skips_a_nested_blocks_own_closing_brace() {
1878        let src = "commons m {\n  \
1879                   fn f() -> Int {\n    \
1880                   match 1 {\n      \
1881                   is 1 -> { let z = }\n      \
1882                   is _ -> 2\n    \
1883                   }\n  \
1884                   }\n  \
1885                   fn g() -> Int { 2 }\n\
1886                   }\n";
1887        let (unit, errors) = parse_recover_str(src);
1888        let unit = unit.expect("recovery should produce a partial AST");
1889        let SourceUnit::Commons(c) = unit else {
1890            panic!("expected commons")
1891        };
1892        let names: Vec<_> = c
1893            .items
1894            .iter()
1895            .filter_map(|i| match i {
1896                CommonsItem::Fn(f) => match &f.name {
1897                    FnName::Free(id) => Some(id.name.clone()),
1898                    _ => None,
1899                },
1900                _ => None,
1901            })
1902            .collect();
1903        assert_eq!(
1904            names,
1905            vec!["g".to_string()],
1906            "g must still be recovered as an item; got {names:?}"
1907        );
1908        assert!(
1909            !errors
1910                .iter()
1911                .any(|e| e.category == "bynk.parse.expected_unit_header"),
1912            "the outer body's own closing brace must not be mistaken for \
1913             end-of-file: {errors:?}"
1914        );
1915    }
1916
1917    #[test]
1918    fn doc_block_attaches_to_type() {
1919        let c =
1920            parse_str("commons x {\n---\nA descriptive doc.\n---\ntype T = Int where Positive\n}")
1921                .unwrap();
1922        let CommonsItem::Type(t) = &c.items[0] else {
1923            panic!()
1924        };
1925        assert!(t.documentation.is_some());
1926        assert!(
1927            t.documentation
1928                .as_ref()
1929                .unwrap()
1930                .contains("A descriptive doc.")
1931        );
1932    }
1933
1934    #[test]
1935    fn interpolated_string_parses_into_parts() {
1936        // v0.43: `"Hi, \(name)!"` splits into chunk / hole / chunk.
1937        let c = parse_str("commons x\n\nfn f(name: String) -> String {\n  \"Hi, \\(name)!\"\n}\n")
1938            .unwrap();
1939        let CommonsItem::Fn(f) = &c.items[0] else {
1940            panic!("expected fn")
1941        };
1942        let ExprKind::InterpStr(parts) = &f.body.tail.kind else {
1943            panic!("expected InterpStr, got {:?}", f.body.tail.kind)
1944        };
1945        assert_eq!(parts.len(), 3);
1946        assert!(matches!(&parts[0], InterpPart::Chunk(s) if s == "Hi, "));
1947        assert!(
1948            matches!(&parts[1], InterpPart::Hole(h) if matches!(&h.kind, ExprKind::Ident(id) if id.name == "name"))
1949        );
1950        assert!(matches!(&parts[2], InterpPart::Chunk(s) if s == "!"));
1951    }
1952
1953    #[test]
1954    fn interpolated_hole_parses_a_full_expression() {
1955        // A hole holds an arbitrary expression, not just an identifier.
1956        let c =
1957            parse_str("commons x\n\nfn f(a: Int, b: Int) -> String {\n  \"sum = \\(a + b)\"\n}\n")
1958                .unwrap();
1959        let CommonsItem::Fn(f) = &c.items[0] else {
1960            panic!("expected fn")
1961        };
1962        let ExprKind::InterpStr(parts) = &f.body.tail.kind else {
1963            panic!("expected InterpStr")
1964        };
1965        assert!(matches!(&parts[1], InterpPart::Hole(h) if matches!(&h.kind, ExprKind::BinOp(..))));
1966    }
1967
1968    #[test]
1969    fn empty_interpolation_hole_is_rejected() {
1970        let errs = parse_str("commons x\n\nfn f() -> String {\n  \"\\()\"\n}\n").unwrap_err();
1971        assert!(
1972            errs.iter()
1973                .any(|e| e.category == "bynk.parse.empty_interpolation"),
1974            "expected empty_interpolation; got {errs:?}"
1975        );
1976    }
1977
1978    #[test]
1979    fn interpolation_hole_lex_error_span_is_rebased() {
1980        // #716: a lex error inside a `\(…)` hole once carried a span relative to
1981        // the hole substring — never rebased by `hole.start` — so it pointed at
1982        // the file's opening bytes and could split a multibyte char, tripping
1983        // the char-boundary invariant. The error must land on the offending
1984        // bytes within the hole and stay on char boundaries.
1985        let cases = [
1986            // `$` is not a valid token; the error should point at it, not byte 0.
1987            "commons x\n\nfn f() -> String {\n  \"a \\($)\"\n}\n",
1988            // Integer overflow — the reported span must cover the literal itself.
1989            "commons x\n\nfn f() -> String {\n  \"n = \\(99999999999999999999)\"\n}\n",
1990            // A multibyte char before the hole means an un-rebased span could
1991            // land inside the `é`; the rebased span must not.
1992            "commons x\n\nfn f() -> String {\n  \"é \\($)\"\n}\n",
1993        ];
1994        for src in cases {
1995            let errs = parse_str(src).unwrap_err();
1996            assert!(!errs.is_empty(), "expected a lex error for {src:?}");
1997            for e in &errs {
1998                assert!(
1999                    src.is_char_boundary(e.span.start) && src.is_char_boundary(e.span.end),
2000                    "span {:?} splits a codepoint in {src:?}",
2001                    e.span,
2002                );
2003                // The error must point inside the interpolation hole, not at the
2004                // header text that precedes it.
2005                let hole_start = src.find("\\(").expect("case has a hole") + 2;
2006                assert!(
2007                    e.span.start >= hole_start,
2008                    "span {:?} precedes the hole (starts at {hole_start}) in {src:?}",
2009                    e.span,
2010                );
2011            }
2012        }
2013    }
2014
2015    #[test]
2016    fn fragment_form_parses() {
2017        let c = parse_str("commons x.y\n\ntype T = Int where NonNegative\n").unwrap();
2018        assert_eq!(c.form, CommonsForm::Fragment);
2019        assert_eq!(c.items.len(), 1);
2020    }
2021
2022    #[test]
2023    fn uses_parses() {
2024        let c = parse_str("commons x\n\nuses other.lib\n").unwrap();
2025        assert_eq!(c.uses.len(), 1);
2026        assert_eq!(c.uses[0].target.joined(), "other.lib");
2027    }
2028
2029    fn parse_unit_str(src: &str) -> Result<SourceUnit, Vec<CompileError>> {
2030        let toks = tokenize(src).map_err(|e| vec![e])?;
2031        parse_unit(&toks, src)
2032    }
2033
2034    #[test]
2035    fn minimal_context_parses() {
2036        let u = parse_unit_str("context commerce.orders {}").unwrap();
2037        let SourceUnit::Context(c) = u else {
2038            panic!("expected context");
2039        };
2040        assert_eq!(c.name.joined(), "commerce.orders");
2041        assert!(c.items.is_empty());
2042    }
2043
2044    #[test]
2045    fn context_consumes_and_exports_parse() {
2046        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}";
2047        let u = parse_unit_str(src).unwrap();
2048        let SourceUnit::Context(c) = u else { panic!() };
2049        assert_eq!(c.uses.len(), 1);
2050        assert_eq!(c.consumes.len(), 1);
2051        assert_eq!(c.exports.len(), 2);
2052        assert_eq!(c.exports[0].kind, ExportKind::Type(Visibility::Opaque));
2053        assert_eq!(c.exports[1].kind, ExportKind::Type(Visibility::Transparent));
2054    }
2055
2056    #[test]
2057    fn context_fragment_form_parses() {
2058        let src = "context x.y\n\nuses other.lib\nconsumes other.ctx\nexports opaque { T }\n\ntype T = Int where NonNegative\n";
2059        let u = parse_unit_str(src).unwrap();
2060        let SourceUnit::Context(c) = u else { panic!() };
2061        assert_eq!(c.form, CommonsForm::Fragment);
2062        assert_eq!(c.uses.len(), 1);
2063        assert_eq!(c.consumes.len(), 1);
2064        assert_eq!(c.exports.len(), 1);
2065    }
2066
2067    #[test]
2068    fn opaque_type_parses() {
2069        let c = parse_str("commons x { type T = opaque Int where NonNegative }").unwrap();
2070        let CommonsItem::Type(t) = &c.items[0] else {
2071            panic!()
2072        };
2073        assert!(matches!(t.body, TypeBody::Opaque { .. }));
2074    }
2075
2076    #[test]
2077    fn empty_commons() {
2078        let c = parse_str("commons fitness.units {}").unwrap();
2079        assert_eq!(c.name.joined(), "fitness.units");
2080        assert!(c.items.is_empty());
2081    }
2082
2083    #[test]
2084    fn one_type_decl() {
2085        let c = parse_str("commons x { type Metres = Int where NonNegative }").unwrap();
2086        assert_eq!(c.items.len(), 1);
2087        let CommonsItem::Type(t) = &c.items[0] else {
2088            panic!()
2089        };
2090        assert_eq!(t.name.name, "Metres");
2091        match &t.body {
2092            TypeBody::Refined {
2093                base, refinement, ..
2094            } => {
2095                assert_eq!(*base, BaseType::Int);
2096                assert!(refinement.is_some());
2097            }
2098            _ => panic!("expected refined body"),
2099        }
2100    }
2101
2102    #[test]
2103    fn function_decl() {
2104        let c = parse_str("commons x { fn add(a: Int, b: Int) -> Int { a + b } }").unwrap();
2105        let CommonsItem::Fn(f) = &c.items[0] else {
2106            panic!()
2107        };
2108        assert_eq!(f.name.ident().name, "add");
2109        assert_eq!(f.params.len(), 2);
2110    }
2111
2112    #[test]
2113    fn chained_comparison_is_error() {
2114        let errs = parse_str("commons x { fn f(a: Int, b: Int, c: Int) -> Bool { a < b < c } }")
2115            .unwrap_err();
2116        assert_eq!(errs[0].category, "bynk.parse.non_associative");
2117    }
2118
2119    #[test]
2120    fn chained_equality_is_error() {
2121        let errs = parse_str("commons x { fn f(a: Int, b: Int, c: Int) -> Bool { a == b == c } }")
2122            .unwrap_err();
2123        assert_eq!(errs[0].category, "bynk.parse.non_associative");
2124    }
2125
2126    /// Run `f` on a thread with a generous stack. The depth-guard tests build
2127    /// source that, *without* the guard, overflows — so if the guard ever
2128    /// regressed we want a clean assertion failure, not a `SIGABRT` that takes
2129    /// the whole test binary down. A large stack also absorbs the fat frames a
2130    /// debug build spends per recursion level (production release frames are
2131    /// ~9 KB/level, so `MAX_NESTING_DEPTH = 64` sits well inside a 1 MB stack;
2132    /// a debug frame is several times larger and would overflow libtest's
2133    /// default 2 MB test thread near the limit even though the guard fires).
2134    fn on_big_stack<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
2135        std::thread::Builder::new()
2136            .stack_size(64 * 1024 * 1024)
2137            .spawn(f)
2138            .unwrap()
2139            .join()
2140            .unwrap()
2141    }
2142
2143    #[test]
2144    fn deeply_nested_parens_are_bounded_not_overflowed() {
2145        // Without a depth guard the parenthesised-expression recursion
2146        // (`parse_primary` -> `parse_expr` -> …) overflows the stack and aborts
2147        // the process (#713). Well past the limit it must instead report a
2148        // bounded-depth diagnostic. The nesting is left open so the guard, not
2149        // a later `)`, is what stops the descent.
2150        let errs = on_big_stack(|| {
2151            let depth = crate::MAX_NESTING_DEPTH + 8;
2152            let src = format!(
2153                "commons x {{ fn f() -> Int {{ {}0{} }} }}",
2154                "(".repeat(depth),
2155                ")".repeat(depth),
2156            );
2157            parse_str(&src).unwrap_err()
2158        });
2159        assert_eq!(errs[0].category, "bynk.parse.nesting_too_deep");
2160    }
2161
2162    #[test]
2163    fn deeply_nested_types_are_bounded_not_overflowed() {
2164        // The type parser self-recurses through generic type arguments
2165        // (`parse_type_ref` -> `parse_type_atom` -> `parse_type_ref`); the same
2166        // guard bounds it (#713). A right-nested `Result[Int, …]` in parameter
2167        // position drives that recursion.
2168        let errs = on_big_stack(|| {
2169            let depth = crate::MAX_NESTING_DEPTH + 8;
2170            let src = format!(
2171                "commons x {{ fn f(x: {}Int{}) -> Int {{ 0 }} }}",
2172                "Result[Int, ".repeat(depth),
2173                "]".repeat(depth),
2174            );
2175            parse_str(&src).unwrap_err()
2176        });
2177        assert_eq!(errs[0].category, "bynk.parse.nesting_too_deep");
2178    }
2179
2180    #[test]
2181    fn deeply_nested_patterns_are_bounded_not_overflowed() {
2182        // Variant patterns are a third self-recursive descent (`parse_pattern`
2183        // -> `parse_pattern_binding` -> `parse_pattern`) that routes through
2184        // neither `parse_expr` nor `parse_type_ref`; without its own guard a
2185        // nested `Ok(Ok(…))` match arm reproduces the #713 crash.
2186        let errs = on_big_stack(|| {
2187            let depth = crate::MAX_NESTING_DEPTH + 8;
2188            let src = format!(
2189                "commons x {{ fn f(n: Int) -> Int {{ match n {{ {}n{} => 0 }} }} }}",
2190                "Ok(".repeat(depth),
2191                ")".repeat(depth),
2192            );
2193            parse_str(&src).unwrap_err()
2194        });
2195        assert_eq!(errs[0].category, "bynk.parse.nesting_too_deep");
2196    }
2197
2198    #[test]
2199    fn nesting_below_the_limit_still_parses() {
2200        // The guard must not reject ordinary well-nested source: a paren-nested
2201        // expression comfortably under the limit still parses cleanly.
2202        let ok = on_big_stack(|| {
2203            let depth = crate::MAX_NESTING_DEPTH - 8;
2204            let src = format!(
2205                "commons x {{ fn f() -> Int {{ {}0{} }} }}",
2206                "(".repeat(depth),
2207                ")".repeat(depth),
2208            );
2209            parse_str(&src).is_ok()
2210        });
2211        assert!(ok, "well-nested source under the limit should parse");
2212    }
2213
2214    #[test]
2215    fn let_statement_parses() {
2216        let c = parse_str("commons x { fn f(n: Int) -> Int { let y = n + 1\n y } }").unwrap();
2217        let CommonsItem::Fn(f) = &c.items[0] else {
2218            panic!()
2219        };
2220        assert_eq!(f.body.statements.len(), 1);
2221        match &f.body.statements[0] {
2222            Statement::Let(l) => {
2223                assert_eq!(l.name.name, "y");
2224                assert!(l.type_annot.is_none());
2225            }
2226            _ => panic!("expected a pure `let` statement"),
2227        }
2228    }
2229
2230    #[test]
2231    fn let_with_annotation() {
2232        let c = parse_str("commons x { fn f(n: Int) -> Int { let y: Int = n\n y } }").unwrap();
2233        let CommonsItem::Fn(f) = &c.items[0] else {
2234            panic!()
2235        };
2236        match &f.body.statements[0] {
2237            Statement::Let(l) => assert!(l.type_annot.is_some()),
2238            _ => panic!("expected a pure `let` statement"),
2239        }
2240    }
2241
2242    #[test]
2243    fn if_else_parses_as_expression() {
2244        let c = parse_str("commons x { fn f(b: Bool) -> Int { if b { 1 } else { 0 } } }").unwrap();
2245        let CommonsItem::Fn(f) = &c.items[0] else {
2246            panic!()
2247        };
2248        assert!(matches!(f.body.tail.kind, ExprKind::If { .. }));
2249    }
2250
2251    #[test]
2252    fn else_if_chain_parses() {
2253        let c = parse_str(
2254            "commons x { fn f(n: Int) -> Int { if n < 0 { -1 } else if n == 0 { 0 } else { 1 } } }",
2255        )
2256        .unwrap();
2257        let CommonsItem::Fn(f) = &c.items[0] else {
2258            panic!()
2259        };
2260        let ExprKind::If { else_block, .. } = &f.body.tail.kind else {
2261            panic!()
2262        };
2263        // The else-branch is a block whose tail is another `If`.
2264        assert!(else_block.statements.is_empty());
2265        assert!(matches!(else_block.tail.kind, ExprKind::If { .. }));
2266    }
2267
2268    #[test]
2269    fn ok_and_err_parse_as_expressions() {
2270        let c = parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Ok(n) } }").unwrap();
2271        let CommonsItem::Fn(f) = &c.items[0] else {
2272            panic!()
2273        };
2274        assert!(matches!(f.body.tail.kind, ExprKind::Ok(_)));
2275
2276        let c =
2277            parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Err(\"x\") } }").unwrap();
2278        let CommonsItem::Fn(f) = &c.items[0] else {
2279            panic!()
2280        };
2281        assert!(matches!(f.body.tail.kind, ExprKind::Err(_)));
2282    }
2283
2284    #[test]
2285    fn question_postfix_parses() {
2286        let c = parse_str(
2287            "commons x { type T = Int where Positive\n fn f(n: Int) -> Result[T, ValidationError] { let x = T.of(n)?\n Ok(x) } }",
2288        )
2289        .unwrap();
2290        let CommonsItem::Fn(f) = &c.items[1] else {
2291            panic!()
2292        };
2293        let Statement::Let(l) = &f.body.statements[0] else {
2294            panic!("expected a pure `let` statement");
2295        };
2296        assert!(matches!(l.value.kind, ExprKind::Question(_)));
2297    }
2298
2299    #[test]
2300    fn constructor_call_parses() {
2301        let c = parse_str(
2302            "commons x { type T = Int where Positive\n fn f(n: Int) -> Result[T, ValidationError] { T.of(n) } }",
2303        )
2304        .unwrap();
2305        let CommonsItem::Fn(f) = &c.items[1] else {
2306            panic!()
2307        };
2308        // v0.2: T.of(n) parses as a MethodCall with receiver Ident("T"); the
2309        // checker reinterprets it as a static call by noticing T is a type.
2310        let ExprKind::MethodCall {
2311            receiver, method, ..
2312        } = &f.body.tail.kind
2313        else {
2314            panic!("expected MethodCall, got {:?}", f.body.tail.kind)
2315        };
2316        let ExprKind::Ident(id) = &receiver.kind else {
2317            panic!("expected receiver Ident");
2318        };
2319        assert_eq!(id.name, "T");
2320        assert_eq!(method.name, "of");
2321    }
2322
2323    #[test]
2324    fn result_type_ref_parses() {
2325        let c = parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Ok(n) } }").unwrap();
2326        let CommonsItem::Fn(f) = &c.items[0] else {
2327            panic!()
2328        };
2329        assert!(matches!(f.return_type, TypeRef::Result(_, _, _)));
2330    }
2331
2332    #[test]
2333    fn result_missing_arg_count_errors() {
2334        let errs = parse_str("commons x { fn f(n: Int) -> Result[Int] { Ok(n) } }").unwrap_err();
2335        assert_eq!(errs[0].category, "bynk.parse.generic_arg_count");
2336    }
2337
2338    #[test]
2339    fn field_access_parses_in_v0_2() {
2340        // v0.2: field access is supported (the type checker validates the
2341        // field exists on the receiver's type). Parser-level acceptance:
2342        let c =
2343            parse_str("commons x { type R = { foo: Int }\n fn f(r: R) -> Int { r.foo } }").unwrap();
2344        let CommonsItem::Fn(f) = &c.items[1] else {
2345            panic!()
2346        };
2347        assert!(matches!(f.body.tail.kind, ExprKind::FieldAccess { .. }));
2348    }
2349
2350    // -- v1.1 trivia attachment --
2351
2352    #[test]
2353    fn leading_line_comment_attaches_to_next_decl() {
2354        let src = "commons x {\n// explain the type\ntype T = Int where NonNegative\n}";
2355        let c = parse_str(src).unwrap();
2356        let CommonsItem::Type(t) = &c.items[0] else {
2357            panic!()
2358        };
2359        assert_eq!(
2360            t.trivia.leading,
2361            vec![Comment::Line(" explain the type".to_string())]
2362        );
2363        assert!(t.trivia.trailing.is_none());
2364    }
2365
2366    #[test]
2367    fn trailing_line_comment_attaches_to_prev_decl() {
2368        let src = "commons x {\ntype T = Int where NonNegative  // trailing note\n}";
2369        let c = parse_str(src).unwrap();
2370        let CommonsItem::Type(t) = &c.items[0] else {
2371            panic!()
2372        };
2373        assert!(t.trivia.leading.is_empty());
2374        assert_eq!(t.trivia.trailing.as_deref(), Some(" trailing note"));
2375    }
2376
2377    #[test]
2378    fn grouped_leading_comments_attach_together() {
2379        let src = "commons x {\n// one\n// two\n// three\ntype T = Int where Positive\n}";
2380        let c = parse_str(src).unwrap();
2381        let CommonsItem::Type(t) = &c.items[0] else {
2382            panic!()
2383        };
2384        assert_eq!(
2385            t.trivia.leading,
2386            vec![
2387                Comment::Line(" one".to_string()),
2388                Comment::Line(" two".to_string()),
2389                Comment::Line(" three".to_string())
2390            ],
2391        );
2392    }
2393
2394    #[test]
2395    fn comment_with_doc_block_keeps_both() {
2396        // Both `-- intro` and the doc block should attach to the type decl.
2397        let src = "commons x {\n// intro\n---\ndocs\n---\ntype T = Int where Positive\n}";
2398        let c = parse_str(src).unwrap();
2399        let CommonsItem::Type(t) = &c.items[0] else {
2400            panic!()
2401        };
2402        assert_eq!(t.trivia.leading, vec![Comment::Line(" intro".to_string())]);
2403        assert_eq!(t.documentation.as_deref(), Some("docs"));
2404    }
2405
2406    #[test]
2407    fn messages_keyword_does_not_collide_with_a_commons_name_segment() {
2408        // `messages` is RESERVED_CONTEXTUAL (like `case`/`on`/`suite`), not a
2409        // hard keyword: `commons app.messages { ... }` — the design's own
2410        // natural naming choice for a bundle commons — must still parse.
2411        // (Caught during slice-1 implementation: a first pass made `messages`
2412        // a plain hard keyword and this exact name broke.)
2413        let src = "commons app.messages {\ntype T = Int where Positive\n}";
2414        let c = parse_str(src).unwrap();
2415        assert_eq!(c.name.joined(), "app.messages");
2416    }
2417
2418    #[test]
2419    fn messages_decl_parses_tag_annotation_and_entries() {
2420        // message-bundles slice 1 (#859): the construct + doc/trivia wiring.
2421        let src = "commons app.messages {\n\
2422                   // intro\n\
2423                   ---\n\
2424                   docs\n\
2425                   ---\n\
2426                   messages \"en\" @reference {\n\
2427                   \"greeting\" => \"Hello, {name}!\"\n\
2428                   \"farewell\" => \"Bye\"\n\
2429                   } // trailing\n\
2430                   }";
2431        let c = parse_str(src).unwrap();
2432        let CommonsItem::Messages(m) = &c.items[0] else {
2433            panic!("expected a messages item, got {:?}", c.items[0]);
2434        };
2435        assert_eq!(m.tag, "en");
2436        assert_eq!(m.annotations.len(), 1);
2437        assert_eq!(m.annotations[0].name.name, "reference");
2438        assert!(m.annotations[0].args.is_empty());
2439        assert_eq!(m.entries.len(), 2);
2440        assert_eq!(m.entries[0].code, "greeting");
2441        assert_eq!(m.entries[0].template, "Hello, {name}!");
2442        assert_eq!(m.entries[1].code, "farewell");
2443        assert_eq!(m.entries[1].template, "Bye");
2444        assert_eq!(m.trivia.leading, vec![Comment::Line(" intro".to_string())]);
2445        assert_eq!(m.documentation.as_deref(), Some("docs"));
2446        assert_eq!(m.trivia.trailing.as_deref(), Some(" trailing"));
2447    }
2448
2449    #[test]
2450    fn messages_decl_parses_with_no_annotation_and_no_entries() {
2451        // The parser stays permissive on annotation cardinality (zero-or-more)
2452        // — "exactly one `@reference`" is a checker concern (validate.rs), not
2453        // a parse error.
2454        let src = "commons app.messages {\nmessages \"en\" {\n}\n}";
2455        let c = parse_str(src).unwrap();
2456        let CommonsItem::Messages(m) = &c.items[0] else {
2457            panic!("expected a messages item, got {:?}", c.items[0]);
2458        };
2459        assert_eq!(m.tag, "en");
2460        assert!(m.annotations.is_empty());
2461        assert!(m.entries.is_empty());
2462    }
2463
2464    #[test]
2465    fn messages_decl_parses_syntactically_inside_a_context_too() {
2466        // Commons-only legality is a checker concern (bynk.messages.outside_commons
2467        // in bynk-emit's project validation), not a parser rejection — mirrors
2468        // how `service`/`agent` already parse syntactically inside `adapter`
2469        // bodies for the same reason.
2470        let src = "context app.svc {\nmessages \"en\" @reference {\n\"a\" => \"b\"\n}\n}";
2471        let toks = tokenize(src).unwrap();
2472        let (unit, errors) = parse_unit_with_recovery(&toks, src);
2473        assert!(errors.is_empty(), "unexpected parse errors: {errors:?}");
2474        let Some(SourceUnit::Context(ctx)) = unit else {
2475            panic!("expected a context")
2476        };
2477        let CommonsItem::Messages(m) = &ctx.items[0] else {
2478            panic!("expected a messages item, got {:?}", ctx.items[0]);
2479        };
2480        assert_eq!(m.tag, "en");
2481    }
2482
2483    #[test]
2484    fn comment_before_let_statement_attaches() {
2485        let src = "commons x {\nfn f(n: Int) -> Int {\n// pick a value\nlet y = n + 1\ny\n}\n}";
2486        let c = parse_str(src).unwrap();
2487        let CommonsItem::Fn(f) = &c.items[0] else {
2488            panic!()
2489        };
2490        let Statement::Let(l) = &f.body.statements[0] else {
2491            panic!()
2492        };
2493        assert_eq!(
2494            l.trivia.leading,
2495            vec![Comment::Line(" pick a value".to_string())]
2496        );
2497    }
2498
2499    #[test]
2500    fn comment_before_tail_attaches_to_block_tail() {
2501        let src = "commons x {\nfn f(n: Int) -> Int {\nlet y = n + 1\n// result\ny\n}\n}";
2502        let c = parse_str(src).unwrap();
2503        let CommonsItem::Fn(f) = &c.items[0] else {
2504            panic!()
2505        };
2506        assert_eq!(
2507            f.body.tail_leading_comments,
2508            vec![Comment::Line(" result".to_string())],
2509        );
2510    }
2511
2512    /// #637 Gap A: the contextual keywords `on` / `suite` / `case` are lexer
2513    /// tokens but `expect_ident` admits them as identifiers outside their one
2514    /// keyword position, so they are valid record-field and parameter names.
2515    /// The keyword reference now renders them as a distinct "contextual" tier
2516    /// rather than claiming (falsely) that they cannot be used as identifiers.
2517    #[test]
2518    fn contextual_keywords_are_valid_identifiers() {
2519        // Record field names.
2520        let c = parse_str("commons demo {\n  type R = { on: Int, suite: String, case: Bool }\n}")
2521            .expect("`on`/`suite`/`case` are valid field names");
2522        let CommonsItem::Type(_) = &c.items[0] else {
2523            panic!("expected a type decl")
2524        };
2525
2526        // Function parameter names (the other `expect_ident` position).
2527        parse_str("commons demo {\n  fn f(on: Int, case: Int) -> Int { 0 }\n}")
2528            .expect("`on`/`case` are valid parameter names");
2529
2530        // `suite` too, as a field name.
2531        parse_str("commons demo {\n  type R = { suite: Int }\n}")
2532            .expect("`suite` is a valid field name");
2533    }
2534
2535    /// Drift guard: every alphabetic keyword the lexer declares must be
2536    /// classified by `is_reserved_keyword`, or be one of the *contextual*
2537    /// keywords `expect_ident` deliberately admits as identifiers
2538    /// (`on`/`suite`/`case`). Everything else in this codebase that can
2539    /// drift has a guard; this predicate had silently fallen 17 keywords
2540    /// behind, degrading the reserved-keyword diagnostic to the generic
2541    /// expected-token one.
2542    #[test]
2543    fn is_reserved_keyword_covers_every_lexer_keyword() {
2544        let lexer_src = include_str!("lexer.rs");
2545        let mut words = Vec::new();
2546        for line in lexer_src.lines() {
2547            let t = line.trim();
2548            if let Some(rest) = t.strip_prefix("#[token(\"")
2549                && let Some(word) = rest.split('"').next()
2550                && word.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
2551                && word.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
2552            {
2553                words.push(word.to_string());
2554            }
2555        }
2556        assert!(
2557            words.len() > 30,
2558            "keyword extraction looks broken: only {} words",
2559            words.len()
2560        );
2561        // Contextual keywords double as identifiers (see `expect_ident`); the
2562        // tier is single-sourced in `keywords::RESERVED_CONTEXTUAL`.
2563        use crate::keywords::RESERVED_CONTEXTUAL;
2564        let mut unclassified = Vec::new();
2565        for word in &words {
2566            let tokens = crate::lexer::tokenize(word).expect("keyword lexes");
2567            let kind = tokens.first().expect("keyword yields a token").kind;
2568            if !is_reserved_keyword(kind) && !RESERVED_CONTEXTUAL.contains(&word.as_str()) {
2569                unclassified.push(word.clone());
2570            }
2571        }
2572        assert!(
2573            unclassified.is_empty(),
2574            "keywords missing from is_reserved_keyword (add them, or document \
2575             them as contextual): {unclassified:?}"
2576        );
2577    }
2578
2579    /// Finding #27/#30: pins `is_item_start` to exactly the keyword set
2580    /// `declarations.rs`'s six item loops (`parse_commons_brace`/`_fragment`,
2581    /// `parse_context_brace`/`_fragment`, `parse_test_brace`/`_fragment`) plus
2582    /// `parse_adapter_body` dispatch on, as of this writing — the drift this
2583    /// finding fixed (`Property`, `Actor`, `Event`, `Binding`, and the
2584    /// `adapter` unit keyword itself were all missing from
2585    /// `recover_to_top_item`'s old hand-written sync list, even though every
2586    /// one of them is a real item/unit start). Adding a new item keyword to
2587    /// any of those loops should mean deliberately updating this list too,
2588    /// not silently leaving recovery unable to resync at it.
2589    #[test]
2590    fn is_item_start_matches_the_pinned_keyword_set() {
2591        use TokenKind::*;
2592        let expected_true = [
2593            Commons, Context, Adapter, Suite, Type, Fn, Messages, Event, Asset, Embed, Uses,
2594            Consumes, Exports, Capability, Provides, Service, Agent, Actor, Binding, Stub, Case,
2595            Property,
2596        ];
2597        for kind in expected_true {
2598            assert!(is_item_start(kind), "{kind:?} must be an item start");
2599        }
2600        let expected_false = [
2601            Ident, Plus, Minus, Colon, Dot, Eq, LBrace, RBrace, LParen, RParen, If, Else, Let,
2602            Where, True, False, Match, Is, On, Given,
2603        ];
2604        for kind in expected_false {
2605            assert!(!is_item_start(kind), "{kind:?} must not be an item start");
2606        }
2607    }
2608
2609    /// Fuzz-found (#516): a context-only keyword at item position in a
2610    /// commons errors without consuming the token, and the recovery sync
2611    /// stops at exactly that keyword — without a progress guard the item
2612    /// loop re-reported the same error until memory ran out.
2613    #[test]
2614    fn recovery_makes_progress_on_context_only_keyword_in_commons() {
2615        let src = "commons demo\n\ncapability Logger {\n  fn log(m: String) -> Effect[()]\n}\n";
2616        let tokens = crate::lexer::tokenize(src).unwrap();
2617        let (unit, errors) = parse_unit_with_recovery(&tokens, src);
2618        assert!(unit.is_some(), "the commons header still parses");
2619        assert!(
2620            errors
2621                .iter()
2622                .any(|e| e.category == "bynk.capability.outside_context"),
2623            "the misplaced capability is reported: {errors:?}"
2624        );
2625        // Termination is the real assertion (this used to OOM); a bounded,
2626        // non-repeating error list is the observable proxy.
2627        assert!(errors.len() < 10, "recovery repeated itself: {errors:?}");
2628    }
2629
2630    #[test]
2631    fn trailing_file_comment_becomes_unit_trailing() {
2632        // A comment after the last item but before EOF (fragment form)
2633        // becomes the commons body's trailing comments so the formatter
2634        // can preserve it.
2635        let src = "commons x\n\ntype T = Int where Positive\n// afterword\n";
2636        let c = parse_str(src).unwrap();
2637        assert_eq!(
2638            c.trailing_comments,
2639            vec![Comment::Line(" afterword".to_string())]
2640        );
2641    }
2642
2643    #[test]
2644    fn trailing_file_comment_after_a_brace_form_commons_is_not_dropped() {
2645        // Regression: the brace form's item loop exits on `RBrace`, never
2646        // reaching the fragment form's end-of-input case that drains the
2647        // trivia table's epilogue — so a comment after the closing `}` was
2648        // silently discarded (and, per `epilogue_is_empty`'s debug_assert,
2649        // would panic a debug build instead of round-tripping through
2650        // `bynk-fmt`).
2651        let src = "commons x {\n  type T = Int where Positive\n}\n// afterword\n";
2652        let c = parse_str(src).unwrap();
2653        assert_eq!(
2654            c.trailing_comments,
2655            vec![Comment::Line(" afterword".to_string())]
2656        );
2657    }
2658
2659    #[test]
2660    fn trailing_file_comment_after_a_brace_form_context_is_not_dropped() {
2661        // Same regression as the commons case, for `parse_context_brace`.
2662        let src = "context x {\n  type T = Int where Positive\n}\n// afterword\n";
2663        let SourceUnit::Context(c) = parse_unit_str(src).unwrap() else {
2664            panic!("expected context");
2665        };
2666        assert_eq!(
2667            c.trailing_comments,
2668            vec![Comment::Line(" afterword".to_string())]
2669        );
2670    }
2671
2672    #[test]
2673    fn trailing_file_comment_after_a_brace_form_suite_is_not_dropped() {
2674        // Same regression as the commons case, for `parse_test_brace`.
2675        let src = "suite x {\n  case \"c\" {\n    expect 1 == 1\n  }\n}\n// afterword\n";
2676        let SourceUnit::Suite(s) = parse_unit_str(src).unwrap() else {
2677            panic!("expected suite");
2678        };
2679        assert_eq!(
2680            s.trailing_comments,
2681            vec![Comment::Line(" afterword".to_string())]
2682        );
2683    }
2684
2685    /// Finding #30: unlike the three regressions just above,
2686    /// `parse_adapter_body`'s brace-closing path never called
2687    /// `take_epilogue` at all (not a regression from a shared pattern — it
2688    /// simply never had the call), so a comment after a brace-form adapter's
2689    /// closing `}` was silently dropped. The fragment form (no braces) was
2690    /// already correct.
2691    #[test]
2692    fn trailing_file_comment_after_a_brace_form_adapter_is_not_dropped() {
2693        let src = "adapter x {\n  binding \"./x.ts\"\n}\n// afterword\n";
2694        let SourceUnit::Adapter(a) = parse_unit_str(src).unwrap() else {
2695            panic!("expected adapter");
2696        };
2697        assert_eq!(
2698            a.trailing_comments,
2699            vec![Comment::Line(" afterword".to_string())]
2700        );
2701    }
2702
2703    /// #2321: `includes [ … ]` follows the module, before `requires`; it is a
2704    /// contextual word, so a malformed or unclosed list is an error, not a panic.
2705    #[test]
2706    fn binding_includes_list_parses_before_requires() {
2707        let src = "adapter x {\n  binding \"./x.ts\" includes [\"./a.wasm\", \"b/c.wasm\"] requires { \"p\": \"^1\" }\n}\n";
2708        let SourceUnit::Adapter(a) = parse_unit_str(src).unwrap() else {
2709            panic!("expected adapter");
2710        };
2711        let b = a.binding.unwrap();
2712        let paths: Vec<_> = b.includes.iter().map(|i| i.path.as_str()).collect();
2713        assert_eq!(paths, ["./a.wasm", "b/c.wasm"]);
2714        assert_eq!(b.requires.len(), 1);
2715        assert!(parse_unit_str("adapter x {\n  binding \"./x.ts\" includes [\"a.wasm\"").is_err());
2716        assert!(parse_unit_str("adapter x {\n  binding \"./x.ts\" includes [a]\n}\n").is_err());
2717    }
2718
2719    // -- Six-fold unification (review Part 3): the fragment-only ordering
2720    // restrictions declarations.rs's brace/fragment pairs preserve, now that
2721    // they share one function each behind `brace: bool`. None of these had
2722    // any prior test coverage at all. --
2723
2724    /// Fragment-form commons: `uses` must precede every `type`/`fn`.
2725    #[test]
2726    fn commons_fragment_rejects_uses_after_a_decl() {
2727        let src = "commons x\n\ntype T = Int where Positive\nuses bynk.list\n";
2728        let errs = parse_str(src).unwrap_err();
2729        assert!(
2730            errs.iter()
2731                .any(|e| e.category == "bynk.parse.uses_after_decls"),
2732            "{errs:?}"
2733        );
2734    }
2735
2736    /// The same ordering is NOT enforced in brace form — `uses` may appear
2737    /// anywhere in the body.
2738    #[test]
2739    fn commons_brace_allows_uses_after_a_decl() {
2740        let src = "commons x {\n  type T = Int where Positive\n  uses bynk.list\n}\n";
2741        parse_str(src).expect("brace form must not enforce fragment's uses-ordering rule");
2742    }
2743
2744    /// Fragment-form context: `consumes` must precede every `type`/`fn`/etc.
2745    #[test]
2746    fn context_fragment_rejects_consumes_after_a_decl() {
2747        let src = "context x\n\ntype T = Int where Positive\nconsumes bynk\n";
2748        let errs = parse_unit_str(src).unwrap_err();
2749        assert!(
2750            errs.iter()
2751                .any(|e| e.category == "bynk.parse.consumes_after_decls"),
2752            "{errs:?}"
2753        );
2754    }
2755
2756    /// Fragment-form context: `exports` must precede every `type`/`fn`/etc.
2757    #[test]
2758    fn context_fragment_rejects_exports_after_a_decl() {
2759        let src = "context x\n\ntype T = Int where Positive\nexports opaque { T }\n";
2760        let errs = parse_unit_str(src).unwrap_err();
2761        assert!(
2762            errs.iter()
2763                .any(|e| e.category == "bynk.parse.exports_after_decls"),
2764            "{errs:?}"
2765        );
2766    }
2767
2768    /// Brace-form context enforces none of the three orderings.
2769    #[test]
2770    fn context_brace_allows_consumes_and_exports_after_a_decl() {
2771        let src = "context x {\n  type T = Int where Positive\n  consumes bynk\n  exports opaque { T }\n}\n";
2772        parse_unit_str(src)
2773            .expect("brace form must not enforce fragment's consumes/exports-ordering rules");
2774    }
2775
2776    /// Fragment-form suite/test: `uses` must precede every `stub`/`case`/`property`.
2777    #[test]
2778    fn test_fragment_rejects_uses_after_a_decl() {
2779        let src = "suite m\n\ncase \"c\" {\n  expect true\n}\nuses bynk.list\n";
2780        let errs = parse_unit_str(src).unwrap_err();
2781        assert!(
2782            errs.iter()
2783                .any(|e| e.category == "bynk.parse.uses_after_decls"),
2784            "{errs:?}"
2785        );
2786    }
2787
2788    /// Brace-form suite/test allows `uses` anywhere.
2789    #[test]
2790    fn test_brace_allows_uses_after_a_decl() {
2791        let src = "suite m {\n  case \"c\" {\n    expect true\n  }\n  uses bynk.list\n}\n";
2792        parse_unit_str(src).expect("brace form must not enforce fragment's uses-ordering rule");
2793    }
2794
2795    // ---- #636: `if`/`match` condition vs record construction ----
2796
2797    /// Parse `body` as the tail expression of a fn and return its kind.
2798    fn body_tail(body: &str) -> ExprKind {
2799        let src = format!("commons x\n\nfn f() -> Int {{\n  {body}\n}}\n");
2800        let c = parse_str(&src).unwrap_or_else(|e| panic!("parse failed for {body:?}: {e:?}"));
2801        let CommonsItem::Fn(f) = &c.items[0] else {
2802            panic!("expected fn, got {:?}", c.items[0]);
2803        };
2804        f.body.tail.kind.clone()
2805    }
2806
2807    fn body_err(body: &str) -> Vec<CompileError> {
2808        let src = format!("commons x\n\nfn f() -> Int {{\n  {body}\n}}\n");
2809        parse_str(&src).expect_err(&format!("expected a parse error for {body:?}"))
2810    }
2811
2812    #[test]
2813    fn if_condition_ending_in_ident_does_not_swallow_a_single_ident_branch() {
2814        // #636: `ready { result }` shares its shape with a shorthand-field
2815        // record construction. In condition position the branch must win.
2816        for src in [
2817            "if ready { result } else { fallback }",
2818            "if ready { fallback } else { result }",
2819            "if !ready { result } else { fallback }",
2820            "if a == b { result } else { fallback }",
2821            "if a && b { result } else { fallback }",
2822        ] {
2823            let ExprKind::If {
2824                then_block,
2825                else_block,
2826                ..
2827            } = body_tail(src)
2828            else {
2829                panic!("expected If for {src:?}, got {:?}", body_tail(src));
2830            };
2831            // Both branches carry a bare-identifier tail — proof the `{ … }`
2832            // was read as a block, not consumed as a record by the condition.
2833            assert!(
2834                matches!(&then_block.tail.kind, ExprKind::Ident(_)),
2835                "then-branch tail not an ident for {src:?}: {:?}",
2836                then_block.tail.kind,
2837            );
2838            assert!(
2839                matches!(&else_block.tail.kind, ExprKind::Ident(_)),
2840                "else-branch tail not an ident for {src:?}: {:?}",
2841                else_block.tail.kind,
2842            );
2843        }
2844    }
2845
2846    #[test]
2847    fn else_less_if_with_single_ident_branch_parses() {
2848        // The no-`else` reproduction: previously errored `found `}``.
2849        let ExprKind::If { then_block, .. } = body_tail("if ready { result }") else {
2850            panic!("expected If");
2851        };
2852        assert!(matches!(&then_block.tail.kind, ExprKind::Ident(_)));
2853    }
2854
2855    #[test]
2856    fn record_construction_still_parses_in_value_position() {
2857        // The restriction is confined to condition spines — an ordinary value
2858        // position still constructs records, including the shorthand tail form.
2859        assert!(matches!(
2860            body_tail("Point { x }"),
2861            ExprKind::RecordConstruction { .. }
2862        ));
2863        assert!(matches!(
2864            body_tail("Point { x: 1, y: 2 }"),
2865            ExprKind::RecordConstruction { .. }
2866        ));
2867        assert!(matches!(
2868            body_tail("Empty {}"),
2869            ExprKind::RecordConstruction { .. }
2870        ));
2871    }
2872
2873    #[test]
2874    fn parenthesised_record_is_allowed_in_condition_head() {
2875        // A delimiter lifts the restriction: `(ready { result })` constructs a
2876        // record even in condition position (mirrors Rust's paren escape).
2877        let ExprKind::If { cond, .. } =
2878            body_tail("if (ready { result }) { branch } else { other }")
2879        else {
2880            panic!("expected If");
2881        };
2882        let ExprKind::Paren(inner) = &cond.kind else {
2883            panic!("expected a parenthesised condition, got {:?}", cond.kind);
2884        };
2885        assert!(
2886            matches!(&inner.kind, ExprKind::RecordConstruction { .. }),
2887            "parenthesised record in condition head should still construct: {:?}",
2888            inner.kind,
2889        );
2890    }
2891
2892    #[test]
2893    fn record_in_call_arg_within_condition_still_constructs() {
2894        // The restriction is lifted through a call-argument delimiter, so a
2895        // record literal passed to a predicate in the condition still parses.
2896        let ExprKind::If { cond, .. } = body_tail("if check(Point { x: 1 }) { a } else { b }")
2897        else {
2898            panic!("expected If");
2899        };
2900        let ExprKind::Call { args, .. } = &cond.kind else {
2901            panic!("expected Call in condition, got {:?}", cond.kind);
2902        };
2903        assert!(matches!(&args[0].kind, ExprKind::RecordConstruction { .. }));
2904    }
2905
2906    #[test]
2907    fn safe_condition_shapes_are_unaffected() {
2908        // Cases the issue lists as already-safe must stay safe.
2909        assert!(matches!(
2910            body_tail("if ready == true { result } else { fallback }"),
2911            ExprKind::If { .. }
2912        ));
2913        assert!(matches!(
2914            body_tail("if (ready) { result } else { fallback }"),
2915            ExprKind::If { .. }
2916        ));
2917        assert!(matches!(
2918            body_tail("if ready { \"a\" } else { \"b\" }"),
2919            ExprKind::If { .. }
2920        ));
2921    }
2922
2923    fn body_block(body: &str) -> Block {
2924        let src = format!("commons x\n\nfn f() -> Int {{\n  {body}\n}}\n");
2925        let c = parse_str(&src).unwrap_or_else(|e| panic!("parse failed for {body:?}: {e:?}"));
2926        let CommonsItem::Fn(f) = &c.items[0] else {
2927            panic!("expected fn, got {:?}", c.items[0]);
2928        };
2929        f.body.clone()
2930    }
2931
2932    fn has_error(errs: &[CompileError], code: &str) -> bool {
2933        errs.iter().any(|e| e.category == code)
2934    }
2935
2936    #[test]
2937    fn let_else_desugars_to_a_hidden_binding_and_a_match() {
2938        // #2010: `let P = e else E; rest` is `let __v = e` then
2939        // `match __v { P => { rest }, _ => E }`.
2940        let b = body_block("let x = 1\n  let Some(n) = find(x) else { 0 }\n  n + x");
2941        assert_eq!(
2942            b.statements.len(),
2943            2,
2944            "the plain let and the hidden binding"
2945        );
2946        let Statement::Let(hidden) = &b.statements[1] else {
2947            panic!("expected a pure hidden binding, got {:?}", b.statements[1]);
2948        };
2949        assert!(
2950            is_generated_binding(&hidden.name.name),
2951            "{}",
2952            hidden.name.name
2953        );
2954        assert_eq!(
2955            hidden.name.span.start, hidden.name.span.end,
2956            "the binder has no extent"
2957        );
2958        let le = hidden
2959            .let_else
2960            .as_ref()
2961            .expect("the binding records the sugar");
2962        assert_eq!(le.form, LetElseForm::Block);
2963        let ExprKind::Match { discriminant, arms } = &b.tail.kind else {
2964            panic!("expected a match tail, got {:?}", b.tail.kind);
2965        };
2966        assert!(matches!(&discriminant.kind, ExprKind::Ident(i) if i.name == hidden.name.name));
2967        assert_eq!(arms.len(), 2);
2968        assert!(matches!(arms[0].pattern, Pattern::Variant { .. }));
2969        let MatchBody::Block(rest) = &arms[0].body else {
2970            panic!("the pattern's arm runs the rest of the block");
2971        };
2972        assert!(rest.statements.is_empty());
2973        assert!(
2974            !matches!(rest.tail.kind, ExprKind::UnitLit),
2975            "{:?}",
2976            rest.tail.kind
2977        );
2978        assert!(matches!(arms[1].pattern, Pattern::Wildcard(_)));
2979    }
2980
2981    #[test]
2982    fn effectful_let_else_binds_with_an_arrow() {
2983        let b = body_block("let Ok(v) <- load() else { Effect.pure(0) }\n  v");
2984        assert!(matches!(&b.statements[0], Statement::EffectLet(l) if l.let_else.is_some()));
2985    }
2986
2987    #[test]
2988    fn let_else_chain_nests_each_step_in_the_previous_arm() {
2989        // Three steps in one flat block: each later step lives in the earlier
2990        // one's pattern arm, and the plain `let` between them stays in order.
2991        let b = body_block(
2992            "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",
2993        );
2994        let ExprKind::Match { arms, .. } = &b.tail.kind else {
2995            panic!("expected a match tail");
2996        };
2997        let MatchBody::Block(second) = &arms[0].body else {
2998            panic!("expected a block arm");
2999        };
3000        assert_eq!(
3001            second.statements.len(),
3002            2,
3003            "`let y` and the second hidden binding"
3004        );
3005        assert!(matches!(&second.statements[0], Statement::Let(l) if l.name.name == "y"));
3006        let ExprKind::Match { arms, .. } = &second.tail.kind else {
3007            panic!("expected the second step's match");
3008        };
3009        let MatchBody::Block(third) = &arms[0].body else {
3010            panic!("expected a block arm");
3011        };
3012        let ExprKind::Match { arms, .. } = &third.tail.kind else {
3013            panic!("expected the third step's match");
3014        };
3015        let MatchBody::Block(last) = &arms[0].body else {
3016            panic!("expected a block arm");
3017        };
3018        assert!(matches!(&last.tail.kind, ExprKind::Ident(i) if i.name == "c"));
3019    }
3020
3021    #[test]
3022    fn else_match_arms_follow_the_patterns_arm() {
3023        let b = body_block("let Ok(v) = r else match { Err(e) => 0 }\n  v");
3024        let Statement::Let(l) = &b.statements[0] else {
3025            panic!("expected the hidden binding");
3026        };
3027        assert_eq!(l.let_else.as_ref().unwrap().form, LetElseForm::Match);
3028        let ExprKind::Match { arms, .. } = &b.tail.kind else {
3029            panic!("expected a match tail");
3030        };
3031        assert_eq!(arms.len(), 2);
3032        assert!(
3033            matches!(&arms[1].pattern, Pattern::Variant { variant, .. } if variant.name == "Err")
3034        );
3035    }
3036
3037    #[test]
3038    fn let_else_as_the_last_statement_leaves_a_unit_rest() {
3039        let b = body_block("let Some(n) = find(1) else { () }");
3040        let ExprKind::Match { arms, .. } = &b.tail.kind else {
3041            panic!("expected a match tail");
3042        };
3043        let MatchBody::Block(rest) = &arms[0].body else {
3044            panic!("expected a block arm");
3045        };
3046        assert!(rest.implicit_tail && rest.statements.is_empty());
3047    }
3048
3049    #[test]
3050    fn let_pattern_forms_are_recognised() {
3051        for src in [
3052            "let None = o else { 0 }\n  1",
3053            "let Status.Done = s else { 0 }\n  1",
3054            "let Shipped(at) = s else { 0 }\n  at",
3055            "let 0 = n else { 1 }\n  0",
3056            "let -1 = n else { 1 }\n  0",
3057            "let \"a\" = s else { 1 }\n  0",
3058            "let (Ok(x) | Err(x)) = r else { 1 }\n  x",
3059        ] {
3060            let b = body_block(src);
3061            assert!(
3062                matches!(&b.statements[0], Statement::Let(l) if l.let_else.is_some()),
3063                "{src:?} should parse as a let-else"
3064            );
3065        }
3066        // A bare name, even capitalised, is still a binding.
3067        let b = body_block("let Total = 1\n  Total");
3068        assert!(
3069            matches!(&b.statements[0], Statement::Let(l) if l.let_else.is_none() && l.name.name == "Total")
3070        );
3071    }
3072
3073    #[test]
3074    fn let_pattern_without_else_is_refused() {
3075        assert!(has_error(
3076            &body_err("let Ok(v) = r\n  v"),
3077            "bynk.parse.let_pattern_without_else"
3078        ));
3079        assert!(has_error(
3080            &body_err("let Ok(v) <- r()\n  v"),
3081            "bynk.parse.let_pattern_without_else"
3082        ));
3083    }
3084
3085    #[test]
3086    fn else_on_a_plain_binding_is_refused() {
3087        assert!(has_error(
3088            &body_err("let v = r else { 0 }\n  v"),
3089            "bynk.parse.let_else_on_binding"
3090        ));
3091        assert!(has_error(
3092            &body_err("let v <- r() else { 0 }\n  v"),
3093            "bynk.parse.let_else_on_binding"
3094        ));
3095    }
3096
3097    #[test]
3098    fn annotated_let_pattern_is_refused() {
3099        assert!(has_error(
3100            &body_err("let Ok(v): Int = r else { 0 }\n  v"),
3101            "bynk.parse.let_pattern_annotation"
3102        ));
3103    }
3104
3105    #[test]
3106    fn empty_else_match_is_refused() {
3107        assert!(has_error(
3108            &body_err("let Ok(v) = r else match { }\n  v"),
3109            "bynk.parse.empty_match"
3110        ));
3111    }
3112
3113    #[test]
3114    fn let_else_steps_count_against_the_nesting_budget() {
3115        // Each step nests the rest of the block one level deeper, exactly as
3116        // the hand-written `match` it stands for, so a very long chain is
3117        // refused with the nesting diagnostic rather than overflowing later.
3118        let chain = |n: usize| -> String {
3119            let steps: String = (0..n)
3120                .map(|i| format!("let Ok(v{i}) = step() else {{ {i} }}\n  "))
3121                .collect();
3122            format!("{steps}0")
3123        };
3124        body_block(&chain(40));
3125        let errs = body_err(&chain(400));
3126        assert!(
3127            errs.iter()
3128                .any(|e| e.category == "bynk.parse.nesting_too_deep"),
3129            "{errs:?}"
3130        );
3131    }
3132
3133    #[test]
3134    fn empty_match_reports_its_own_diagnostic() {
3135        // #636: `match result {}` once parsed `result {}` as an empty record,
3136        // masking `bynk.parse.empty_match`. The intended diagnostic is now
3137        // reachable.
3138        let errs = body_err("match result {}");
3139        assert!(
3140            errs.iter().any(|e| e.category == "bynk.parse.empty_match"),
3141            "expected empty_match; got {errs:?}",
3142        );
3143    }
3144
3145    #[test]
3146    fn match_discriminant_ending_in_ident_parses() {
3147        // A `match` over a bare-identifier discriminant reaches its arm list.
3148        assert!(matches!(
3149            body_tail("match ready { x => x }"),
3150            ExprKind::Match { .. }
3151        ));
3152    }
3153
3154    /// #981: an identifier statement immediately followed, on its own line, by
3155    /// a standalone `()` must stay two separate constructs — not merge into a
3156    /// zero-arg call. `status := Paid` / `()` is an Assign statement whose
3157    /// value is the bare identifier `Paid`, then a unit tail; it must never
3158    /// parse as a single `status := Paid()` (a call). The call-parens rule
3159    /// mirrors the v0.20b same-line `[` rule already applied to type
3160    /// arguments: a postfix opener that begins a new line does not continue
3161    /// the previous token.
3162    #[test]
3163    fn identifier_statement_followed_by_unit_tail_does_not_merge_into_a_call() {
3164        let src = "commons c\n\nfn f() -> Int {\n  status := Paid\n  ()\n}\n";
3165        let c = parse_str(src).unwrap_or_else(|e| panic!("parse failed: {e:?}"));
3166        let CommonsItem::Fn(f) = &c.items[0] else {
3167            panic!("expected fn, got {:?}", c.items[0]);
3168        };
3169        assert_eq!(
3170            f.body.statements.len(),
3171            1,
3172            "expected exactly one Assign statement, got {:?}",
3173            f.body.statements
3174        );
3175        let Statement::Assign(a) = &f.body.statements[0] else {
3176            panic!(
3177                "expected an Assign statement, got {:?}",
3178                f.body.statements[0]
3179            );
3180        };
3181        assert!(
3182            matches!(a.value.kind, ExprKind::Ident(_)),
3183            "assign value must stay the bare identifier `Paid`, got {:?}",
3184            a.value.kind
3185        );
3186        assert!(
3187            matches!(f.body.tail.kind, ExprKind::UnitLit),
3188            "the `()` must remain the block's own tail, got {:?}",
3189            f.body.tail.kind
3190        );
3191    }
3192
3193    /// #1858: a variant pattern's `(` must sit on the variant's line too. An
3194    /// `is` test ending in a nullary variant, above a `()` tail on its own
3195    /// line, is a pattern and a unit, not `Paid()`/`None()`: `fmt` writes that
3196    /// tail after a comment, and its reparse must not absorb it.
3197    #[test]
3198    fn variant_pattern_followed_by_unit_tail_does_not_take_it_as_bindings() {
3199        for (name, pat) in [("user variant", "Paid"), ("built-in variant", "None")] {
3200            let src = format!("commons c\n\nfn f() -> () {{\n  let b = r is {pat}\n  ()\n}}\n");
3201            let c = parse_str(&src).unwrap_or_else(|e| panic!("{name}: parse failed: {e:?}"));
3202            let CommonsItem::Fn(f) = &c.items[0] else {
3203                panic!("expected fn, got {:?}", c.items[0]);
3204            };
3205            let Statement::Let(l) = &f.body.statements[0] else {
3206                panic!("{name}: expected a Let, got {:?}", f.body.statements[0]);
3207            };
3208            let ExprKind::Is { pattern, .. } = &l.value.kind else {
3209                panic!("{name}: expected an `is` test, got {:?}", l.value.kind);
3210            };
3211            let Pattern::Variant { variant, span, .. } = pattern.as_ref() else {
3212                panic!("{name}: expected a variant pattern, got {pattern:?}");
3213            };
3214            assert_eq!(
3215                span.end, variant.span.end,
3216                "{name}: the pattern must end at `{pat}`, not take the `()` below it"
3217            );
3218            assert!(
3219                matches!(f.body.tail.kind, ExprKind::UnitLit),
3220                "{name}: the `()` must remain the block's own tail, got {:?}",
3221                f.body.tail.kind
3222            );
3223        }
3224    }
3225
3226    /// #1858's boundary: a `(` on the variant's line still opens its payload
3227    /// list, and the list itself may span lines.
3228    #[test]
3229    fn variant_pattern_payload_on_the_same_line_still_binds() {
3230        for (name, pat) in [
3231            ("user variant", "Paid(\n    x\n  )"),
3232            ("built-in variant", "Some(x)"),
3233        ] {
3234            let src = format!("commons c\n\nfn f() -> Bool {{\n  r is {pat}\n}}\n");
3235            let c = parse_str(&src).unwrap_or_else(|e| panic!("{name}: parse failed: {e:?}"));
3236            let CommonsItem::Fn(f) = &c.items[0] else {
3237                panic!("expected fn, got {:?}", c.items[0]);
3238            };
3239            let ExprKind::Is { pattern, .. } = &f.body.tail.kind else {
3240                panic!("{name}: expected an `is` tail, got {:?}", f.body.tail.kind);
3241            };
3242            assert!(
3243                matches!(pattern.as_ref(), Pattern::Variant { bindings, .. } if bindings.len() == 1),
3244                "{name}: expected one binding, got {pattern:?}"
3245            );
3246        }
3247    }
3248
3249    /// #1858, chosen uniformly: a match arm's pattern is followed only by `if`
3250    /// or `=>`, so a next-line `(` there could not be the next statement, but
3251    /// the same-line rule holds in every pattern position anyway. One lexical
3252    /// rule, wherever a pattern appears, is the one a reader can apply without
3253    /// knowing the position. The arm is rejected, not misparsed.
3254    #[test]
3255    fn match_arm_pattern_payload_on_a_later_line_is_rejected() {
3256        let src = "commons c\n\nfn f() -> Int {\n  match m {\n    Settled\n      (amount) => 1,\n    _ => 0,\n  }\n}\n";
3257        assert!(
3258            parse_str(src).is_err(),
3259            "a next-line `(` after an arm's variant must not open its payload list"
3260        );
3261        let same_line = src.replace("Settled\n      (amount)", "Settled(amount)");
3262        parse_str(&same_line).unwrap_or_else(|e| panic!("same-line form must parse: {e:?}"));
3263    }
3264
3265    /// #981: the same same-line rule extends to a method call's parens — a
3266    /// `.method` immediately followed, on its own line, by a standalone `()`
3267    /// must not merge into `.method()`.
3268    #[test]
3269    fn method_reference_followed_by_unit_tail_does_not_merge_into_a_call() {
3270        let src = "commons c\n\nfn f() -> Int {\n  let y = x.field\n  ()\n}\n";
3271        let c = parse_str(src).unwrap_or_else(|e| panic!("parse failed: {e:?}"));
3272        let CommonsItem::Fn(f) = &c.items[0] else {
3273            panic!("expected fn, got {:?}", c.items[0]);
3274        };
3275        let Statement::Let(l) = &f.body.statements[0] else {
3276            panic!("expected a Let statement, got {:?}", f.body.statements[0]);
3277        };
3278        assert!(
3279            matches!(l.value.kind, ExprKind::FieldAccess { .. }),
3280            "let value must stay a field access, got {:?}",
3281            l.value.kind
3282        );
3283        assert!(
3284            matches!(f.body.tail.kind, ExprKind::UnitLit),
3285            "the `()` must remain the block's own tail, got {:?}",
3286            f.body.tail.kind
3287        );
3288    }
3289
3290    #[test]
3291    fn unparenthesised_record_in_condition_head_now_errors() {
3292        // #636 narrowing (matches Rust): a record literal in condition *head*
3293        // position must be parenthesised. Unparenthesised, `Point` reads as the
3294        // discriminant and `{ x: 1 }` as the arm list, whose first "arm" `x: 1`
3295        // is not an arm — so the parse fails. Pinned so the divergence from the
3296        // (still-accepting) tree-sitter grammar is deliberate, not a bug.
3297        assert!(
3298            !body_err("match Point { x: 1 } { p => p }").is_empty(),
3299            "unparenthesised record discriminant should not parse",
3300        );
3301        // Parenthesised, the record is the discriminant and the match parses.
3302        let ExprKind::Match { discriminant, .. } = body_tail("match (Point { x: 1 }) { p => p }")
3303        else {
3304            panic!("expected Match for the parenthesised form");
3305        };
3306        let ExprKind::Paren(inner) = &discriminant.kind else {
3307            panic!(
3308                "expected a parenthesised discriminant, got {:?}",
3309                discriminant.kind
3310            );
3311        };
3312        assert!(matches!(&inner.kind, ExprKind::RecordConstruction { .. }));
3313    }
3314
3315    /// The codes a strict multi-unit parse (the build's path) reports.
3316    fn strict_codes(src: &str) -> Vec<&'static str> {
3317        let toks = tokenize(src).unwrap();
3318        match parse_units_with_warnings(&toks, src) {
3319            Ok((_, warnings)) => warnings.iter().map(|e| e.category).collect(),
3320            Err(errs) => errs.iter().map(|e| e.category).collect(),
3321        }
3322    }
3323
3324    /// #1885 (DECISION B): a divider pair around a declaration, attached to
3325    /// the next one, swallowed `helper` silently. Every parse entry point now
3326    /// reports it.
3327    #[test]
3328    fn a_divider_pair_around_code_is_doc_block_contains_code() {
3329        let src =
3330            "commons m\n\n--------\nfn helper() -> Int { 1 }\n--------\nfn f() -> Int { 2 }\n";
3331        assert_eq!(strict_codes(src), ["bynk.parse.doc_block_contains_code"]);
3332        let errs = parse_str(src).unwrap_err();
3333        assert_eq!(errs[0].category, "bynk.parse.doc_block_contains_code");
3334        // The span is the whole doc block.
3335        assert_eq!(
3336            &src[errs[0].span.range()],
3337            "--------\nfn helper() -> Int { 1 }\n--------\n"
3338        );
3339        let (unit, errs) = parse_recover_str(src);
3340        assert!(unit.is_some(), "recovery keeps the unit");
3341        assert!(
3342            errs.iter()
3343                .any(|e| e.category == "bynk.parse.doc_block_contains_code"),
3344            "{errs:?}"
3345        );
3346    }
3347
3348    /// The orphan shape (a blank line before `fn f`) is the same error, beside
3349    /// the orphan warning; and so is a pair with nothing after it.
3350    #[test]
3351    fn an_orphaned_or_trailing_divider_pair_is_doc_block_contains_code() {
3352        let orphan = "commons m\n\n---\nfn helper() -> Int { 1 }\n---\n\nfn f() -> Int { 2 }\n";
3353        let codes = strict_codes(orphan);
3354        assert!(
3355            codes.contains(&"bynk.parse.doc_block_contains_code"),
3356            "{codes:?}"
3357        );
3358        let trailing = "commons m\n\nfn f() -> Int { 2 }\n\n---\ntype Id = Int\n---\n";
3359        let codes = strict_codes(trailing);
3360        assert!(
3361            codes.contains(&"bynk.parse.doc_block_contains_code"),
3362            "{codes:?}"
3363        );
3364    }
3365
3366    /// Prose that only starts like a declaration, a doc whose code sits in a
3367    /// Markdown fence, and a doc that holds only a nested doc example are all
3368    /// documentation.
3369    #[test]
3370    fn near_miss_prose_and_fenced_code_are_not_code() {
3371        for doc in [
3372            "type of the thing",
3373            "fn is used here",
3374            "The type Id is an Int.",
3375            "uses the clock",
3376            "Event handler.\nfn helper() -> Int { 1 }",
3377            "Example:\n```bynk\nfn helper() -> Int { 1 }\n```",
3378            "~~~~\ntype Id = Int\n~~~~",
3379            "---\nA nested doc.\n---",
3380            "",
3381        ] {
3382            assert!(!doc_content_is_code(doc), "{doc:?} read as code");
3383        }
3384        for doc in [
3385            "fn helper() -> Int { 1 }",
3386            "type Id = Int\nfn f() -> Int { 1 }",
3387            "```\nan example\n```\nfn helper() -> Int { 1 }",
3388            "---\nA nested doc.\n---\nfn helper() -> Int { 1 }",
3389        ] {
3390            assert!(doc_content_is_code(doc), "{doc:?} not read as code");
3391        }
3392    }
3393
3394    /// #1894 review: code one level down, inside a nested doc block, or
3395    /// beside a lone `---` rule that keeps the content from lexing, is still
3396    /// code; prose at either level is not.
3397    #[test]
3398    fn code_inside_a_nested_doc_or_beside_a_rule_is_code() {
3399        let nested =
3400            "commons m\n\n----\n---\nfn helper() -> Int { 1 }\n---\n----\nfn f() -> Int { 2 }\n";
3401        assert_eq!(strict_codes(nested), ["bynk.parse.doc_block_contains_code"]);
3402        let ruled = "commons m\n\n----\n---\nfn helper() -> Int { 1 }\n----\nfn f() -> Int { 2 }\n";
3403        assert_eq!(strict_codes(ruled), ["bynk.parse.doc_block_contains_code"]);
3404        // Two levels down.
3405        assert!(doc_content_is_code(
3406            "----\n---\nfn helper() -> Int { 1 }\n---\n----"
3407        ));
3408        for doc in [
3409            "---\nAn inner doc.\n---",
3410            "Intro.\n\n---\n\nfn is used here",
3411            "---\n```\nfn helper() -> Int { 1 }\n```\n---",
3412        ] {
3413            assert!(!doc_content_is_code(doc), "{doc:?} read as code");
3414        }
3415        // Deeper than the bound, the probe stops looking.
3416        let mut deep = "fn helper() -> Int { 1 }".to_string();
3417        for n in (3..3 + DOC_PROBE_MAX_DEPTH + 1).rev() {
3418            let fence = "-".repeat(n);
3419            deep = format!("{fence}\n{deep}\n{fence}");
3420        }
3421        assert!(!doc_content_is_code(&deep));
3422    }
3423
3424    /// A `----` doc holding a nested doc example in a code fence attaches to
3425    /// its declaration with no diagnostic.
3426    #[test]
3427    fn a_fenced_nested_doc_example_parses_clean() {
3428        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";
3429        assert!(strict_codes(src).is_empty(), "{:?}", strict_codes(src));
3430        let c = parse_str(src).unwrap();
3431        let CommonsItem::Fn(f) = &c.items[0] else {
3432            panic!("expected fn")
3433        };
3434        assert!(
3435            f.documentation
3436                .as_deref()
3437                .is_some_and(|d| d.contains("---\nInner.\n---")),
3438            "{:?}",
3439            f.documentation
3440        );
3441    }
3442
3443    /// The `TypeDecl` at `items[i]`.
3444    fn type_at(c: &Commons, i: usize) -> &TypeDecl {
3445        let CommonsItem::Type(t) = &c.items[i] else {
3446            panic!("expected a type at {i}")
3447        };
3448        t
3449    }
3450
3451    fn doc_of(d: &Option<Doc>) -> Option<(&str, DocForm)> {
3452        // An indented `---` block keeps a trailing newline (its closing
3453        // marker's indent survives the marker trim); it prints the same.
3454        d.as_ref().map(|d| (d.as_str().trim_end(), d.form))
3455    }
3456
3457    /// #2203 (DECISION C): a `--- text` line documents a declaration, with or
3458    /// without its closer.
3459    #[test]
3460    fn doc_lines_document_a_declaration() {
3461        let src = "commons m\n\n--- A commit SHA.\ntype Commit = String\n\n---- First. ----\nfn f() -> Int { 1 }\n";
3462        let c = parse_str(src).unwrap();
3463        assert_eq!(
3464            doc_of(&type_at(&c, 0).documentation),
3465            Some(("A commit SHA.", DocForm::Lines))
3466        );
3467        let CommonsItem::Fn(f) = &c.items[1] else {
3468            panic!("expected fn")
3469        };
3470        assert_eq!(doc_of(&f.documentation), Some(("First.", DocForm::Lines)));
3471        assert!(strict_codes(src).is_empty(), "{:?}", strict_codes(src));
3472    }
3473
3474    /// #2203 (DECISION E): a legacy `--` comment and `--|` doc line still
3475    /// parse as a comment and a doc, and each is reported, all at once, with
3476    /// its fix; the formatter's migrating parse accepts them.
3477    #[test]
3478    fn legacy_spellings_parse_and_are_each_reported() {
3479        let src =
3480            "-- a header\ncommons m\n\n--| A run.\ntype Run = {\n  repo: String, -- trailing\n}\n";
3481        assert_eq!(
3482            strict_codes(src),
3483            [
3484                "bynk.lex.legacy_comment",
3485                "bynk.lex.legacy_doc_line",
3486                "bynk.lex.legacy_comment"
3487            ]
3488        );
3489        let tokens = tokenize(src).unwrap();
3490        let (units, _, _) = parse_units_migrating(&tokens, src).unwrap();
3491        let SourceUnit::Commons(c) = &units[0] else {
3492            panic!("commons")
3493        };
3494        assert_eq!(
3495            doc_of(&type_at(c, 0).documentation),
3496            Some(("A run.", DocForm::Lines))
3497        );
3498    }
3499
3500    /// #2203 (DECISION D): two one-line docs on one target is
3501    /// `bynk.parse.multiple_docs`, whose fix joins them into a block.
3502    #[test]
3503    fn consecutive_doc_lines_are_multiple_docs_with_a_block_fix() {
3504        let src = "commons m\n\ntype R = {\n  --- First.\n  --- Second.\n  a: Int,\n}\n";
3505        let tokens = tokenize(src).unwrap();
3506        let errs = parse_units(&tokens, src).unwrap_err();
3507        assert_eq!(errs.len(), 1, "{errs:?}");
3508        assert_eq!(errs[0].category, "bynk.parse.multiple_docs");
3509        let (span, block) = &errs[0].suggestions[0].edits[0];
3510        assert_eq!(&src[span.range()], "--- First.\n  --- Second.");
3511        assert_eq!(block, "---\n  First.\n  Second.\n  ---");
3512    }
3513
3514    /// #1888 (DECISION D): record fields, both sum forms' variants, payload
3515    /// fields and message entries take a doc in either form; a `//` comment
3516    /// above a field stays a comment.
3517    #[test]
3518    fn members_take_docs_in_either_form() {
3519        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";
3520        assert!(strict_codes(src).is_empty(), "{:?}", strict_codes(src));
3521        let c = parse_str(src).unwrap();
3522        let TypeBody::Record(r) = &type_at(&c, 0).body else {
3523            panic!("record")
3524        };
3525        assert_eq!(
3526            doc_of(&r.fields[0].documentation),
3527            Some(("The repository.", DocForm::Lines))
3528        );
3529        assert_eq!(
3530            doc_of(&r.fields[1].documentation),
3531            Some(("The version.", DocForm::Block))
3532        );
3533        assert_eq!(r.fields[2].documentation, None);
3534        assert_eq!(
3535            r.fields[2].trivia.leading,
3536            vec![Comment::Line(" a note".into())]
3537        );
3538        assert_eq!(r.fields[2].trivia.trailing.as_deref(), Some(" trailing"));
3539        let TypeBody::Sum(e) = &type_at(&c, 1).body else {
3540            panic!("enum")
3541        };
3542        assert_eq!(
3543            doc_of(&e.variants[0].documentation),
3544            Some(("Passed.", DocForm::Lines))
3545        );
3546        assert_eq!(e.variants[1].documentation, None);
3547        let TypeBody::Sum(p) = &type_at(&c, 2).body else {
3548            panic!("sum")
3549        };
3550        assert_eq!(
3551            doc_of(&p.variants[0].documentation),
3552            Some(("Round.", DocForm::Lines))
3553        );
3554        assert_eq!(
3555            doc_of(&p.variants[0].payload[0].documentation),
3556            Some(("The radius.", DocForm::Lines))
3557        );
3558        assert_eq!(p.variants[1].documentation, None);
3559        // The doc after the last variant is the next declaration's.
3560        let CommonsItem::Fn(f) = &c.items[3] else {
3561            panic!("expected fn")
3562        };
3563        assert_eq!(
3564            doc_of(&f.documentation),
3565            Some(("After the sum.", DocForm::Lines))
3566        );
3567        let CommonsItem::Messages(m) = &c.items[4] else {
3568            panic!("expected messages")
3569        };
3570        assert_eq!(
3571            doc_of(&m.entries[0].documentation),
3572            Some(("Greets.", DocForm::Lines))
3573        );
3574    }
3575
3576    /// #2203 (DECISION D): two docs directly above one target, of either
3577    /// form or both, is `bynk.parse.multiple_docs`, on a declaration and on a
3578    /// member; the recovering parse keeps the unit.
3579    #[test]
3580    fn two_docs_on_one_target_is_multiple_docs() {
3581        for src in [
3582            "commons m\n\n---\nBlock.\n---\n--- Lines.\nfn f() -> Int { 1 }\n",
3583            "commons m\n\n--- Lines.\n---\nBlock.\n---\nfn f() -> Int { 1 }\n",
3584            "commons m\n\n---\nOne.\n---\n---\nTwo.\n---\nfn f() -> Int { 1 }\n",
3585            "commons m\n\n--- One.\n--- Two.\nfn f() -> Int { 1 }\n",
3586            "commons m\n\ntype R = {\n  --- Lines.\n  ---\n  Block.\n  ---\n  a: Int,\n}\n",
3587        ] {
3588            assert_eq!(strict_codes(src), ["bynk.parse.multiple_docs"], "{src}");
3589            let (unit, errs) = parse_recover_str(src);
3590            assert!(unit.is_some());
3591            assert!(
3592                errs.iter()
3593                    .any(|e| e.category == "bynk.parse.multiple_docs")
3594            );
3595        }
3596    }
3597
3598    /// #1888 review: a doc with only a payload's `)` after it is an orphan
3599    /// warning, and the variant still parses with its field.
3600    #[test]
3601    fn a_dangling_doc_before_a_payload_close_is_only_an_orphan() {
3602        let src =
3603            "commons m\n\ntype S =\n  | Circle(\n    r: Int,\n    --- dangling\n  )\n  | Dot\n";
3604        assert_eq!(strict_codes(src), ["bynk.parse.orphan_doc_block"]);
3605        let c = parse_str(src).unwrap();
3606        let TypeBody::Sum(s) = &type_at(&c, 0).body else {
3607            panic!("sum")
3608        };
3609        assert_eq!(s.variants[0].payload.len(), 1);
3610        assert_eq!(s.variants[0].payload[0].documentation, None);
3611    }
3612
3613    /// #1888: a member doc follows the declaration rules: a blank line
3614    /// orphans it (a warning, kept in place), as does the body's end.
3615    #[test]
3616    fn an_unattached_member_doc_is_an_orphan() {
3617        let src = "commons m\n\ntype R = {\n  --- Spaced.\n\n  a: Int,\n  --- Dangling.\n}\n";
3618        assert_eq!(
3619            strict_codes(src),
3620            ["bynk.parse.orphan_doc_block", "bynk.parse.orphan_doc_block"]
3621        );
3622        let c = parse_str(src).unwrap();
3623        let TypeBody::Record(r) = &type_at(&c, 0).body else {
3624            panic!("record")
3625        };
3626        assert_eq!(r.fields[0].documentation, None);
3627        assert!(matches!(
3628            &r.fields[0].trivia.leading[..],
3629            [Comment::OrphanDoc(d)] if d.as_str() == "Spaced." && d.form == DocForm::Lines
3630        ));
3631        assert!(matches!(
3632            &r.trailing_comments[..],
3633            [Comment::OrphanDoc(d)] if d.as_str() == "Dangling."
3634        ));
3635    }
3636}