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