Skip to main content

bynk_syntax/
parser.rs

1//! Hand-written recursive-descent parser for Bynk v0.
2//!
3//! Token grammar in spec §4. The expression parser uses one function per
4//! precedence level (§4.4). Errors carry spans and short fix-oriented
5//! messages; the parser does not currently attempt synchronisation, which
6//! means at most one parse error is reported per compilation.
7
8use crate::ast::*;
9use crate::error::CompileError;
10use crate::lexer::{Token, TokenKind, comment_body, doc_block_content, has_blank_line_between};
11use crate::span::Span;
12mod declarations;
13mod expressions;
14mod statements;
15mod types;
16
17/// Side-channel store for line-comment trivia (v1.1 LSP spec §3.5).
18///
19/// Built once up-front by [`split_trivia`] from the raw lexer token stream.
20/// Comments are removed from the token stream the parser walks; their text
21/// is filed into `leading` (comments on lines preceding a content token)
22/// and `trailing` (a single comment on the same line as a content token).
23/// The parser consumes entries through [`TriviaTable::take_leading`] and
24/// [`TriviaTable::take_trailing`] as it recognises declarations.
25#[derive(Debug, Default)]
26struct TriviaTable {
27    /// `leading[i]` holds the comment-body texts that appear immediately
28    /// before content token `i` (zero or more `--` lines, in source order,
29    /// not separated from the token by another content token).
30    leading: Vec<Vec<String>>,
31    /// `trailing[i]` holds an optional comment on the same source line as
32    /// content token `i`. Only one trailing comment is recorded per token
33    /// because a single `--` consumes the rest of the line.
34    trailing: Vec<Option<String>>,
35    /// Any pending leading comments at end-of-file (no content token
36    /// followed). Used to preserve file-trailing comments.
37    epilogue: Vec<String>,
38}
39
40impl TriviaTable {
41    fn take_leading(&mut self, index: usize) -> Vec<String> {
42        match self.leading.get_mut(index) {
43            Some(v) => std::mem::take(v),
44            None => Vec::new(),
45        }
46    }
47
48    fn take_trailing(&mut self, index: usize) -> Option<String> {
49        self.trailing.get_mut(index).and_then(|s| s.take())
50    }
51
52    fn take_epilogue(&mut self) -> Vec<String> {
53        std::mem::take(&mut self.epilogue)
54    }
55}
56
57/// Remove `Comment` trivia tokens from `tokens` and bin them into a
58/// [`TriviaTable`] keyed against the surviving content tokens. A comment
59/// on the same source line as the preceding content token is recorded as
60/// that token's *trailing* trivia; everything else is *leading* for the
61/// next content token.
62fn split_trivia(tokens: &[Token], source: &str) -> (Vec<Token>, TriviaTable) {
63    let mut filtered: Vec<Token> = Vec::with_capacity(tokens.len());
64    let mut table = TriviaTable::default();
65    let mut pending_leading: Vec<String> = Vec::new();
66    let mut last_content_end: Option<usize> = None;
67    for tok in tokens {
68        if tok.kind == TokenKind::Comment {
69            let body = comment_body(source, tok.span).to_string();
70            // If nothing has been buffered as leading for the next token and
71            // there is no newline between the previous content token and
72            // this comment, it trails that token.
73            if pending_leading.is_empty()
74                && let Some(prev_end) = last_content_end
75                && !source[prev_end..tok.span.start].contains('\n')
76            {
77                let last_idx = filtered.len() - 1;
78                // Only attach if no trailing already recorded (shouldn't
79                // happen because `--` consumes through end-of-line).
80                if table.trailing[last_idx].is_none() {
81                    table.trailing[last_idx] = Some(body);
82                    continue;
83                }
84            }
85            pending_leading.push(body);
86            continue;
87        }
88        filtered.push(*tok);
89        table.leading.push(std::mem::take(&mut pending_leading));
90        table.trailing.push(None);
91        last_content_end = Some(tok.span.end);
92    }
93    table.epilogue = pending_leading;
94    (filtered, table)
95}
96
97/// Parse a token slice into a [`Commons`] AST.
98///
99/// Accepts either form of v0.3 commons file:
100/// - Brace form: `commons name { items... }` (v0–v0.2 compatible).
101/// - Fragment form: `commons name uses... items...` to EOF (v0.3).
102pub fn parse(tokens: &[Token], source: &str) -> Result<Commons, Vec<CompileError>> {
103    parse_with_warnings(tokens, source).map(|(c, _warnings)| c)
104}
105
106/// [`parse`] with the non-fatal diagnostics threaded out alongside the AST
107/// (ADR 0117) — see [`parse_units_with_warnings`].
108pub fn parse_with_warnings(
109    tokens: &[Token],
110    source: &str,
111) -> Result<(Commons, Vec<CompileError>), Vec<CompileError>> {
112    let (unit, warnings) = parse_unit_with_warnings(tokens, source)?;
113    match unit {
114        SourceUnit::Commons(c) => Ok((c, warnings)),
115        SourceUnit::Context(ctx) => Err(vec![
116            CompileError::new(
117                "bynk.parse.unexpected_context",
118                ctx.span,
119                "expected a `commons` declaration but found a `context` declaration",
120            )
121            .with_note(
122                "contexts must be compiled as part of a project — pass the source directory, e.g. `bynkc compile --target bundle --output out src`",
123            ),
124        ]),
125        SourceUnit::Suite(t) => Err(vec![
126            CompileError::new(
127                "bynk.parse.unexpected_suite",
128                t.span,
129                "expected a `commons` declaration but found a `suite` declaration",
130            )
131            .with_note(
132                "tests must be compiled as part of a project — pass the source directory, e.g. `bynkc compile --target bundle --output out src`",
133            ),
134        ]),
135        SourceUnit::Adapter(a) => Err(vec![
136            CompileError::new(
137                "bynk.parse.unexpected_adapter",
138                a.span,
139                "expected a `commons` declaration but found an `adapter` declaration",
140            )
141            .with_note(
142                "adapters must be compiled as part of a project — pass the source directory, e.g. `bynkc compile --target bundle --output out src`",
143            ),
144        ]),
145    }
146}
147
148/// Parse a token slice into a [`SourceUnit`] with error recovery, returning a
149/// best-effort partial AST plus the full list of parse errors and warnings.
150///
151/// Used by the LSP: item-level recovery skips past a malformed declaration to
152/// the next top-level item, so multiple errors are reported per compilation
153/// rather than just the first. Compared to [`parse_unit`], this never bails;
154/// if no SourceUnit could be parsed at all (e.g. the file is empty or the
155/// header itself fails) the returned `Option` is `None`.
156pub fn parse_unit_with_recovery(
157    tokens: &[Token],
158    source: &str,
159) -> (Option<SourceUnit>, Vec<CompileError>) {
160    let (filtered, trivia) = split_trivia(tokens, source);
161    let mut warnings = Vec::new();
162    let mut p = Parser::new(&filtered, source, trivia, &mut warnings);
163    p.recover_mode = true;
164    let unit_opt = match p.parse_unit() {
165        Ok(u) => {
166            // v0.113: a file may hold more than one top-level unit (an atomic
167            // `commons` + `suite` file, DECISION S). Consume any further units
168            // so trailing declarations are not mis-reported as stray tokens; the
169            // editor view is keyed on the first (primary) unit. A genuinely
170            // malformed trailing declaration is still surfaced via recovery.
171            while p.peek().is_some() {
172                match p.parse_unit() {
173                    Ok(_) => {}
174                    Err(e) => {
175                        p.recovered_errors.push(e);
176                        break;
177                    }
178                }
179            }
180            Some(u)
181        }
182        Err(e) => {
183            p.recovered_errors.push(e);
184            None
185        }
186    };
187    let mut all_errors = p.recovered_errors;
188    all_errors.append(&mut warnings);
189    (unit_opt, all_errors)
190}
191
192/// Parse a token slice into a [`SourceUnit`] — either a commons or a context.
193///
194/// Each `.bynk` file is exactly one declaration of one kind.
195pub fn parse_unit(tokens: &[Token], source: &str) -> Result<SourceUnit, Vec<CompileError>> {
196    parse_unit_with_warnings(tokens, source).map(|(unit, _warnings)| unit)
197}
198
199/// [`parse_unit`] with the non-fatal diagnostics threaded out alongside the
200/// AST (ADR 0117) — see [`parse_units_with_warnings`].
201pub fn parse_unit_with_warnings(
202    tokens: &[Token],
203    source: &str,
204) -> Result<(SourceUnit, Vec<CompileError>), Vec<CompileError>> {
205    let (filtered, trivia) = split_trivia(tokens, source);
206    let mut warnings = Vec::new();
207    let mut p = Parser::new(&filtered, source, trivia, &mut warnings);
208    let result = match p.parse_unit() {
209        Ok(u) => {
210            if let Some(extra) = p.peek() {
211                Err(vec![
212                    CompileError::new(
213                        "bynk.parse.extra_tokens",
214                        extra.span,
215                        "unexpected token after top-level declaration",
216                    )
217                    .with_note(
218                        "a `.bynk` file contains exactly one `commons` or `context` declaration",
219                    ),
220                ])
221            } else {
222                Ok(u)
223            }
224        }
225        Err(e) => Err(vec![e]),
226    };
227    // ADR 0117: warnings (e.g. orphan doc blocks) ride alongside a successful
228    // parse — severity governs gating at the caller, not here.
229    match result {
230        Ok(u) => Ok((u, warnings)),
231        Err(mut errs) => {
232            errs.append(&mut warnings);
233            Err(errs)
234        }
235    }
236}
237
238/// Parse a token slice into **all** the top-level [`SourceUnit`]s in one file
239/// (v0.113, testing track slice 1b). A `.bynk` file may hold more than one
240/// top-level declaration — an *atomic* file with `commons`/`context` **and** a
241/// `suite` together (DECISION S) — so the compiler parses a `Vec`, not a single
242/// unit. Test-ness is a property of each declaration, not of the file.
243///
244/// Bails on the first malformed declaration (like [`parse_unit`], not the
245/// recovering LSP path). An empty file is an error.
246pub fn parse_units(tokens: &[Token], source: &str) -> Result<Vec<SourceUnit>, Vec<CompileError>> {
247    parse_units_with_warnings(tokens, source).map(|(units, _warnings)| units)
248}
249
250/// [`parse_units`] with the non-fatal diagnostics threaded out alongside the
251/// AST (ADR 0117): a successful parse returns `Ok((units, warnings))` instead
252/// of hard-failing on a warning-severity diagnostic (an orphan doc block used
253/// to abort file discovery and throw the good AST away). A failed parse still
254/// returns every diagnostic — errors then warnings — in the `Err`.
255pub fn parse_units_with_warnings(
256    tokens: &[Token],
257    source: &str,
258) -> Result<(Vec<SourceUnit>, Vec<CompileError>), Vec<CompileError>> {
259    let (filtered, trivia) = split_trivia(tokens, source);
260    let mut warnings = Vec::new();
261    let mut p = Parser::new(&filtered, source, trivia, &mut warnings);
262    let mut units = Vec::new();
263    let mut errors: Vec<CompileError> = Vec::new();
264    while p.peek().is_some() {
265        match p.parse_unit() {
266            Ok(u) => units.push(u),
267            Err(e) => {
268                errors.push(e);
269                break;
270            }
271        }
272    }
273    let eof = p.eof_span();
274    // `p` (and thus its `&mut warnings` borrow) is no longer used past here, so
275    // the local `warnings` are readable again.
276    if !errors.is_empty() {
277        errors.append(&mut warnings);
278        return Err(errors);
279    }
280    if units.is_empty() {
281        return Err(vec![CompileError::new(
282            "bynk.parse.unexpected_eof",
283            eof,
284            "expected `commons`, `context`, or `suite` to start the file, found end of file",
285        )]);
286    }
287    Ok((units, warnings))
288}
289
290/// A signed numeric literal in refinement-bound position (v0.21): `InRange`
291/// bounds are either both `Int` or both `Float`.
292enum SignedNumLit {
293    Int(IntBound),
294    Float(FloatBound),
295}
296
297struct Parser<'a> {
298    tokens: &'a [Token],
299    source: &'a str,
300    pos: usize,
301    /// Accumulated non-fatal diagnostics. v0.3 uses this for orphan-doc
302    /// warnings, which are emitted as errors with a distinguishable category.
303    warnings: &'a mut Vec<CompileError>,
304    /// When true, the item-level loops catch errors from individual item
305    /// parses, push them into `recovered_errors`, and skip forward to the
306    /// next top-level item boundary instead of bailing. Used by the LSP via
307    /// [`parse_unit_with_recovery`]; disabled in the normal `parse` path so
308    /// existing single-error behaviour is preserved.
309    recover_mode: bool,
310    /// Errors collected during recovery-mode parsing. Only populated when
311    /// `recover_mode` is true.
312    recovered_errors: Vec<CompileError>,
313    /// Line-comment trivia separated from the token stream. See
314    /// [`TriviaTable`].
315    trivia: TriviaTable,
316}
317
318impl<'a> Parser<'a> {
319    fn new(
320        tokens: &'a [Token],
321        source: &'a str,
322        trivia: TriviaTable,
323        warnings: &'a mut Vec<CompileError>,
324    ) -> Self {
325        Self {
326            tokens,
327            source,
328            pos: 0,
329            warnings,
330            recover_mode: false,
331            recovered_errors: Vec::new(),
332            trivia,
333        }
334    }
335
336    /// Comments immediately preceding the current peek position. Consumed
337    /// (the table entry is cleared) so the same comments are not attached
338    /// to two nodes.
339    fn take_leading_trivia(&mut self) -> Vec<String> {
340        self.trivia.take_leading(self.pos)
341    }
342
343    /// Trailing comment, if any, on the same source line as the most
344    /// recently consumed content token. Call AFTER finishing a declaration
345    /// or statement, while `self.pos` points one past its last token.
346    fn take_trailing_trivia(&mut self) -> Option<String> {
347        if self.pos == 0 {
348            return None;
349        }
350        self.trivia.take_trailing(self.pos - 1)
351    }
352
353    /// Handle a per-item parse error. In recovery mode, record the error and
354    /// advance to the next sync point so the item loop can continue; otherwise
355    /// propagate as a hard failure.
356    fn handle_item_err(&mut self, e: CompileError) -> Result<(), CompileError> {
357        if self.recover_mode {
358            self.recovered_errors.push(e);
359            let before = self.pos;
360            self.recover_to_top_item();
361            // The sync target may be the very token that produced the error —
362            // a context-only keyword (`capability`, `service`, …) at item
363            // position in a commons errors *without consuming it*, and it is
364            // itself a sync point. Recovery must always make progress, or the
365            // item loop re-reports the same error until memory runs out
366            // (found by the `parse` fuzz target on a seed input).
367            if self.pos == before {
368                self.bump();
369            }
370            Ok(())
371        } else {
372            Err(e)
373        }
374    }
375
376    /// Skip forward to the next top-level item boundary: either a top-level
377    /// declaration keyword (`type`, `fn`, `uses`, `consumes`, `exports`,
378    /// `capability`, `provides`, `service`, `agent`), a closing brace, or
379    /// end-of-input. Used only in recovery mode.
380    fn recover_to_top_item(&mut self) {
381        while let Some(t) = self.peek() {
382            match t.kind {
383                TokenKind::Type
384                | TokenKind::Fn
385                | TokenKind::Uses
386                | TokenKind::Consumes
387                | TokenKind::Exports
388                | TokenKind::Capability
389                | TokenKind::Provides
390                | TokenKind::Service
391                | TokenKind::Agent
392                | TokenKind::Suite
393                | TokenKind::Case
394                | TokenKind::RBrace
395                | TokenKind::Commons
396                | TokenKind::Context => return,
397                _ => {
398                    self.bump();
399                }
400            }
401        }
402    }
403
404    fn peek(&self) -> Option<Token> {
405        self.tokens.get(self.pos).copied()
406    }
407
408    fn peek_kind(&self) -> Option<TokenKind> {
409        self.peek().map(|t| t.kind)
410    }
411
412    /// The token `n` positions ahead of the cursor (`nth(0)` == `peek()`).
413    fn nth(&self, n: usize) -> Option<Token> {
414        self.tokens.get(self.pos + n).copied()
415    }
416
417    fn nth_kind(&self, n: usize) -> Option<TokenKind> {
418        self.nth(n).map(|t| t.kind)
419    }
420
421    /// The source text of the token `n` positions ahead, or `""` if none.
422    fn nth_text(&self, n: usize) -> &'a str {
423        self.nth(n).map(|t| self.slice(t.span)).unwrap_or("")
424    }
425
426    /// The span of the most recently consumed token (`self.pos - 1`). Falls back
427    /// to the current token's span when nothing has been consumed yet.
428    fn prev_span(&self) -> Span {
429        self.tokens
430            .get(self.pos.wrapping_sub(1))
431            .or_else(|| self.peek_ref())
432            .map(|t| t.span)
433            .unwrap_or_default()
434    }
435
436    fn peek_ref(&self) -> Option<&Token> {
437        self.tokens.get(self.pos)
438    }
439
440    fn bump(&mut self) -> Option<Token> {
441        let t = self.peek();
442        if t.is_some() {
443            self.pos += 1;
444        }
445        t
446    }
447
448    fn eat(&mut self, kind: TokenKind) -> Option<Token> {
449        if self.peek_kind() == Some(kind) {
450            self.bump()
451        } else {
452            None
453        }
454    }
455
456    fn slice(&self, span: Span) -> &'a str {
457        &self.source[span.range()]
458    }
459
460    /// True when the next token sits on a later line than `prev`. Used to
461    /// keep a `[` that opens a new line out of the postfix type-application
462    /// form: `f` followed by `[1, 2]` on the next line is an identifier and
463    /// a list literal, not `f[…]` (v0.20b).
464    fn next_token_on_new_line(&self, prev: Span) -> bool {
465        match self.peek() {
466            Some(t) if prev.end <= t.span.start => {
467                self.source[prev.end..t.span.start].contains('\n')
468            }
469            _ => false,
470        }
471    }
472
473    /// Span pointing at the end of input — used for "unexpected EOF" reports.
474    /// The start backs up to the **start of the final char**, not `len - 1`, so
475    /// the span never splits a multibyte codepoint (an unterminated construct
476    /// whose last line ends in non-ASCII — e.g. a `--` comment ending in `→`).
477    fn eof_span(&self) -> Span {
478        let end = self.source.len();
479        let start = (0..end)
480            .rev()
481            .find(|&i| self.source.is_char_boundary(i))
482            .unwrap_or(0);
483        Span::new(start, end)
484    }
485
486    fn expect(&mut self, kind: TokenKind, ctx: &str) -> Result<Token, CompileError> {
487        match self.peek() {
488            Some(t) if t.kind == kind => {
489                self.bump();
490                Ok(t)
491            }
492            Some(t) => Err(CompileError::new(
493                "bynk.parse.expected_token",
494                t.span,
495                format!(
496                    "expected {} {ctx}, found {}",
497                    kind.describe(),
498                    t.kind.describe()
499                ),
500            )),
501            None => Err(CompileError::new(
502                "bynk.parse.unexpected_eof",
503                self.eof_span(),
504                format!("expected {} {ctx}, found end of file", kind.describe()),
505            )),
506        }
507    }
508
509    fn expect_ident(&mut self, ctx: &str) -> Result<Ident, CompileError> {
510        match self.peek() {
511            Some(t) if t.kind == TokenKind::Ident => {
512                self.bump();
513                Ok(Ident {
514                    name: self.slice(t.span).to_string(),
515                    span: t.span,
516                })
517            }
518            // v0.5 contextual keyword `on` doubles as an identifier in
519            // expression / field-access positions so users can name fields and
520            // parameters using it. It retains its keyword meaning only at
521            // handler-decl-level (`on call(...)`).
522            //
523            // v0.7 / v0.112: `suite` and `case` are contextual too — they
524            // introduce the suite declaration and its cases, but are perfectly
525            // valid commons/context/field names otherwise.
526            Some(t) if matches!(t.kind, TokenKind::On | TokenKind::Suite | TokenKind::Case) => {
527                self.bump();
528                Ok(Ident {
529                    name: self.slice(t.span).to_string(),
530                    span: t.span,
531                })
532            }
533            Some(t) if is_reserved_keyword(t.kind) => Err(CompileError::new(
534                "bynk.parse.reserved_keyword",
535                t.span,
536                format!(
537                    "expected identifier {ctx}, but `{}` is a reserved keyword",
538                    self.slice(t.span)
539                ),
540            )
541            .with_note("rename the identifier to something that is not a keyword")),
542            Some(t) => Err(CompileError::new(
543                "bynk.parse.expected_token",
544                t.span,
545                format!("expected identifier {ctx}, found {}", t.kind.describe()),
546            )),
547            None => Err(CompileError::new(
548                "bynk.parse.unexpected_eof",
549                self.eof_span(),
550                format!("expected identifier {ctx}, found end of file"),
551            )),
552        }
553    }
554
555    // -- top level --
556
557    /// Consume an optional doc block at the current position, returning the
558    /// (content, end-of-doc span) pair. Returns None if the next token is not
559    /// a doc block.
560    fn take_doc_block(&mut self) -> Option<(String, Span)> {
561        if self.peek_kind() == Some(TokenKind::DocBlock) {
562            let t = self.bump().unwrap();
563            let body = doc_block_content(self.source, t.span);
564            return Some((body, t.span));
565        }
566        None
567    }
568
569    /// Collect all line-comment trivia leading the next declaration plus
570    /// the optional doc block. Comments may appear both *before* and
571    /// *between* the doc and the declaration; the spec canonicalises both
572    /// groups above the doc, so we concatenate them.
573    fn collect_item_lead(&mut self) -> (Vec<String>, Option<(String, Span)>) {
574        let mut leading = self.take_leading_trivia();
575        let doc = self.take_doc_block();
576        if doc.is_some() {
577            leading.extend(self.take_leading_trivia());
578        }
579        (leading, doc)
580    }
581
582    /// Attach a parsed doc block to a following declaration unless a blank
583    /// line separates them, in which case the doc is orphaned (warning).
584    fn finalize_doc(&mut self, doc: Option<(String, Span)>, next_span: Span) -> Option<String> {
585        let (content, doc_span) = doc?;
586        // A blank line between the doc and the next decl orphans the doc.
587        if has_blank_line_between(self.source, doc_span.end, next_span.start) {
588            self.warnings.push(
589                CompileError::new(
590                    "bynk.parse.orphan_doc_block",
591                    doc_span,
592                    "documentation block is separated from the following declaration by a blank line; it will not be attached",
593                )
594                .with_note(
595                    "remove the blank line to attach the doc to the next declaration, \
596                     or remove the doc block if it is not meant to document anything",
597                ),
598            );
599            return None;
600        }
601        Some(content)
602    }
603}
604
605/// Parse the body of a lexed double-quoted string literal (the lexeme,
606/// including surrounding quotes), applying the v0 escape rules.
607fn parse_string_literal(lexeme: &str, span: Span) -> Result<String, CompileError> {
608    let bytes = lexeme.as_bytes();
609    debug_assert!(bytes.first() == Some(&b'"') && bytes.last() == Some(&b'"'));
610    let inner = &lexeme[1..lexeme.len() - 1];
611    let mut out = String::with_capacity(inner.len());
612    let mut chars = inner.chars();
613    while let Some(c) = chars.next() {
614        if c == '\\' {
615            match chars.next() {
616                Some('n') => out.push('\n'),
617                Some('t') => out.push('\t'),
618                Some('"') => out.push('"'),
619                Some('\\') => out.push('\\'),
620                other => {
621                    return Err(CompileError::new(
622                        "bynk.lex.bad_escape",
623                        span,
624                        format!(
625                            "invalid escape sequence `\\{}` in string literal",
626                            other.map(|c| c.to_string()).unwrap_or_default()
627                        ),
628                    )
629                    .with_note("supported escapes: \\n \\t \\\" \\\\"));
630                }
631            }
632        } else {
633            out.push(c);
634        }
635    }
636    Ok(out)
637}
638
639fn is_reserved_keyword(kind: TokenKind) -> bool {
640    use TokenKind::*;
641    matches!(
642        kind,
643        Commons
644            | Type
645            | Fn
646            | Where
647            | And
648            | True
649            | False
650            | Int
651            | String
652            | Bool
653            | Let
654            | If
655            | Else
656            | Ok
657            | Err
658            | Result
659            | ValidationError
660            | Enum
661            | Match
662            | Option
663            | Record
664            | Self_
665            | Some
666            | None
667            | Is
668            | Opaque
669            | Uses
670            | Context
671            | Consumes
672            | Exports
673            | Transparent
674            | Agent
675            | As
676            | Capability
677            | Effect
678            | Do
679            | Given
680            | On
681            | Http
682            | Provides
683            | Service
684            | Actor
685            | By
686            | Expect
687            | Suite
688            | Case
689            | Float
690            | Duration
691            | Instant
692            | Bytes
693            | JsonError
694            | Property
695            | Adapter
696            | Binding
697            | Cron
698            | Queue
699            | From
700            | Protocol
701            | Invariant
702            | Implies
703            | Requires
704            | Ensures
705            | Transition
706    )
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712    use crate::lexer::tokenize;
713
714    fn parse_str(src: &str) -> Result<Commons, Vec<CompileError>> {
715        let toks = tokenize(src).map_err(|e| vec![e])?;
716        parse(&toks, src)
717    }
718
719    fn parse_recover_str(src: &str) -> (Option<SourceUnit>, Vec<CompileError>) {
720        let toks = match tokenize(src) {
721            Ok(t) => t,
722            Err(e) => return (None, vec![e]),
723        };
724        parse_unit_with_recovery(&toks, src)
725    }
726
727    #[test]
728    fn eof_span_never_splits_a_multibyte_codepoint() {
729        // An unterminated construct whose final line ends in a non-ASCII char
730        // (here a `--` comment ending in `→`) once produced an `unexpected_eof`
731        // span of `len - 1 .. len`, landing on the arrow's last continuation
732        // byte. Every reported span must sit on char boundaries.
733        for src in [
734            "commons x {\n  -- ends with an arrow →",
735            "agent A {\n  key k: String\n  -- note 🦀",
736            "commons y {\n  type T = é",
737        ] {
738            let (_unit, errors) = parse_recover_str(src);
739            for e in &errors {
740                assert!(
741                    src.is_char_boundary(e.span.start) && src.is_char_boundary(e.span.end),
742                    "span {:?} splits a codepoint in {src:?}",
743                    e.span,
744                );
745            }
746        }
747    }
748
749    #[test]
750    fn recovery_skips_garbage_between_decls() {
751        // Two `type` declarations separated by garbage. Recovery should
752        // accept both and report one error for the garbage between them.
753        let src = "commons x {\n\
754                   type A = Int where NonNegative\n\
755                   ??? !!!\n\
756                   type B = String where NonEmpty\n\
757                   }";
758        let (unit, errors) = parse_recover_str(src);
759        let unit = unit.expect("recovery should produce a partial AST");
760        let SourceUnit::Commons(c) = unit else {
761            panic!("expected commons")
762        };
763        // Both type decls should have been collected despite the garbage.
764        let names: Vec<_> = c
765            .items
766            .iter()
767            .map(|i| match i {
768                CommonsItem::Type(t) => t.name.name.clone(),
769                _ => panic!("expected only types"),
770            })
771            .collect();
772        assert!(
773            names.contains(&"A".to_string()) && names.contains(&"B".to_string()),
774            "expected both A and B; got {names:?}",
775        );
776        assert!(!errors.is_empty(), "expected at least one parse error");
777    }
778
779    #[test]
780    fn recovery_handles_bad_first_decl_then_good_second() {
781        // First decl is malformed (missing `=`); second is well-formed.
782        let src = "commons x {\n\
783                   type A Int where NonNegative\n\
784                   type B = String where NonEmpty\n\
785                   }";
786        let (unit, errors) = parse_recover_str(src);
787        let unit = unit.expect("recovery should produce a partial AST");
788        let SourceUnit::Commons(c) = unit else {
789            panic!("expected commons")
790        };
791        let names: Vec<_> = c
792            .items
793            .iter()
794            .filter_map(|i| match i {
795                CommonsItem::Type(t) => Some(t.name.name.clone()),
796                _ => None,
797            })
798            .collect();
799        assert!(
800            names.contains(&"B".to_string()),
801            "B should be parsed after A's failure; got {names:?}"
802        );
803        assert!(!errors.is_empty(), "expected at least one parse error");
804    }
805
806    #[test]
807    fn doc_block_attaches_to_type() {
808        let c =
809            parse_str("commons x {\n---\nA descriptive doc.\n---\ntype T = Int where Positive\n}")
810                .unwrap();
811        let CommonsItem::Type(t) = &c.items[0] else {
812            panic!()
813        };
814        assert!(t.documentation.is_some());
815        assert!(
816            t.documentation
817                .as_ref()
818                .unwrap()
819                .contains("A descriptive doc.")
820        );
821    }
822
823    #[test]
824    fn interpolated_string_parses_into_parts() {
825        // v0.43: `"Hi, \(name)!"` splits into chunk / hole / chunk.
826        let c = parse_str("commons x\n\nfn f(name: String) -> String {\n  \"Hi, \\(name)!\"\n}\n")
827            .unwrap();
828        let CommonsItem::Fn(f) = &c.items[0] else {
829            panic!("expected fn")
830        };
831        let ExprKind::InterpStr(parts) = &f.body.tail.kind else {
832            panic!("expected InterpStr, got {:?}", f.body.tail.kind)
833        };
834        assert_eq!(parts.len(), 3);
835        assert!(matches!(&parts[0], InterpPart::Chunk(s) if s == "Hi, "));
836        assert!(
837            matches!(&parts[1], InterpPart::Hole(h) if matches!(&h.kind, ExprKind::Ident(id) if id.name == "name"))
838        );
839        assert!(matches!(&parts[2], InterpPart::Chunk(s) if s == "!"));
840    }
841
842    #[test]
843    fn interpolated_hole_parses_a_full_expression() {
844        // A hole holds an arbitrary expression, not just an identifier.
845        let c =
846            parse_str("commons x\n\nfn f(a: Int, b: Int) -> String {\n  \"sum = \\(a + b)\"\n}\n")
847                .unwrap();
848        let CommonsItem::Fn(f) = &c.items[0] else {
849            panic!("expected fn")
850        };
851        let ExprKind::InterpStr(parts) = &f.body.tail.kind else {
852            panic!("expected InterpStr")
853        };
854        assert!(matches!(&parts[1], InterpPart::Hole(h) if matches!(&h.kind, ExprKind::BinOp(..))));
855    }
856
857    #[test]
858    fn empty_interpolation_hole_is_rejected() {
859        let errs = parse_str("commons x\n\nfn f() -> String {\n  \"\\()\"\n}\n").unwrap_err();
860        assert!(
861            errs.iter()
862                .any(|e| e.category == "bynk.parse.empty_interpolation"),
863            "expected empty_interpolation; got {errs:?}"
864        );
865    }
866
867    #[test]
868    fn fragment_form_parses() {
869        let c = parse_str("commons x.y\n\ntype T = Int where NonNegative\n").unwrap();
870        assert_eq!(c.form, CommonsForm::Fragment);
871        assert_eq!(c.items.len(), 1);
872    }
873
874    #[test]
875    fn uses_parses() {
876        let c = parse_str("commons x\n\nuses other.lib\n").unwrap();
877        assert_eq!(c.uses.len(), 1);
878        assert_eq!(c.uses[0].target.joined(), "other.lib");
879    }
880
881    fn parse_unit_str(src: &str) -> Result<SourceUnit, Vec<CompileError>> {
882        let toks = tokenize(src).map_err(|e| vec![e])?;
883        parse_unit(&toks, src)
884    }
885
886    #[test]
887    fn minimal_context_parses() {
888        let u = parse_unit_str("context commerce.orders {}").unwrap();
889        let SourceUnit::Context(c) = u else {
890            panic!("expected context");
891        };
892        assert_eq!(c.name.joined(), "commerce.orders");
893        assert!(c.items.is_empty());
894    }
895
896    #[test]
897    fn context_consumes_and_exports_parse() {
898        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}";
899        let u = parse_unit_str(src).unwrap();
900        let SourceUnit::Context(c) = u else { panic!() };
901        assert_eq!(c.uses.len(), 1);
902        assert_eq!(c.consumes.len(), 1);
903        assert_eq!(c.exports.len(), 2);
904        assert_eq!(c.exports[0].kind, ExportKind::Type(Visibility::Opaque));
905        assert_eq!(c.exports[1].kind, ExportKind::Type(Visibility::Transparent));
906    }
907
908    #[test]
909    fn context_fragment_form_parses() {
910        let src = "context x.y\n\nuses other.lib\nconsumes other.ctx\nexports opaque { T }\n\ntype T = Int where NonNegative\n";
911        let u = parse_unit_str(src).unwrap();
912        let SourceUnit::Context(c) = u else { panic!() };
913        assert_eq!(c.form, CommonsForm::Fragment);
914        assert_eq!(c.uses.len(), 1);
915        assert_eq!(c.consumes.len(), 1);
916        assert_eq!(c.exports.len(), 1);
917    }
918
919    #[test]
920    fn opaque_type_parses() {
921        let c = parse_str("commons x { type T = opaque Int where NonNegative }").unwrap();
922        let CommonsItem::Type(t) = &c.items[0] else {
923            panic!()
924        };
925        assert!(matches!(t.body, TypeBody::Opaque { .. }));
926    }
927
928    #[test]
929    fn empty_commons() {
930        let c = parse_str("commons fitness.units {}").unwrap();
931        assert_eq!(c.name.joined(), "fitness.units");
932        assert!(c.items.is_empty());
933    }
934
935    #[test]
936    fn one_type_decl() {
937        let c = parse_str("commons x { type Metres = Int where NonNegative }").unwrap();
938        assert_eq!(c.items.len(), 1);
939        let CommonsItem::Type(t) = &c.items[0] else {
940            panic!()
941        };
942        assert_eq!(t.name.name, "Metres");
943        match &t.body {
944            TypeBody::Refined {
945                base, refinement, ..
946            } => {
947                assert_eq!(*base, BaseType::Int);
948                assert!(refinement.is_some());
949            }
950            _ => panic!("expected refined body"),
951        }
952    }
953
954    #[test]
955    fn function_decl() {
956        let c = parse_str("commons x { fn add(a: Int, b: Int) -> Int { a + b } }").unwrap();
957        let CommonsItem::Fn(f) = &c.items[0] else {
958            panic!()
959        };
960        assert_eq!(f.name.ident().name, "add");
961        assert_eq!(f.params.len(), 2);
962    }
963
964    #[test]
965    fn chained_comparison_is_error() {
966        let errs = parse_str("commons x { fn f(a: Int, b: Int, c: Int) -> Bool { a < b < c } }")
967            .unwrap_err();
968        assert_eq!(errs[0].category, "bynk.parse.non_associative");
969    }
970
971    #[test]
972    fn chained_equality_is_error() {
973        let errs = parse_str("commons x { fn f(a: Int, b: Int, c: Int) -> Bool { a == b == c } }")
974            .unwrap_err();
975        assert_eq!(errs[0].category, "bynk.parse.non_associative");
976    }
977
978    #[test]
979    fn let_statement_parses() {
980        let c = parse_str("commons x { fn f(n: Int) -> Int { let y = n + 1\n y } }").unwrap();
981        let CommonsItem::Fn(f) = &c.items[0] else {
982            panic!()
983        };
984        assert_eq!(f.body.statements.len(), 1);
985        match &f.body.statements[0] {
986            Statement::Let(l) => {
987                assert_eq!(l.name.name, "y");
988                assert!(l.type_annot.is_none());
989            }
990            _ => panic!("expected a pure `let` statement"),
991        }
992    }
993
994    #[test]
995    fn let_with_annotation() {
996        let c = parse_str("commons x { fn f(n: Int) -> Int { let y: Int = n\n y } }").unwrap();
997        let CommonsItem::Fn(f) = &c.items[0] else {
998            panic!()
999        };
1000        match &f.body.statements[0] {
1001            Statement::Let(l) => assert!(l.type_annot.is_some()),
1002            _ => panic!("expected a pure `let` statement"),
1003        }
1004    }
1005
1006    #[test]
1007    fn if_else_parses_as_expression() {
1008        let c = parse_str("commons x { fn f(b: Bool) -> Int { if b { 1 } else { 0 } } }").unwrap();
1009        let CommonsItem::Fn(f) = &c.items[0] else {
1010            panic!()
1011        };
1012        assert!(matches!(f.body.tail.kind, ExprKind::If { .. }));
1013    }
1014
1015    #[test]
1016    fn else_if_chain_parses() {
1017        let c = parse_str(
1018            "commons x { fn f(n: Int) -> Int { if n < 0 { -1 } else if n == 0 { 0 } else { 1 } } }",
1019        )
1020        .unwrap();
1021        let CommonsItem::Fn(f) = &c.items[0] else {
1022            panic!()
1023        };
1024        let ExprKind::If { else_block, .. } = &f.body.tail.kind else {
1025            panic!()
1026        };
1027        // The else-branch is a block whose tail is another `If`.
1028        assert!(else_block.statements.is_empty());
1029        assert!(matches!(else_block.tail.kind, ExprKind::If { .. }));
1030    }
1031
1032    #[test]
1033    fn ok_and_err_parse_as_expressions() {
1034        let c = parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Ok(n) } }").unwrap();
1035        let CommonsItem::Fn(f) = &c.items[0] else {
1036            panic!()
1037        };
1038        assert!(matches!(f.body.tail.kind, ExprKind::Ok(_)));
1039
1040        let c =
1041            parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Err(\"x\") } }").unwrap();
1042        let CommonsItem::Fn(f) = &c.items[0] else {
1043            panic!()
1044        };
1045        assert!(matches!(f.body.tail.kind, ExprKind::Err(_)));
1046    }
1047
1048    #[test]
1049    fn question_postfix_parses() {
1050        let c = parse_str(
1051            "commons x { type T = Int where Positive\n fn f(n: Int) -> Result[T, ValidationError] { let x = T.of(n)?\n Ok(x) } }",
1052        )
1053        .unwrap();
1054        let CommonsItem::Fn(f) = &c.items[1] else {
1055            panic!()
1056        };
1057        let Statement::Let(l) = &f.body.statements[0] else {
1058            panic!("expected a pure `let` statement");
1059        };
1060        assert!(matches!(l.value.kind, ExprKind::Question(_)));
1061    }
1062
1063    #[test]
1064    fn constructor_call_parses() {
1065        let c = parse_str(
1066            "commons x { type T = Int where Positive\n fn f(n: Int) -> Result[T, ValidationError] { T.of(n) } }",
1067        )
1068        .unwrap();
1069        let CommonsItem::Fn(f) = &c.items[1] else {
1070            panic!()
1071        };
1072        // v0.2: T.of(n) parses as a MethodCall with receiver Ident("T"); the
1073        // checker reinterprets it as a static call by noticing T is a type.
1074        let ExprKind::MethodCall {
1075            receiver, method, ..
1076        } = &f.body.tail.kind
1077        else {
1078            panic!("expected MethodCall, got {:?}", f.body.tail.kind)
1079        };
1080        let ExprKind::Ident(id) = &receiver.kind else {
1081            panic!("expected receiver Ident");
1082        };
1083        assert_eq!(id.name, "T");
1084        assert_eq!(method.name, "of");
1085    }
1086
1087    #[test]
1088    fn result_type_ref_parses() {
1089        let c = parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Ok(n) } }").unwrap();
1090        let CommonsItem::Fn(f) = &c.items[0] else {
1091            panic!()
1092        };
1093        assert!(matches!(f.return_type, TypeRef::Result(_, _, _)));
1094    }
1095
1096    #[test]
1097    fn result_missing_arg_count_errors() {
1098        let errs = parse_str("commons x { fn f(n: Int) -> Result[Int] { Ok(n) } }").unwrap_err();
1099        assert_eq!(errs[0].category, "bynk.parse.generic_arg_count");
1100    }
1101
1102    #[test]
1103    fn field_access_parses_in_v0_2() {
1104        // v0.2: field access is supported (the type checker validates the
1105        // field exists on the receiver's type). Parser-level acceptance:
1106        let c =
1107            parse_str("commons x { type R = { foo: Int }\n fn f(r: R) -> Int { r.foo } }").unwrap();
1108        let CommonsItem::Fn(f) = &c.items[1] else {
1109            panic!()
1110        };
1111        assert!(matches!(f.body.tail.kind, ExprKind::FieldAccess { .. }));
1112    }
1113
1114    // -- v1.1 trivia attachment --
1115
1116    #[test]
1117    fn leading_line_comment_attaches_to_next_decl() {
1118        let src = "commons x {\n-- explain the type\ntype T = Int where NonNegative\n}";
1119        let c = parse_str(src).unwrap();
1120        let CommonsItem::Type(t) = &c.items[0] else {
1121            panic!()
1122        };
1123        assert_eq!(t.trivia.leading, vec![" explain the type".to_string()]);
1124        assert!(t.trivia.trailing.is_none());
1125    }
1126
1127    #[test]
1128    fn trailing_line_comment_attaches_to_prev_decl() {
1129        let src = "commons x {\ntype T = Int where NonNegative  -- trailing note\n}";
1130        let c = parse_str(src).unwrap();
1131        let CommonsItem::Type(t) = &c.items[0] else {
1132            panic!()
1133        };
1134        assert!(t.trivia.leading.is_empty());
1135        assert_eq!(t.trivia.trailing.as_deref(), Some(" trailing note"));
1136    }
1137
1138    #[test]
1139    fn grouped_leading_comments_attach_together() {
1140        let src = "commons x {\n-- one\n-- two\n-- three\ntype T = Int where Positive\n}";
1141        let c = parse_str(src).unwrap();
1142        let CommonsItem::Type(t) = &c.items[0] else {
1143            panic!()
1144        };
1145        assert_eq!(
1146            t.trivia.leading,
1147            vec![" one".to_string(), " two".to_string(), " three".to_string()],
1148        );
1149    }
1150
1151    #[test]
1152    fn comment_with_doc_block_keeps_both() {
1153        // Both `-- intro` and the doc block should attach to the type decl.
1154        let src = "commons x {\n-- intro\n---\ndocs\n---\ntype T = Int where Positive\n}";
1155        let c = parse_str(src).unwrap();
1156        let CommonsItem::Type(t) = &c.items[0] else {
1157            panic!()
1158        };
1159        assert_eq!(t.trivia.leading, vec![" intro".to_string()]);
1160        assert_eq!(t.documentation.as_deref(), Some("docs"));
1161    }
1162
1163    #[test]
1164    fn comment_before_let_statement_attaches() {
1165        let src = "commons x {\nfn f(n: Int) -> Int {\n-- pick a value\nlet y = n + 1\ny\n}\n}";
1166        let c = parse_str(src).unwrap();
1167        let CommonsItem::Fn(f) = &c.items[0] else {
1168            panic!()
1169        };
1170        let Statement::Let(l) = &f.body.statements[0] else {
1171            panic!()
1172        };
1173        assert_eq!(l.trivia.leading, vec![" pick a value".to_string()]);
1174    }
1175
1176    #[test]
1177    fn comment_before_tail_attaches_to_block_tail() {
1178        let src = "commons x {\nfn f(n: Int) -> Int {\nlet y = n + 1\n-- result\ny\n}\n}";
1179        let c = parse_str(src).unwrap();
1180        let CommonsItem::Fn(f) = &c.items[0] else {
1181            panic!()
1182        };
1183        assert_eq!(f.body.tail_leading_comments, vec![" result".to_string()],);
1184    }
1185
1186    /// Drift guard: every alphabetic keyword the lexer declares must be
1187    /// classified by `is_reserved_keyword`, or be one of the *contextual*
1188    /// keywords `expect_ident` deliberately admits as identifiers
1189    /// (`on`/`suite`/`case`). Everything else in this codebase that can
1190    /// drift has a guard; this predicate had silently fallen 17 keywords
1191    /// behind, degrading the reserved-keyword diagnostic to the generic
1192    /// expected-token one.
1193    #[test]
1194    fn is_reserved_keyword_covers_every_lexer_keyword() {
1195        let lexer_src = include_str!("lexer.rs");
1196        let mut words = Vec::new();
1197        for line in lexer_src.lines() {
1198            let t = line.trim();
1199            if let Some(rest) = t.strip_prefix("#[token(\"")
1200                && let Some(word) = rest.split('"').next()
1201                && word.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
1202                && word.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
1203            {
1204                words.push(word.to_string());
1205            }
1206        }
1207        assert!(
1208            words.len() > 30,
1209            "keyword extraction looks broken: only {} words",
1210            words.len()
1211        );
1212        // Contextual keywords double as identifiers (see `expect_ident`).
1213        const CONTEXTUAL: &[&str] = &["on", "suite", "case"];
1214        let mut unclassified = Vec::new();
1215        for word in &words {
1216            let tokens = crate::lexer::tokenize(word).expect("keyword lexes");
1217            let kind = tokens.first().expect("keyword yields a token").kind;
1218            if !is_reserved_keyword(kind) && !CONTEXTUAL.contains(&word.as_str()) {
1219                unclassified.push(word.clone());
1220            }
1221        }
1222        assert!(
1223            unclassified.is_empty(),
1224            "keywords missing from is_reserved_keyword (add them, or document \
1225             them as contextual): {unclassified:?}"
1226        );
1227    }
1228
1229    /// Fuzz-found (#516): a context-only keyword at item position in a
1230    /// commons errors without consuming the token, and the recovery sync
1231    /// stops at exactly that keyword — without a progress guard the item
1232    /// loop re-reported the same error until memory ran out.
1233    #[test]
1234    fn recovery_makes_progress_on_context_only_keyword_in_commons() {
1235        let src = "commons demo\n\ncapability Logger {\n  fn log(m: String) -> Effect[()]\n}\n";
1236        let tokens = crate::lexer::tokenize(src).unwrap();
1237        let (unit, errors) = parse_unit_with_recovery(&tokens, src);
1238        assert!(unit.is_some(), "the commons header still parses");
1239        assert!(
1240            errors
1241                .iter()
1242                .any(|e| e.category == "bynk.capability.outside_context"),
1243            "the misplaced capability is reported: {errors:?}"
1244        );
1245        // Termination is the real assertion (this used to OOM); a bounded,
1246        // non-repeating error list is the observable proxy.
1247        assert!(errors.len() < 10, "recovery repeated itself: {errors:?}");
1248    }
1249
1250    #[test]
1251    fn trailing_file_comment_becomes_unit_trailing() {
1252        // A comment after the last item but before EOF (fragment form)
1253        // becomes the commons body's trailing comments so the formatter
1254        // can preserve it.
1255        let src = "commons x\n\ntype T = Int where Positive\n-- afterword\n";
1256        let c = parse_str(src).unwrap();
1257        assert_eq!(c.trailing_comments, vec![" afterword".to_string()]);
1258    }
1259}