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    match parse_unit(tokens, source)? {
104        SourceUnit::Commons(c) => Ok(c),
105        SourceUnit::Context(ctx) => Err(vec![
106            CompileError::new(
107                "bynk.parse.unexpected_context",
108                ctx.span,
109                "expected a `commons` declaration but found a `context` declaration",
110            )
111            .with_note(
112                "contexts must be compiled as part of a project — pass the source directory, e.g. `bynkc compile --target bundle --output out src`",
113            ),
114        ]),
115        SourceUnit::Suite(t) => Err(vec![
116            CompileError::new(
117                "bynk.parse.unexpected_suite",
118                t.span,
119                "expected a `commons` declaration but found a `suite` declaration",
120            )
121            .with_note(
122                "tests 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::Adapter(a) => Err(vec![
126            CompileError::new(
127                "bynk.parse.unexpected_adapter",
128                a.span,
129                "expected a `commons` declaration but found an `adapter` declaration",
130            )
131            .with_note(
132                "adapters must be compiled as part of a project — pass the source directory, e.g. `bynkc compile --target bundle --output out src`",
133            ),
134        ]),
135    }
136}
137
138/// Parse a token slice into a [`SourceUnit`] with error recovery, returning a
139/// best-effort partial AST plus the full list of parse errors and warnings.
140///
141/// Used by the LSP: item-level recovery skips past a malformed declaration to
142/// the next top-level item, so multiple errors are reported per compilation
143/// rather than just the first. Compared to [`parse_unit`], this never bails;
144/// if no SourceUnit could be parsed at all (e.g. the file is empty or the
145/// header itself fails) the returned `Option` is `None`.
146pub fn parse_unit_with_recovery(
147    tokens: &[Token],
148    source: &str,
149) -> (Option<SourceUnit>, Vec<CompileError>) {
150    let (filtered, trivia) = split_trivia(tokens, source);
151    let mut warnings = Vec::new();
152    let mut p = Parser::new(&filtered, source, trivia, &mut warnings);
153    p.recover_mode = true;
154    let unit_opt = match p.parse_unit() {
155        Ok(u) => {
156            // v0.113: a file may hold more than one top-level unit (an atomic
157            // `commons` + `suite` file, DECISION S). Consume any further units
158            // so trailing declarations are not mis-reported as stray tokens; the
159            // editor view is keyed on the first (primary) unit. A genuinely
160            // malformed trailing declaration is still surfaced via recovery.
161            while p.peek().is_some() {
162                match p.parse_unit() {
163                    Ok(_) => {}
164                    Err(e) => {
165                        p.recovered_errors.push(e);
166                        break;
167                    }
168                }
169            }
170            Some(u)
171        }
172        Err(e) => {
173            p.recovered_errors.push(e);
174            None
175        }
176    };
177    let mut all_errors = p.recovered_errors;
178    all_errors.append(&mut warnings);
179    (unit_opt, all_errors)
180}
181
182/// Parse a token slice into a [`SourceUnit`] — either a commons or a context.
183///
184/// Each `.bynk` file is exactly one declaration of one kind.
185pub fn parse_unit(tokens: &[Token], source: &str) -> Result<SourceUnit, Vec<CompileError>> {
186    let (filtered, trivia) = split_trivia(tokens, source);
187    let mut warnings = Vec::new();
188    let mut p = Parser::new(&filtered, source, trivia, &mut warnings);
189    let result = match p.parse_unit() {
190        Ok(u) => {
191            if let Some(extra) = p.peek() {
192                Err(vec![
193                    CompileError::new(
194                        "bynk.parse.extra_tokens",
195                        extra.span,
196                        "unexpected token after top-level declaration",
197                    )
198                    .with_note(
199                        "a `.bynk` file contains exactly one `commons` or `context` declaration",
200                    ),
201                ])
202            } else {
203                Ok(u)
204            }
205        }
206        Err(e) => Err(vec![e]),
207    };
208    // Warnings (e.g. orphan doc blocks) are returned as errors in v0.3 — there
209    // is no separate warning channel yet; the test harness matches on category.
210    if !warnings.is_empty() {
211        match result {
212            Ok(_) => return Err(warnings),
213            Err(mut errs) => {
214                errs.append(&mut warnings);
215                return Err(errs);
216            }
217        }
218    }
219    result
220}
221
222/// Parse a token slice into **all** the top-level [`SourceUnit`]s in one file
223/// (v0.113, testing track slice 1b). A `.bynk` file may hold more than one
224/// top-level declaration — an *atomic* file with `commons`/`context` **and** a
225/// `suite` together (DECISION S) — so the compiler parses a `Vec`, not a single
226/// unit. Test-ness is a property of each declaration, not of the file.
227///
228/// Bails on the first malformed declaration (like [`parse_unit`], not the
229/// recovering LSP path). An empty file is an error.
230pub fn parse_units(tokens: &[Token], source: &str) -> Result<Vec<SourceUnit>, Vec<CompileError>> {
231    let (filtered, trivia) = split_trivia(tokens, source);
232    let mut warnings = Vec::new();
233    let mut p = Parser::new(&filtered, source, trivia, &mut warnings);
234    let mut units = Vec::new();
235    let mut errors: Vec<CompileError> = Vec::new();
236    while p.peek().is_some() {
237        match p.parse_unit() {
238            Ok(u) => units.push(u),
239            Err(e) => {
240                errors.push(e);
241                break;
242            }
243        }
244    }
245    let eof = p.eof_span();
246    // `p` (and thus its `&mut warnings` borrow) is no longer used past here, so
247    // the local `warnings` are readable again.
248    errors.append(&mut warnings);
249    if !errors.is_empty() {
250        return Err(errors);
251    }
252    if units.is_empty() {
253        return Err(vec![CompileError::new(
254            "bynk.parse.unexpected_eof",
255            eof,
256            "expected `commons`, `context`, or `suite` to start the file, found end of file",
257        )]);
258    }
259    Ok(units)
260}
261
262/// A signed numeric literal in refinement-bound position (v0.21): `InRange`
263/// bounds are either both `Int` or both `Float`.
264enum SignedNumLit {
265    Int(IntBound),
266    Float(FloatBound),
267}
268
269struct Parser<'a> {
270    tokens: &'a [Token],
271    source: &'a str,
272    pos: usize,
273    /// Accumulated non-fatal diagnostics. v0.3 uses this for orphan-doc
274    /// warnings, which are emitted as errors with a distinguishable category.
275    warnings: &'a mut Vec<CompileError>,
276    /// When true, the item-level loops catch errors from individual item
277    /// parses, push them into `recovered_errors`, and skip forward to the
278    /// next top-level item boundary instead of bailing. Used by the LSP via
279    /// [`parse_unit_with_recovery`]; disabled in the normal `parse` path so
280    /// existing single-error behaviour is preserved.
281    recover_mode: bool,
282    /// Errors collected during recovery-mode parsing. Only populated when
283    /// `recover_mode` is true.
284    recovered_errors: Vec<CompileError>,
285    /// Line-comment trivia separated from the token stream. See
286    /// [`TriviaTable`].
287    trivia: TriviaTable,
288}
289
290impl<'a> Parser<'a> {
291    fn new(
292        tokens: &'a [Token],
293        source: &'a str,
294        trivia: TriviaTable,
295        warnings: &'a mut Vec<CompileError>,
296    ) -> Self {
297        Self {
298            tokens,
299            source,
300            pos: 0,
301            warnings,
302            recover_mode: false,
303            recovered_errors: Vec::new(),
304            trivia,
305        }
306    }
307
308    /// Comments immediately preceding the current peek position. Consumed
309    /// (the table entry is cleared) so the same comments are not attached
310    /// to two nodes.
311    fn take_leading_trivia(&mut self) -> Vec<String> {
312        self.trivia.take_leading(self.pos)
313    }
314
315    /// Trailing comment, if any, on the same source line as the most
316    /// recently consumed content token. Call AFTER finishing a declaration
317    /// or statement, while `self.pos` points one past its last token.
318    fn take_trailing_trivia(&mut self) -> Option<String> {
319        if self.pos == 0 {
320            return None;
321        }
322        self.trivia.take_trailing(self.pos - 1)
323    }
324
325    /// Handle a per-item parse error. In recovery mode, record the error and
326    /// advance to the next sync point so the item loop can continue; otherwise
327    /// propagate as a hard failure.
328    fn handle_item_err(&mut self, e: CompileError) -> Result<(), CompileError> {
329        if self.recover_mode {
330            self.recovered_errors.push(e);
331            self.recover_to_top_item();
332            Ok(())
333        } else {
334            Err(e)
335        }
336    }
337
338    /// Skip forward to the next top-level item boundary: either a top-level
339    /// declaration keyword (`type`, `fn`, `uses`, `consumes`, `exports`,
340    /// `capability`, `provides`, `service`, `agent`), a closing brace, or
341    /// end-of-input. Used only in recovery mode.
342    fn recover_to_top_item(&mut self) {
343        while let Some(t) = self.peek() {
344            match t.kind {
345                TokenKind::Type
346                | TokenKind::Fn
347                | TokenKind::Uses
348                | TokenKind::Consumes
349                | TokenKind::Exports
350                | TokenKind::Capability
351                | TokenKind::Provides
352                | TokenKind::Service
353                | TokenKind::Agent
354                | TokenKind::Suite
355                | TokenKind::Case
356                | TokenKind::RBrace
357                | TokenKind::Commons
358                | TokenKind::Context => return,
359                _ => {
360                    self.bump();
361                }
362            }
363        }
364    }
365
366    fn peek(&self) -> Option<Token> {
367        self.tokens.get(self.pos).copied()
368    }
369
370    fn peek_kind(&self) -> Option<TokenKind> {
371        self.peek().map(|t| t.kind)
372    }
373
374    /// The token `n` positions ahead of the cursor (`nth(0)` == `peek()`).
375    fn nth(&self, n: usize) -> Option<Token> {
376        self.tokens.get(self.pos + n).copied()
377    }
378
379    fn nth_kind(&self, n: usize) -> Option<TokenKind> {
380        self.nth(n).map(|t| t.kind)
381    }
382
383    /// The source text of the token `n` positions ahead, or `""` if none.
384    fn nth_text(&self, n: usize) -> &'a str {
385        self.nth(n).map(|t| self.slice(t.span)).unwrap_or("")
386    }
387
388    /// The span of the most recently consumed token (`self.pos - 1`). Falls back
389    /// to the current token's span when nothing has been consumed yet.
390    fn prev_span(&self) -> Span {
391        self.tokens
392            .get(self.pos.wrapping_sub(1))
393            .or_else(|| self.peek_ref())
394            .map(|t| t.span)
395            .unwrap_or_default()
396    }
397
398    fn peek_ref(&self) -> Option<&Token> {
399        self.tokens.get(self.pos)
400    }
401
402    fn bump(&mut self) -> Option<Token> {
403        let t = self.peek();
404        if t.is_some() {
405            self.pos += 1;
406        }
407        t
408    }
409
410    fn eat(&mut self, kind: TokenKind) -> Option<Token> {
411        if self.peek_kind() == Some(kind) {
412            self.bump()
413        } else {
414            None
415        }
416    }
417
418    fn slice(&self, span: Span) -> &'a str {
419        &self.source[span.range()]
420    }
421
422    /// True when the next token sits on a later line than `prev`. Used to
423    /// keep a `[` that opens a new line out of the postfix type-application
424    /// form: `f` followed by `[1, 2]` on the next line is an identifier and
425    /// a list literal, not `f[…]` (v0.20b).
426    fn next_token_on_new_line(&self, prev: Span) -> bool {
427        match self.peek() {
428            Some(t) if prev.end <= t.span.start => {
429                self.source[prev.end..t.span.start].contains('\n')
430            }
431            _ => false,
432        }
433    }
434
435    /// Span pointing at the end of input — used for "unexpected EOF" reports.
436    fn eof_span(&self) -> Span {
437        let end = self.source.len();
438        Span::new(end.saturating_sub(1), end)
439    }
440
441    fn expect(&mut self, kind: TokenKind, ctx: &str) -> Result<Token, CompileError> {
442        match self.peek() {
443            Some(t) if t.kind == kind => {
444                self.bump();
445                Ok(t)
446            }
447            Some(t) => Err(CompileError::new(
448                "bynk.parse.expected_token",
449                t.span,
450                format!(
451                    "expected {} {ctx}, found {}",
452                    kind.describe(),
453                    t.kind.describe()
454                ),
455            )),
456            None => Err(CompileError::new(
457                "bynk.parse.unexpected_eof",
458                self.eof_span(),
459                format!("expected {} {ctx}, found end of file", kind.describe()),
460            )),
461        }
462    }
463
464    fn expect_ident(&mut self, ctx: &str) -> Result<Ident, CompileError> {
465        match self.peek() {
466            Some(t) if t.kind == TokenKind::Ident => {
467                self.bump();
468                Ok(Ident {
469                    name: self.slice(t.span).to_string(),
470                    span: t.span,
471                })
472            }
473            // v0.5 contextual keyword `on` doubles as an identifier in
474            // expression / field-access positions so users can name fields and
475            // parameters using it. It retains its keyword meaning only at
476            // handler-decl-level (`on call(...)`).
477            //
478            // v0.7 / v0.112: `suite` and `case` are contextual too — they
479            // introduce the suite declaration and its cases, but are perfectly
480            // valid commons/context/field names otherwise.
481            Some(t) if matches!(t.kind, TokenKind::On | TokenKind::Suite | TokenKind::Case) => {
482                self.bump();
483                Ok(Ident {
484                    name: self.slice(t.span).to_string(),
485                    span: t.span,
486                })
487            }
488            Some(t) if is_reserved_keyword(t.kind) => Err(CompileError::new(
489                "bynk.parse.reserved_keyword",
490                t.span,
491                format!(
492                    "expected identifier {ctx}, but `{}` is a reserved keyword",
493                    self.slice(t.span)
494                ),
495            )
496            .with_note("rename the identifier to something that is not a keyword")),
497            Some(t) => Err(CompileError::new(
498                "bynk.parse.expected_token",
499                t.span,
500                format!("expected identifier {ctx}, found {}", t.kind.describe()),
501            )),
502            None => Err(CompileError::new(
503                "bynk.parse.unexpected_eof",
504                self.eof_span(),
505                format!("expected identifier {ctx}, found end of file"),
506            )),
507        }
508    }
509
510    // -- top level --
511
512    /// Consume an optional doc block at the current position, returning the
513    /// (content, end-of-doc span) pair. Returns None if the next token is not
514    /// a doc block.
515    fn take_doc_block(&mut self) -> Option<(String, Span)> {
516        if self.peek_kind() == Some(TokenKind::DocBlock) {
517            let t = self.bump().unwrap();
518            let body = doc_block_content(self.source, t.span);
519            return Some((body, t.span));
520        }
521        None
522    }
523
524    /// Collect all line-comment trivia leading the next declaration plus
525    /// the optional doc block. Comments may appear both *before* and
526    /// *between* the doc and the declaration; the spec canonicalises both
527    /// groups above the doc, so we concatenate them.
528    fn collect_item_lead(&mut self) -> (Vec<String>, Option<(String, Span)>) {
529        let mut leading = self.take_leading_trivia();
530        let doc = self.take_doc_block();
531        if doc.is_some() {
532            leading.extend(self.take_leading_trivia());
533        }
534        (leading, doc)
535    }
536
537    /// Attach a parsed doc block to a following declaration unless a blank
538    /// line separates them, in which case the doc is orphaned (warning).
539    fn finalize_doc(&mut self, doc: Option<(String, Span)>, next_span: Span) -> Option<String> {
540        let (content, doc_span) = doc?;
541        // A blank line between the doc and the next decl orphans the doc.
542        if has_blank_line_between(self.source, doc_span.end, next_span.start) {
543            self.warnings.push(
544                CompileError::new(
545                    "bynk.parse.orphan_doc_block",
546                    doc_span,
547                    "documentation block is separated from the following declaration by a blank line; it will not be attached",
548                )
549                .with_note(
550                    "remove the blank line to attach the doc to the next declaration, \
551                     or remove the doc block if it is not meant to document anything",
552                ),
553            );
554            return None;
555        }
556        Some(content)
557    }
558}
559
560/// Parse the body of a lexed double-quoted string literal (the lexeme,
561/// including surrounding quotes), applying the v0 escape rules.
562fn parse_string_literal(lexeme: &str, span: Span) -> Result<String, CompileError> {
563    let bytes = lexeme.as_bytes();
564    debug_assert!(bytes.first() == Some(&b'"') && bytes.last() == Some(&b'"'));
565    let inner = &lexeme[1..lexeme.len() - 1];
566    let mut out = String::with_capacity(inner.len());
567    let mut chars = inner.chars();
568    while let Some(c) = chars.next() {
569        if c == '\\' {
570            match chars.next() {
571                Some('n') => out.push('\n'),
572                Some('t') => out.push('\t'),
573                Some('"') => out.push('"'),
574                Some('\\') => out.push('\\'),
575                other => {
576                    return Err(CompileError::new(
577                        "bynk.lex.bad_escape",
578                        span,
579                        format!(
580                            "invalid escape sequence `\\{}` in string literal",
581                            other.map(|c| c.to_string()).unwrap_or_default()
582                        ),
583                    )
584                    .with_note("supported escapes: \\n \\t \\\" \\\\"));
585                }
586            }
587        } else {
588            out.push(c);
589        }
590    }
591    Ok(out)
592}
593
594fn is_reserved_keyword(kind: TokenKind) -> bool {
595    use TokenKind::*;
596    matches!(
597        kind,
598        Commons
599            | Type
600            | Fn
601            | Where
602            | And
603            | True
604            | False
605            | Int
606            | String
607            | Bool
608            | Let
609            | If
610            | Else
611            | Ok
612            | Err
613            | Result
614            | ValidationError
615            | Enum
616            | Match
617            | Option
618            | Record
619            | Self_
620            | Some
621            | None
622            | Is
623            | Opaque
624            | Uses
625            | Context
626            | Consumes
627            | Exports
628            | Transparent
629            | Agent
630            | As
631            | Capability
632            | Effect
633            | Given
634            | On
635            | Http
636            | Provides
637            | Service
638            | Actor
639            | By
640            | Expect
641            | Suite
642            | Case
643    )
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649    use crate::lexer::tokenize;
650
651    fn parse_str(src: &str) -> Result<Commons, Vec<CompileError>> {
652        let toks = tokenize(src).map_err(|e| vec![e])?;
653        parse(&toks, src)
654    }
655
656    fn parse_recover_str(src: &str) -> (Option<SourceUnit>, Vec<CompileError>) {
657        let toks = match tokenize(src) {
658            Ok(t) => t,
659            Err(e) => return (None, vec![e]),
660        };
661        parse_unit_with_recovery(&toks, src)
662    }
663
664    #[test]
665    fn recovery_skips_garbage_between_decls() {
666        // Two `type` declarations separated by garbage. Recovery should
667        // accept both and report one error for the garbage between them.
668        let src = "commons x {\n\
669                   type A = Int where NonNegative\n\
670                   ??? !!!\n\
671                   type B = String where NonEmpty\n\
672                   }";
673        let (unit, errors) = parse_recover_str(src);
674        let unit = unit.expect("recovery should produce a partial AST");
675        let SourceUnit::Commons(c) = unit else {
676            panic!("expected commons")
677        };
678        // Both type decls should have been collected despite the garbage.
679        let names: Vec<_> = c
680            .items
681            .iter()
682            .map(|i| match i {
683                CommonsItem::Type(t) => t.name.name.clone(),
684                _ => panic!("expected only types"),
685            })
686            .collect();
687        assert!(
688            names.contains(&"A".to_string()) && names.contains(&"B".to_string()),
689            "expected both A and B; got {names:?}",
690        );
691        assert!(!errors.is_empty(), "expected at least one parse error");
692    }
693
694    #[test]
695    fn recovery_handles_bad_first_decl_then_good_second() {
696        // First decl is malformed (missing `=`); second is well-formed.
697        let src = "commons x {\n\
698                   type A Int where NonNegative\n\
699                   type B = String where NonEmpty\n\
700                   }";
701        let (unit, errors) = parse_recover_str(src);
702        let unit = unit.expect("recovery should produce a partial AST");
703        let SourceUnit::Commons(c) = unit else {
704            panic!("expected commons")
705        };
706        let names: Vec<_> = c
707            .items
708            .iter()
709            .filter_map(|i| match i {
710                CommonsItem::Type(t) => Some(t.name.name.clone()),
711                _ => None,
712            })
713            .collect();
714        assert!(
715            names.contains(&"B".to_string()),
716            "B should be parsed after A's failure; got {names:?}"
717        );
718        assert!(!errors.is_empty(), "expected at least one parse error");
719    }
720
721    #[test]
722    fn doc_block_attaches_to_type() {
723        let c =
724            parse_str("commons x {\n---\nA descriptive doc.\n---\ntype T = Int where Positive\n}")
725                .unwrap();
726        let CommonsItem::Type(t) = &c.items[0] else {
727            panic!()
728        };
729        assert!(t.documentation.is_some());
730        assert!(
731            t.documentation
732                .as_ref()
733                .unwrap()
734                .contains("A descriptive doc.")
735        );
736    }
737
738    #[test]
739    fn interpolated_string_parses_into_parts() {
740        // v0.43: `"Hi, \(name)!"` splits into chunk / hole / chunk.
741        let c = parse_str("commons x\n\nfn f(name: String) -> String {\n  \"Hi, \\(name)!\"\n}\n")
742            .unwrap();
743        let CommonsItem::Fn(f) = &c.items[0] else {
744            panic!("expected fn")
745        };
746        let ExprKind::InterpStr(parts) = &f.body.tail.kind else {
747            panic!("expected InterpStr, got {:?}", f.body.tail.kind)
748        };
749        assert_eq!(parts.len(), 3);
750        assert!(matches!(&parts[0], InterpPart::Chunk(s) if s == "Hi, "));
751        assert!(
752            matches!(&parts[1], InterpPart::Hole(h) if matches!(&h.kind, ExprKind::Ident(id) if id.name == "name"))
753        );
754        assert!(matches!(&parts[2], InterpPart::Chunk(s) if s == "!"));
755    }
756
757    #[test]
758    fn interpolated_hole_parses_a_full_expression() {
759        // A hole holds an arbitrary expression, not just an identifier.
760        let c =
761            parse_str("commons x\n\nfn f(a: Int, b: Int) -> String {\n  \"sum = \\(a + b)\"\n}\n")
762                .unwrap();
763        let CommonsItem::Fn(f) = &c.items[0] else {
764            panic!("expected fn")
765        };
766        let ExprKind::InterpStr(parts) = &f.body.tail.kind else {
767            panic!("expected InterpStr")
768        };
769        assert!(matches!(&parts[1], InterpPart::Hole(h) if matches!(&h.kind, ExprKind::BinOp(..))));
770    }
771
772    #[test]
773    fn empty_interpolation_hole_is_rejected() {
774        let errs = parse_str("commons x\n\nfn f() -> String {\n  \"\\()\"\n}\n").unwrap_err();
775        assert!(
776            errs.iter()
777                .any(|e| e.category == "bynk.parse.empty_interpolation"),
778            "expected empty_interpolation; got {errs:?}"
779        );
780    }
781
782    #[test]
783    fn fragment_form_parses() {
784        let c = parse_str("commons x.y\n\ntype T = Int where NonNegative\n").unwrap();
785        assert_eq!(c.form, CommonsForm::Fragment);
786        assert_eq!(c.items.len(), 1);
787    }
788
789    #[test]
790    fn uses_parses() {
791        let c = parse_str("commons x\n\nuses other.lib\n").unwrap();
792        assert_eq!(c.uses.len(), 1);
793        assert_eq!(c.uses[0].target.joined(), "other.lib");
794    }
795
796    fn parse_unit_str(src: &str) -> Result<SourceUnit, Vec<CompileError>> {
797        let toks = tokenize(src).map_err(|e| vec![e])?;
798        parse_unit(&toks, src)
799    }
800
801    #[test]
802    fn minimal_context_parses() {
803        let u = parse_unit_str("context commerce.orders {}").unwrap();
804        let SourceUnit::Context(c) = u else {
805            panic!("expected context");
806        };
807        assert_eq!(c.name.joined(), "commerce.orders");
808        assert!(c.items.is_empty());
809    }
810
811    #[test]
812    fn context_consumes_and_exports_parse() {
813        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}";
814        let u = parse_unit_str(src).unwrap();
815        let SourceUnit::Context(c) = u else { panic!() };
816        assert_eq!(c.uses.len(), 1);
817        assert_eq!(c.consumes.len(), 1);
818        assert_eq!(c.exports.len(), 2);
819        assert_eq!(c.exports[0].kind, ExportKind::Type(Visibility::Opaque));
820        assert_eq!(c.exports[1].kind, ExportKind::Type(Visibility::Transparent));
821    }
822
823    #[test]
824    fn context_fragment_form_parses() {
825        let src = "context x.y\n\nuses other.lib\nconsumes other.ctx\nexports opaque { T }\n\ntype T = Int where NonNegative\n";
826        let u = parse_unit_str(src).unwrap();
827        let SourceUnit::Context(c) = u else { panic!() };
828        assert_eq!(c.form, CommonsForm::Fragment);
829        assert_eq!(c.uses.len(), 1);
830        assert_eq!(c.consumes.len(), 1);
831        assert_eq!(c.exports.len(), 1);
832    }
833
834    #[test]
835    fn opaque_type_parses() {
836        let c = parse_str("commons x { type T = opaque Int where NonNegative }").unwrap();
837        let CommonsItem::Type(t) = &c.items[0] else {
838            panic!()
839        };
840        assert!(matches!(t.body, TypeBody::Opaque { .. }));
841    }
842
843    #[test]
844    fn empty_commons() {
845        let c = parse_str("commons fitness.units {}").unwrap();
846        assert_eq!(c.name.joined(), "fitness.units");
847        assert!(c.items.is_empty());
848    }
849
850    #[test]
851    fn one_type_decl() {
852        let c = parse_str("commons x { type Metres = Int where NonNegative }").unwrap();
853        assert_eq!(c.items.len(), 1);
854        let CommonsItem::Type(t) = &c.items[0] else {
855            panic!()
856        };
857        assert_eq!(t.name.name, "Metres");
858        match &t.body {
859            TypeBody::Refined {
860                base, refinement, ..
861            } => {
862                assert_eq!(*base, BaseType::Int);
863                assert!(refinement.is_some());
864            }
865            _ => panic!("expected refined body"),
866        }
867    }
868
869    #[test]
870    fn function_decl() {
871        let c = parse_str("commons x { fn add(a: Int, b: Int) -> Int { a + b } }").unwrap();
872        let CommonsItem::Fn(f) = &c.items[0] else {
873            panic!()
874        };
875        assert_eq!(f.name.ident().name, "add");
876        assert_eq!(f.params.len(), 2);
877    }
878
879    #[test]
880    fn chained_comparison_is_error() {
881        let errs = parse_str("commons x { fn f(a: Int, b: Int, c: Int) -> Bool { a < b < c } }")
882            .unwrap_err();
883        assert_eq!(errs[0].category, "bynk.parse.non_associative");
884    }
885
886    #[test]
887    fn chained_equality_is_error() {
888        let errs = parse_str("commons x { fn f(a: Int, b: Int, c: Int) -> Bool { a == b == c } }")
889            .unwrap_err();
890        assert_eq!(errs[0].category, "bynk.parse.non_associative");
891    }
892
893    #[test]
894    fn let_statement_parses() {
895        let c = parse_str("commons x { fn f(n: Int) -> Int { let y = n + 1\n y } }").unwrap();
896        let CommonsItem::Fn(f) = &c.items[0] else {
897            panic!()
898        };
899        assert_eq!(f.body.statements.len(), 1);
900        match &f.body.statements[0] {
901            Statement::Let(l) => {
902                assert_eq!(l.name.name, "y");
903                assert!(l.type_annot.is_none());
904            }
905            _ => panic!("expected a pure `let` statement"),
906        }
907    }
908
909    #[test]
910    fn let_with_annotation() {
911        let c = parse_str("commons x { fn f(n: Int) -> Int { let y: Int = n\n y } }").unwrap();
912        let CommonsItem::Fn(f) = &c.items[0] else {
913            panic!()
914        };
915        match &f.body.statements[0] {
916            Statement::Let(l) => assert!(l.type_annot.is_some()),
917            _ => panic!("expected a pure `let` statement"),
918        }
919    }
920
921    #[test]
922    fn if_else_parses_as_expression() {
923        let c = parse_str("commons x { fn f(b: Bool) -> Int { if b { 1 } else { 0 } } }").unwrap();
924        let CommonsItem::Fn(f) = &c.items[0] else {
925            panic!()
926        };
927        assert!(matches!(f.body.tail.kind, ExprKind::If { .. }));
928    }
929
930    #[test]
931    fn else_if_chain_parses() {
932        let c = parse_str(
933            "commons x { fn f(n: Int) -> Int { if n < 0 { -1 } else if n == 0 { 0 } else { 1 } } }",
934        )
935        .unwrap();
936        let CommonsItem::Fn(f) = &c.items[0] else {
937            panic!()
938        };
939        let ExprKind::If { else_block, .. } = &f.body.tail.kind else {
940            panic!()
941        };
942        // The else-branch is a block whose tail is another `If`.
943        assert!(else_block.statements.is_empty());
944        assert!(matches!(else_block.tail.kind, ExprKind::If { .. }));
945    }
946
947    #[test]
948    fn ok_and_err_parse_as_expressions() {
949        let c = parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Ok(n) } }").unwrap();
950        let CommonsItem::Fn(f) = &c.items[0] else {
951            panic!()
952        };
953        assert!(matches!(f.body.tail.kind, ExprKind::Ok(_)));
954
955        let c =
956            parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Err(\"x\") } }").unwrap();
957        let CommonsItem::Fn(f) = &c.items[0] else {
958            panic!()
959        };
960        assert!(matches!(f.body.tail.kind, ExprKind::Err(_)));
961    }
962
963    #[test]
964    fn question_postfix_parses() {
965        let c = parse_str(
966            "commons x { type T = Int where Positive\n fn f(n: Int) -> Result[T, ValidationError] { let x = T.of(n)?\n Ok(x) } }",
967        )
968        .unwrap();
969        let CommonsItem::Fn(f) = &c.items[1] else {
970            panic!()
971        };
972        let Statement::Let(l) = &f.body.statements[0] else {
973            panic!("expected a pure `let` statement");
974        };
975        assert!(matches!(l.value.kind, ExprKind::Question(_)));
976    }
977
978    #[test]
979    fn constructor_call_parses() {
980        let c = parse_str(
981            "commons x { type T = Int where Positive\n fn f(n: Int) -> Result[T, ValidationError] { T.of(n) } }",
982        )
983        .unwrap();
984        let CommonsItem::Fn(f) = &c.items[1] else {
985            panic!()
986        };
987        // v0.2: T.of(n) parses as a MethodCall with receiver Ident("T"); the
988        // checker reinterprets it as a static call by noticing T is a type.
989        let ExprKind::MethodCall {
990            receiver, method, ..
991        } = &f.body.tail.kind
992        else {
993            panic!("expected MethodCall, got {:?}", f.body.tail.kind)
994        };
995        let ExprKind::Ident(id) = &receiver.kind else {
996            panic!("expected receiver Ident");
997        };
998        assert_eq!(id.name, "T");
999        assert_eq!(method.name, "of");
1000    }
1001
1002    #[test]
1003    fn result_type_ref_parses() {
1004        let c = parse_str("commons x { fn f(n: Int) -> Result[Int, String] { Ok(n) } }").unwrap();
1005        let CommonsItem::Fn(f) = &c.items[0] else {
1006            panic!()
1007        };
1008        assert!(matches!(f.return_type, TypeRef::Result(_, _, _)));
1009    }
1010
1011    #[test]
1012    fn result_missing_arg_count_errors() {
1013        let errs = parse_str("commons x { fn f(n: Int) -> Result[Int] { Ok(n) } }").unwrap_err();
1014        assert_eq!(errs[0].category, "bynk.parse.generic_arg_count");
1015    }
1016
1017    #[test]
1018    fn field_access_parses_in_v0_2() {
1019        // v0.2: field access is supported (the type checker validates the
1020        // field exists on the receiver's type). Parser-level acceptance:
1021        let c =
1022            parse_str("commons x { type R = { foo: Int }\n fn f(r: R) -> Int { r.foo } }").unwrap();
1023        let CommonsItem::Fn(f) = &c.items[1] else {
1024            panic!()
1025        };
1026        assert!(matches!(f.body.tail.kind, ExprKind::FieldAccess { .. }));
1027    }
1028
1029    // -- v1.1 trivia attachment --
1030
1031    #[test]
1032    fn leading_line_comment_attaches_to_next_decl() {
1033        let src = "commons x {\n-- explain the type\ntype T = Int where NonNegative\n}";
1034        let c = parse_str(src).unwrap();
1035        let CommonsItem::Type(t) = &c.items[0] else {
1036            panic!()
1037        };
1038        assert_eq!(t.trivia.leading, vec![" explain the type".to_string()]);
1039        assert!(t.trivia.trailing.is_none());
1040    }
1041
1042    #[test]
1043    fn trailing_line_comment_attaches_to_prev_decl() {
1044        let src = "commons x {\ntype T = Int where NonNegative  -- trailing note\n}";
1045        let c = parse_str(src).unwrap();
1046        let CommonsItem::Type(t) = &c.items[0] else {
1047            panic!()
1048        };
1049        assert!(t.trivia.leading.is_empty());
1050        assert_eq!(t.trivia.trailing.as_deref(), Some(" trailing note"));
1051    }
1052
1053    #[test]
1054    fn grouped_leading_comments_attach_together() {
1055        let src = "commons x {\n-- one\n-- two\n-- three\ntype T = Int where Positive\n}";
1056        let c = parse_str(src).unwrap();
1057        let CommonsItem::Type(t) = &c.items[0] else {
1058            panic!()
1059        };
1060        assert_eq!(
1061            t.trivia.leading,
1062            vec![" one".to_string(), " two".to_string(), " three".to_string()],
1063        );
1064    }
1065
1066    #[test]
1067    fn comment_with_doc_block_keeps_both() {
1068        // Both `-- intro` and the doc block should attach to the type decl.
1069        let src = "commons x {\n-- intro\n---\ndocs\n---\ntype T = Int where Positive\n}";
1070        let c = parse_str(src).unwrap();
1071        let CommonsItem::Type(t) = &c.items[0] else {
1072            panic!()
1073        };
1074        assert_eq!(t.trivia.leading, vec![" intro".to_string()]);
1075        assert_eq!(t.documentation.as_deref(), Some("docs"));
1076    }
1077
1078    #[test]
1079    fn comment_before_let_statement_attaches() {
1080        let src = "commons x {\nfn f(n: Int) -> Int {\n-- pick a value\nlet y = n + 1\ny\n}\n}";
1081        let c = parse_str(src).unwrap();
1082        let CommonsItem::Fn(f) = &c.items[0] else {
1083            panic!()
1084        };
1085        let Statement::Let(l) = &f.body.statements[0] else {
1086            panic!()
1087        };
1088        assert_eq!(l.trivia.leading, vec![" pick a value".to_string()]);
1089    }
1090
1091    #[test]
1092    fn comment_before_tail_attaches_to_block_tail() {
1093        let src = "commons x {\nfn f(n: Int) -> Int {\nlet y = n + 1\n-- result\ny\n}\n}";
1094        let c = parse_str(src).unwrap();
1095        let CommonsItem::Fn(f) = &c.items[0] else {
1096            panic!()
1097        };
1098        assert_eq!(f.body.tail_leading_comments, vec![" result".to_string()],);
1099    }
1100
1101    #[test]
1102    fn trailing_file_comment_becomes_unit_trailing() {
1103        // A comment after the last item but before EOF (fragment form)
1104        // becomes the commons body's trailing comments so the formatter
1105        // can preserve it.
1106        let src = "commons x\n\ntype T = Int where Positive\n-- afterword\n";
1107        let c = parse_str(src).unwrap();
1108        assert_eq!(c.trailing_comments, vec![" afterword".to_string()]);
1109    }
1110}