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