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