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::Stub
391                | TokenKind::Service
392                | TokenKind::Agent
393                | TokenKind::Suite
394                | TokenKind::Case
395                | TokenKind::RBrace
396                | TokenKind::Commons
397                | TokenKind::Context => return,
398                _ => {
399                    self.bump();
400                }
401            }
402        }
403    }
404
405    fn peek(&self) -> Option<Token> {
406        self.tokens.get(self.pos).copied()
407    }
408
409    fn peek_kind(&self) -> Option<TokenKind> {
410        self.peek().map(|t| t.kind)
411    }
412
413    /// The token `n` positions ahead of the cursor (`nth(0)` == `peek()`).
414    fn nth(&self, n: usize) -> Option<Token> {
415        self.tokens.get(self.pos + n).copied()
416    }
417
418    fn nth_kind(&self, n: usize) -> Option<TokenKind> {
419        self.nth(n).map(|t| t.kind)
420    }
421
422    /// The source text of the token `n` positions ahead, or `""` if none.
423    fn nth_text(&self, n: usize) -> &'a str {
424        self.nth(n).map(|t| self.slice(t.span)).unwrap_or("")
425    }
426
427    /// The span of the most recently consumed token (`self.pos - 1`). Falls back
428    /// to the current token's span when nothing has been consumed yet.
429    fn prev_span(&self) -> Span {
430        self.tokens
431            .get(self.pos.wrapping_sub(1))
432            .or_else(|| self.peek_ref())
433            .map(|t| t.span)
434            .unwrap_or_default()
435    }
436
437    fn peek_ref(&self) -> Option<&Token> {
438        self.tokens.get(self.pos)
439    }
440
441    fn bump(&mut self) -> Option<Token> {
442        let t = self.peek();
443        if t.is_some() {
444            self.pos += 1;
445        }
446        t
447    }
448
449    fn eat(&mut self, kind: TokenKind) -> Option<Token> {
450        if self.peek_kind() == Some(kind) {
451            self.bump()
452        } else {
453            None
454        }
455    }
456
457    fn slice(&self, span: Span) -> &'a str {
458        &self.source[span.range()]
459    }
460
461    /// True when the next token sits on a later line than `prev`. Used to
462    /// keep a `[` that opens a new line out of the postfix type-application
463    /// form: `f` followed by `[1, 2]` on the next line is an identifier and
464    /// a list literal, not `f[…]` (v0.20b).
465    fn next_token_on_new_line(&self, prev: Span) -> bool {
466        match self.peek() {
467            Some(t) if prev.end <= t.span.start => {
468                self.source[prev.end..t.span.start].contains('\n')
469            }
470            _ => false,
471        }
472    }
473
474    /// Span pointing at the end of input — used for "unexpected EOF" reports.
475    /// The start backs up to the **start of the final char**, not `len - 1`, so
476    /// the span never splits a multibyte codepoint (an unterminated construct
477    /// whose last line ends in non-ASCII — e.g. a `--` comment ending in `→`).
478    fn eof_span(&self) -> Span {
479        let end = self.source.len();
480        let start = (0..end)
481            .rev()
482            .find(|&i| self.source.is_char_boundary(i))
483            .unwrap_or(0);
484        Span::new(start, end)
485    }
486
487    fn expect(&mut self, kind: TokenKind, ctx: &str) -> Result<Token, CompileError> {
488        match self.peek() {
489            Some(t) if t.kind == kind => {
490                self.bump();
491                Ok(t)
492            }
493            Some(t) => Err(CompileError::new(
494                "bynk.parse.expected_token",
495                t.span,
496                format!(
497                    "expected {} {ctx}, found {}",
498                    kind.describe(),
499                    t.kind.describe()
500                ),
501            )),
502            None => Err(CompileError::new(
503                "bynk.parse.unexpected_eof",
504                self.eof_span(),
505                format!("expected {} {ctx}, found end of file", kind.describe()),
506            )),
507        }
508    }
509
510    fn expect_ident(&mut self, ctx: &str) -> Result<Ident, CompileError> {
511        match self.peek() {
512            Some(t) if t.kind == TokenKind::Ident => {
513                self.bump();
514                Ok(Ident {
515                    name: self.slice(t.span).to_string(),
516                    span: t.span,
517                })
518            }
519            // v0.5 contextual keyword `on` doubles as an identifier in
520            // expression / field-access positions so users can name fields and
521            // parameters using it. It retains its keyword meaning only at
522            // handler-decl-level (`on call(...)`).
523            //
524            // v0.7 / v0.112: `suite` and `case` are contextual too — they
525            // introduce the suite declaration and its cases, but are perfectly
526            // valid commons/context/field names otherwise.
527            Some(t) if matches!(t.kind, TokenKind::On | TokenKind::Suite | TokenKind::Case) => {
528                self.bump();
529                Ok(Ident {
530                    name: self.slice(t.span).to_string(),
531                    span: t.span,
532                })
533            }
534            Some(t) if is_reserved_keyword(t.kind) => Err(CompileError::new(
535                "bynk.parse.reserved_keyword",
536                t.span,
537                format!(
538                    "expected identifier {ctx}, but `{}` is a reserved keyword",
539                    self.slice(t.span)
540                ),
541            )
542            .with_note("rename the identifier to something that is not a keyword")),
543            Some(t) => Err(CompileError::new(
544                "bynk.parse.expected_token",
545                t.span,
546                format!("expected identifier {ctx}, found {}", t.kind.describe()),
547            )),
548            None => Err(CompileError::new(
549                "bynk.parse.unexpected_eof",
550                self.eof_span(),
551                format!("expected identifier {ctx}, found end of file"),
552            )),
553        }
554    }
555
556    // -- top level --
557
558    /// Consume an optional doc block at the current position, returning the
559    /// (content, end-of-doc span) pair. Returns None if the next token is not
560    /// a doc block.
561    fn take_doc_block(&mut self) -> Option<(String, Span)> {
562        if self.peek_kind() == Some(TokenKind::DocBlock) {
563            let t = self.bump().unwrap();
564            let body = doc_block_content(self.source, t.span);
565            return Some((body, t.span));
566        }
567        None
568    }
569
570    /// Collect all line-comment trivia leading the next declaration plus
571    /// the optional doc block. Comments may appear both *before* and
572    /// *between* the doc and the declaration; the spec canonicalises both
573    /// groups above the doc, so we concatenate them.
574    fn collect_item_lead(&mut self) -> (Vec<String>, Option<(String, Span)>) {
575        let mut leading = self.take_leading_trivia();
576        let doc = self.take_doc_block();
577        if doc.is_some() {
578            leading.extend(self.take_leading_trivia());
579        }
580        (leading, doc)
581    }
582
583    /// Attach a parsed doc block to a following declaration unless a blank
584    /// line separates them, in which case the doc is orphaned (warning).
585    fn finalize_doc(&mut self, doc: Option<(String, Span)>, next_span: Span) -> Option<String> {
586        let (content, doc_span) = doc?;
587        // A blank line between the doc and the next decl orphans the doc.
588        if has_blank_line_between(self.source, doc_span.end, next_span.start) {
589            self.warnings.push(
590                CompileError::new(
591                    "bynk.parse.orphan_doc_block",
592                    doc_span,
593                    "documentation block is separated from the following declaration by a blank line; it will not be attached",
594                )
595                .with_note(
596                    "remove the blank line to attach the doc to the next declaration, \
597                     or remove the doc block if it is not meant to document anything",
598                ),
599            );
600            return None;
601        }
602        Some(content)
603    }
604}
605
606/// Parse the body of a lexed double-quoted string literal (the lexeme,
607/// including surrounding quotes), applying the v0 escape rules.
608fn parse_string_literal(lexeme: &str, span: Span) -> Result<String, CompileError> {
609    let bytes = lexeme.as_bytes();
610    debug_assert!(bytes.first() == Some(&b'"') && bytes.last() == Some(&b'"'));
611    let inner = &lexeme[1..lexeme.len() - 1];
612    let mut out = String::with_capacity(inner.len());
613    let mut chars = inner.chars();
614    while let Some(c) = chars.next() {
615        if c == '\\' {
616            match chars.next() {
617                Some('n') => out.push('\n'),
618                Some('t') => out.push('\t'),
619                Some('"') => out.push('"'),
620                Some('\\') => out.push('\\'),
621                other => {
622                    return Err(CompileError::new(
623                        "bynk.lex.bad_escape",
624                        span,
625                        format!(
626                            "invalid escape sequence `\\{}` in string literal",
627                            other.map(|c| c.to_string()).unwrap_or_default()
628                        ),
629                    )
630                    .with_note("supported escapes: \\n \\t \\\" \\\\"));
631                }
632            }
633        } else {
634            out.push(c);
635        }
636    }
637    Ok(out)
638}
639
640fn is_reserved_keyword(kind: TokenKind) -> bool {
641    use TokenKind::*;
642    matches!(
643        kind,
644        Commons
645            | Type
646            | Fn
647            | Where
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            | Stub
684            | Service
685            | Actor
686            | By
687            | Expect
688            | Suite
689            | Case
690            | Float
691            | Duration
692            | Instant
693            | Bytes
694            | JsonError
695            | Property
696            | Adapter
697            | Binding
698            | Cron
699            | Queue
700            | From
701            | Protocol
702            | Invariant
703            | Implies
704            | Requires
705            | Ensures
706            | Transition
707    )
708}
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713    use crate::lexer::tokenize;
714
715    fn parse_str(src: &str) -> Result<Commons, Vec<CompileError>> {
716        let toks = tokenize(src).map_err(|e| vec![e])?;
717        parse(&toks, src)
718    }
719
720    fn parse_recover_str(src: &str) -> (Option<SourceUnit>, Vec<CompileError>) {
721        let toks = match tokenize(src) {
722            Ok(t) => t,
723            Err(e) => return (None, vec![e]),
724        };
725        parse_unit_with_recovery(&toks, src)
726    }
727
728    #[test]
729    fn eof_span_never_splits_a_multibyte_codepoint() {
730        // An unterminated construct whose final line ends in a non-ASCII char
731        // (here a `--` comment ending in `→`) once produced an `unexpected_eof`
732        // span of `len - 1 .. len`, landing on the arrow's last continuation
733        // byte. Every reported span must sit on char boundaries.
734        for src in [
735            "commons x {\n  -- ends with an arrow →",
736            "agent A {\n  key k: String\n  -- note 🦀",
737            "commons y {\n  type T = é",
738        ] {
739            let (_unit, errors) = parse_recover_str(src);
740            for e in &errors {
741                assert!(
742                    src.is_char_boundary(e.span.start) && src.is_char_boundary(e.span.end),
743                    "span {:?} splits a codepoint in {src:?}",
744                    e.span,
745                );
746            }
747        }
748    }
749
750    #[test]
751    fn recovery_skips_garbage_between_decls() {
752        // Two `type` declarations separated by garbage. Recovery should
753        // accept both and report one error for the garbage between them.
754        let src = "commons x {\n\
755                   type A = Int where NonNegative\n\
756                   ??? !!!\n\
757                   type B = String where NonEmpty\n\
758                   }";
759        let (unit, errors) = parse_recover_str(src);
760        let unit = unit.expect("recovery should produce a partial AST");
761        let SourceUnit::Commons(c) = unit else {
762            panic!("expected commons")
763        };
764        // Both type decls should have been collected despite the garbage.
765        let names: Vec<_> = c
766            .items
767            .iter()
768            .map(|i| match i {
769                CommonsItem::Type(t) => t.name.name.clone(),
770                _ => panic!("expected only types"),
771            })
772            .collect();
773        assert!(
774            names.contains(&"A".to_string()) && names.contains(&"B".to_string()),
775            "expected both A and B; got {names:?}",
776        );
777        assert!(!errors.is_empty(), "expected at least one parse error");
778    }
779
780    #[test]
781    fn recovery_handles_bad_first_decl_then_good_second() {
782        // First decl is malformed (missing `=`); second is well-formed.
783        let src = "commons x {\n\
784                   type A Int where NonNegative\n\
785                   type B = String where NonEmpty\n\
786                   }";
787        let (unit, errors) = parse_recover_str(src);
788        let unit = unit.expect("recovery should produce a partial AST");
789        let SourceUnit::Commons(c) = unit else {
790            panic!("expected commons")
791        };
792        let names: Vec<_> = c
793            .items
794            .iter()
795            .filter_map(|i| match i {
796                CommonsItem::Type(t) => Some(t.name.name.clone()),
797                _ => None,
798            })
799            .collect();
800        assert!(
801            names.contains(&"B".to_string()),
802            "B should be parsed after A's failure; got {names:?}"
803        );
804        assert!(!errors.is_empty(), "expected at least one parse error");
805    }
806
807    #[test]
808    fn doc_block_attaches_to_type() {
809        let c =
810            parse_str("commons x {\n---\nA descriptive doc.\n---\ntype T = Int where Positive\n}")
811                .unwrap();
812        let CommonsItem::Type(t) = &c.items[0] else {
813            panic!()
814        };
815        assert!(t.documentation.is_some());
816        assert!(
817            t.documentation
818                .as_ref()
819                .unwrap()
820                .contains("A descriptive doc.")
821        );
822    }
823
824    #[test]
825    fn interpolated_string_parses_into_parts() {
826        // v0.43: `"Hi, \(name)!"` splits into chunk / hole / chunk.
827        let c = parse_str("commons x\n\nfn f(name: String) -> String {\n  \"Hi, \\(name)!\"\n}\n")
828            .unwrap();
829        let CommonsItem::Fn(f) = &c.items[0] else {
830            panic!("expected fn")
831        };
832        let ExprKind::InterpStr(parts) = &f.body.tail.kind else {
833            panic!("expected InterpStr, got {:?}", f.body.tail.kind)
834        };
835        assert_eq!(parts.len(), 3);
836        assert!(matches!(&parts[0], InterpPart::Chunk(s) if s == "Hi, "));
837        assert!(
838            matches!(&parts[1], InterpPart::Hole(h) if matches!(&h.kind, ExprKind::Ident(id) if id.name == "name"))
839        );
840        assert!(matches!(&parts[2], InterpPart::Chunk(s) if s == "!"));
841    }
842
843    #[test]
844    fn interpolated_hole_parses_a_full_expression() {
845        // A hole holds an arbitrary expression, not just an identifier.
846        let c =
847            parse_str("commons x\n\nfn f(a: Int, b: Int) -> String {\n  \"sum = \\(a + b)\"\n}\n")
848                .unwrap();
849        let CommonsItem::Fn(f) = &c.items[0] else {
850            panic!("expected fn")
851        };
852        let ExprKind::InterpStr(parts) = &f.body.tail.kind else {
853            panic!("expected InterpStr")
854        };
855        assert!(matches!(&parts[1], InterpPart::Hole(h) if matches!(&h.kind, ExprKind::BinOp(..))));
856    }
857
858    #[test]
859    fn empty_interpolation_hole_is_rejected() {
860        let errs = parse_str("commons x\n\nfn f() -> String {\n  \"\\()\"\n}\n").unwrap_err();
861        assert!(
862            errs.iter()
863                .any(|e| e.category == "bynk.parse.empty_interpolation"),
864            "expected empty_interpolation; got {errs:?}"
865        );
866    }
867
868    #[test]
869    fn fragment_form_parses() {
870        let c = parse_str("commons x.y\n\ntype T = Int where NonNegative\n").unwrap();
871        assert_eq!(c.form, CommonsForm::Fragment);
872        assert_eq!(c.items.len(), 1);
873    }
874
875    #[test]
876    fn uses_parses() {
877        let c = parse_str("commons x\n\nuses other.lib\n").unwrap();
878        assert_eq!(c.uses.len(), 1);
879        assert_eq!(c.uses[0].target.joined(), "other.lib");
880    }
881
882    fn parse_unit_str(src: &str) -> Result<SourceUnit, Vec<CompileError>> {
883        let toks = tokenize(src).map_err(|e| vec![e])?;
884        parse_unit(&toks, src)
885    }
886
887    #[test]
888    fn minimal_context_parses() {
889        let u = parse_unit_str("context commerce.orders {}").unwrap();
890        let SourceUnit::Context(c) = u else {
891            panic!("expected context");
892        };
893        assert_eq!(c.name.joined(), "commerce.orders");
894        assert!(c.items.is_empty());
895    }
896
897    #[test]
898    fn context_consumes_and_exports_parse() {
899        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}";
900        let u = parse_unit_str(src).unwrap();
901        let SourceUnit::Context(c) = u else { panic!() };
902        assert_eq!(c.uses.len(), 1);
903        assert_eq!(c.consumes.len(), 1);
904        assert_eq!(c.exports.len(), 2);
905        assert_eq!(c.exports[0].kind, ExportKind::Type(Visibility::Opaque));
906        assert_eq!(c.exports[1].kind, ExportKind::Type(Visibility::Transparent));
907    }
908
909    #[test]
910    fn context_fragment_form_parses() {
911        let src = "context x.y\n\nuses other.lib\nconsumes other.ctx\nexports opaque { T }\n\ntype T = Int where NonNegative\n";
912        let u = parse_unit_str(src).unwrap();
913        let SourceUnit::Context(c) = u else { panic!() };
914        assert_eq!(c.form, CommonsForm::Fragment);
915        assert_eq!(c.uses.len(), 1);
916        assert_eq!(c.consumes.len(), 1);
917        assert_eq!(c.exports.len(), 1);
918    }
919
920    #[test]
921    fn opaque_type_parses() {
922        let c = parse_str("commons x { type T = opaque Int where NonNegative }").unwrap();
923        let CommonsItem::Type(t) = &c.items[0] else {
924            panic!()
925        };
926        assert!(matches!(t.body, TypeBody::Opaque { .. }));
927    }
928
929    #[test]
930    fn empty_commons() {
931        let c = parse_str("commons fitness.units {}").unwrap();
932        assert_eq!(c.name.joined(), "fitness.units");
933        assert!(c.items.is_empty());
934    }
935
936    #[test]
937    fn one_type_decl() {
938        let c = parse_str("commons x { type Metres = Int where NonNegative }").unwrap();
939        assert_eq!(c.items.len(), 1);
940        let CommonsItem::Type(t) = &c.items[0] else {
941            panic!()
942        };
943        assert_eq!(t.name.name, "Metres");
944        match &t.body {
945            TypeBody::Refined {
946                base, refinement, ..
947            } => {
948                assert_eq!(*base, BaseType::Int);
949                assert!(refinement.is_some());
950            }
951            _ => panic!("expected refined body"),
952        }
953    }
954
955    #[test]
956    fn function_decl() {
957        let c = parse_str("commons x { fn add(a: Int, b: Int) -> Int { a + b } }").unwrap();
958        let CommonsItem::Fn(f) = &c.items[0] else {
959            panic!()
960        };
961        assert_eq!(f.name.ident().name, "add");
962        assert_eq!(f.params.len(), 2);
963    }
964
965    #[test]
966    fn chained_comparison_is_error() {
967        let errs = parse_str("commons x { fn f(a: Int, b: Int, c: Int) -> Bool { a < b < c } }")
968            .unwrap_err();
969        assert_eq!(errs[0].category, "bynk.parse.non_associative");
970    }
971
972    #[test]
973    fn chained_equality_is_error() {
974        let errs = parse_str("commons x { fn f(a: Int, b: Int, c: Int) -> Bool { a == b == c } }")
975            .unwrap_err();
976        assert_eq!(errs[0].category, "bynk.parse.non_associative");
977    }
978
979    #[test]
980    fn let_statement_parses() {
981        let c = parse_str("commons x { fn f(n: Int) -> Int { let y = n + 1\n y } }").unwrap();
982        let CommonsItem::Fn(f) = &c.items[0] else {
983            panic!()
984        };
985        assert_eq!(f.body.statements.len(), 1);
986        match &f.body.statements[0] {
987            Statement::Let(l) => {
988                assert_eq!(l.name.name, "y");
989                assert!(l.type_annot.is_none());
990            }
991            _ => panic!("expected a pure `let` statement"),
992        }
993    }
994
995    #[test]
996    fn let_with_annotation() {
997        let c = parse_str("commons x { fn f(n: Int) -> Int { let y: Int = n\n y } }").unwrap();
998        let CommonsItem::Fn(f) = &c.items[0] else {
999            panic!()
1000        };
1001        match &f.body.statements[0] {
1002            Statement::Let(l) => assert!(l.type_annot.is_some()),
1003            _ => panic!("expected a pure `let` statement"),
1004        }
1005    }
1006
1007    #[test]
1008    fn if_else_parses_as_expression() {
1009        let c = parse_str("commons x { fn f(b: Bool) -> Int { if b { 1 } else { 0 } } }").unwrap();
1010        let CommonsItem::Fn(f) = &c.items[0] else {
1011            panic!()
1012        };
1013        assert!(matches!(f.body.tail.kind, ExprKind::If { .. }));
1014    }
1015
1016    #[test]
1017    fn else_if_chain_parses() {
1018        let c = parse_str(
1019            "commons x { fn f(n: Int) -> Int { if n < 0 { -1 } else if n == 0 { 0 } else { 1 } } }",
1020        )
1021        .unwrap();
1022        let CommonsItem::Fn(f) = &c.items[0] else {
1023            panic!()
1024        };
1025        let ExprKind::If { else_block, .. } = &f.body.tail.kind else {
1026            panic!()
1027        };
1028        // The else-branch is a block whose tail is another `If`.
1029        assert!(else_block.statements.is_empty());
1030        assert!(matches!(else_block.tail.kind, ExprKind::If { .. }));
1031    }
1032
1033    #[test]
1034    fn ok_and_err_parse_as_expressions() {
1035        let c = parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Ok(n) } }").unwrap();
1036        let CommonsItem::Fn(f) = &c.items[0] else {
1037            panic!()
1038        };
1039        assert!(matches!(f.body.tail.kind, ExprKind::Ok(_)));
1040
1041        let c =
1042            parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Err(\"x\") } }").unwrap();
1043        let CommonsItem::Fn(f) = &c.items[0] else {
1044            panic!()
1045        };
1046        assert!(matches!(f.body.tail.kind, ExprKind::Err(_)));
1047    }
1048
1049    #[test]
1050    fn question_postfix_parses() {
1051        let c = parse_str(
1052            "commons x { type T = Int where Positive\n fn f(n: Int) -> Result[T, ValidationError] { let x = T.of(n)?\n Ok(x) } }",
1053        )
1054        .unwrap();
1055        let CommonsItem::Fn(f) = &c.items[1] else {
1056            panic!()
1057        };
1058        let Statement::Let(l) = &f.body.statements[0] else {
1059            panic!("expected a pure `let` statement");
1060        };
1061        assert!(matches!(l.value.kind, ExprKind::Question(_)));
1062    }
1063
1064    #[test]
1065    fn constructor_call_parses() {
1066        let c = parse_str(
1067            "commons x { type T = Int where Positive\n fn f(n: Int) -> Result[T, ValidationError] { T.of(n) } }",
1068        )
1069        .unwrap();
1070        let CommonsItem::Fn(f) = &c.items[1] else {
1071            panic!()
1072        };
1073        // v0.2: T.of(n) parses as a MethodCall with receiver Ident("T"); the
1074        // checker reinterprets it as a static call by noticing T is a type.
1075        let ExprKind::MethodCall {
1076            receiver, method, ..
1077        } = &f.body.tail.kind
1078        else {
1079            panic!("expected MethodCall, got {:?}", f.body.tail.kind)
1080        };
1081        let ExprKind::Ident(id) = &receiver.kind else {
1082            panic!("expected receiver Ident");
1083        };
1084        assert_eq!(id.name, "T");
1085        assert_eq!(method.name, "of");
1086    }
1087
1088    #[test]
1089    fn result_type_ref_parses() {
1090        let c = parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Ok(n) } }").unwrap();
1091        let CommonsItem::Fn(f) = &c.items[0] else {
1092            panic!()
1093        };
1094        assert!(matches!(f.return_type, TypeRef::Result(_, _, _)));
1095    }
1096
1097    #[test]
1098    fn result_missing_arg_count_errors() {
1099        let errs = parse_str("commons x { fn f(n: Int) -> Result[Int] { Ok(n) } }").unwrap_err();
1100        assert_eq!(errs[0].category, "bynk.parse.generic_arg_count");
1101    }
1102
1103    #[test]
1104    fn field_access_parses_in_v0_2() {
1105        // v0.2: field access is supported (the type checker validates the
1106        // field exists on the receiver's type). Parser-level acceptance:
1107        let c =
1108            parse_str("commons x { type R = { foo: Int }\n fn f(r: R) -> Int { r.foo } }").unwrap();
1109        let CommonsItem::Fn(f) = &c.items[1] else {
1110            panic!()
1111        };
1112        assert!(matches!(f.body.tail.kind, ExprKind::FieldAccess { .. }));
1113    }
1114
1115    // -- v1.1 trivia attachment --
1116
1117    #[test]
1118    fn leading_line_comment_attaches_to_next_decl() {
1119        let src = "commons x {\n-- explain the type\ntype T = Int where NonNegative\n}";
1120        let c = parse_str(src).unwrap();
1121        let CommonsItem::Type(t) = &c.items[0] else {
1122            panic!()
1123        };
1124        assert_eq!(t.trivia.leading, vec![" explain the type".to_string()]);
1125        assert!(t.trivia.trailing.is_none());
1126    }
1127
1128    #[test]
1129    fn trailing_line_comment_attaches_to_prev_decl() {
1130        let src = "commons x {\ntype T = Int where NonNegative  -- trailing note\n}";
1131        let c = parse_str(src).unwrap();
1132        let CommonsItem::Type(t) = &c.items[0] else {
1133            panic!()
1134        };
1135        assert!(t.trivia.leading.is_empty());
1136        assert_eq!(t.trivia.trailing.as_deref(), Some(" trailing note"));
1137    }
1138
1139    #[test]
1140    fn grouped_leading_comments_attach_together() {
1141        let src = "commons x {\n-- one\n-- two\n-- three\ntype T = Int where Positive\n}";
1142        let c = parse_str(src).unwrap();
1143        let CommonsItem::Type(t) = &c.items[0] else {
1144            panic!()
1145        };
1146        assert_eq!(
1147            t.trivia.leading,
1148            vec![" one".to_string(), " two".to_string(), " three".to_string()],
1149        );
1150    }
1151
1152    #[test]
1153    fn comment_with_doc_block_keeps_both() {
1154        // Both `-- intro` and the doc block should attach to the type decl.
1155        let src = "commons x {\n-- intro\n---\ndocs\n---\ntype T = Int where Positive\n}";
1156        let c = parse_str(src).unwrap();
1157        let CommonsItem::Type(t) = &c.items[0] else {
1158            panic!()
1159        };
1160        assert_eq!(t.trivia.leading, vec![" intro".to_string()]);
1161        assert_eq!(t.documentation.as_deref(), Some("docs"));
1162    }
1163
1164    #[test]
1165    fn comment_before_let_statement_attaches() {
1166        let src = "commons x {\nfn f(n: Int) -> Int {\n-- pick a value\nlet y = n + 1\ny\n}\n}";
1167        let c = parse_str(src).unwrap();
1168        let CommonsItem::Fn(f) = &c.items[0] else {
1169            panic!()
1170        };
1171        let Statement::Let(l) = &f.body.statements[0] else {
1172            panic!()
1173        };
1174        assert_eq!(l.trivia.leading, vec![" pick a value".to_string()]);
1175    }
1176
1177    #[test]
1178    fn comment_before_tail_attaches_to_block_tail() {
1179        let src = "commons x {\nfn f(n: Int) -> Int {\nlet y = n + 1\n-- result\ny\n}\n}";
1180        let c = parse_str(src).unwrap();
1181        let CommonsItem::Fn(f) = &c.items[0] else {
1182            panic!()
1183        };
1184        assert_eq!(f.body.tail_leading_comments, vec![" result".to_string()],);
1185    }
1186
1187    /// Drift guard: every alphabetic keyword the lexer declares must be
1188    /// classified by `is_reserved_keyword`, or be one of the *contextual*
1189    /// keywords `expect_ident` deliberately admits as identifiers
1190    /// (`on`/`suite`/`case`). Everything else in this codebase that can
1191    /// drift has a guard; this predicate had silently fallen 17 keywords
1192    /// behind, degrading the reserved-keyword diagnostic to the generic
1193    /// expected-token one.
1194    #[test]
1195    fn is_reserved_keyword_covers_every_lexer_keyword() {
1196        let lexer_src = include_str!("lexer.rs");
1197        let mut words = Vec::new();
1198        for line in lexer_src.lines() {
1199            let t = line.trim();
1200            if let Some(rest) = t.strip_prefix("#[token(\"")
1201                && let Some(word) = rest.split('"').next()
1202                && word.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
1203                && word.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
1204            {
1205                words.push(word.to_string());
1206            }
1207        }
1208        assert!(
1209            words.len() > 30,
1210            "keyword extraction looks broken: only {} words",
1211            words.len()
1212        );
1213        // Contextual keywords double as identifiers (see `expect_ident`).
1214        const CONTEXTUAL: &[&str] = &["on", "suite", "case"];
1215        let mut unclassified = Vec::new();
1216        for word in &words {
1217            let tokens = crate::lexer::tokenize(word).expect("keyword lexes");
1218            let kind = tokens.first().expect("keyword yields a token").kind;
1219            if !is_reserved_keyword(kind) && !CONTEXTUAL.contains(&word.as_str()) {
1220                unclassified.push(word.clone());
1221            }
1222        }
1223        assert!(
1224            unclassified.is_empty(),
1225            "keywords missing from is_reserved_keyword (add them, or document \
1226             them as contextual): {unclassified:?}"
1227        );
1228    }
1229
1230    /// Fuzz-found (#516): a context-only keyword at item position in a
1231    /// commons errors without consuming the token, and the recovery sync
1232    /// stops at exactly that keyword — without a progress guard the item
1233    /// loop re-reported the same error until memory ran out.
1234    #[test]
1235    fn recovery_makes_progress_on_context_only_keyword_in_commons() {
1236        let src = "commons demo\n\ncapability Logger {\n  fn log(m: String) -> Effect[()]\n}\n";
1237        let tokens = crate::lexer::tokenize(src).unwrap();
1238        let (unit, errors) = parse_unit_with_recovery(&tokens, src);
1239        assert!(unit.is_some(), "the commons header still parses");
1240        assert!(
1241            errors
1242                .iter()
1243                .any(|e| e.category == "bynk.capability.outside_context"),
1244            "the misplaced capability is reported: {errors:?}"
1245        );
1246        // Termination is the real assertion (this used to OOM); a bounded,
1247        // non-repeating error list is the observable proxy.
1248        assert!(errors.len() < 10, "recovery repeated itself: {errors:?}");
1249    }
1250
1251    #[test]
1252    fn trailing_file_comment_becomes_unit_trailing() {
1253        // A comment after the last item but before EOF (fragment form)
1254        // becomes the commons body's trailing comments so the formatter
1255        // can preserve it.
1256        let src = "commons x\n\ntype T = Int where Positive\n-- afterword\n";
1257        let c = parse_str(src).unwrap();
1258        assert_eq!(c.trailing_comments, vec![" afterword".to_string()]);
1259    }
1260}