Skip to main content

bynk_syntax/
lexer.rs

1//! Lexer for Bynk v0.
2//!
3//! Token kinds correspond to the terminals defined in the grammar (spec §3
4//! and §4). Whitespace is skipped; line comments are emitted as `Comment`
5//! tokens so the formatter can preserve them through round-trips (v1.1 LSP
6//! spec §3.5). Doc blocks (`---`) are emitted as `DocBlock` tokens, lexed
7//! outside of logos (see [`tokenize`]).
8
9use logos::Logos;
10
11use crate::error::CompileError;
12use crate::span::Span;
13
14/// v0.142 (ADR 0166): strip `_` digit separators from a numeric literal's lexeme
15/// before it is parsed into a value. The lexer's `IntLit`/`FloatLit` regexes only
16/// admit an `_` between two digit groups, so removing every `_` yields a plain
17/// digit string; the separators are purely visual. Allocates only when the
18/// literal actually carries a separator (the common case does not).
19pub(crate) fn strip_digit_separators(lexeme: &str) -> std::borrow::Cow<'_, str> {
20    if lexeme.as_bytes().contains(&b'_') {
21        std::borrow::Cow::Owned(lexeme.replace('_', ""))
22    } else {
23        std::borrow::Cow::Borrowed(lexeme)
24    }
25}
26
27/// Token kinds. Discriminants without payload data; the lexeme is recovered
28/// from the source string via the token's [`Span`].
29///
30/// Note: `--` line comments and `---` doc block markers are handled outside
31/// logos (see [`tokenize`]), because doc blocks are delimited by `---` lines
32/// containing only the marker and may span multiple source lines.
33#[derive(Logos, Debug, Clone, Copy, PartialEq, Eq)]
34#[logos(skip r"[ \t\r\n]+")]
35pub enum TokenKind {
36    // Keywords
37    #[token("commons")]
38    Commons,
39    #[token("type")]
40    Type,
41    #[token("fn")]
42    Fn,
43    #[token("where")]
44    Where,
45    #[token("and")]
46    And,
47    #[token("true")]
48    True,
49    #[token("false")]
50    False,
51    #[token("Int")]
52    Int,
53    #[token("String")]
54    String,
55    #[token("Bool")]
56    Bool,
57    // v0.21 keyword
58    #[token("Float")]
59    Float,
60    // v0.86 keyword (ADR 0112): the `Duration` base type.
61    #[token("Duration")]
62    Duration,
63    // v0.90 keyword (ADR 0114): the `Instant` base type.
64    #[token("Instant")]
65    Instant,
66    // v0.110 keyword (ADR 0142): the `Bytes` base type.
67    #[token("Bytes")]
68    Bytes,
69    // v0.1 keywords
70    #[token("let")]
71    Let,
72    #[token("if")]
73    If,
74    #[token("else")]
75    Else,
76    #[token("Ok")]
77    Ok,
78    #[token("Err")]
79    Err,
80    #[token("Result")]
81    Result,
82    #[token("ValidationError")]
83    ValidationError,
84    // v0.22b keyword
85    #[token("JsonError")]
86    JsonError,
87    // v0.2 keywords
88    #[token("enum")]
89    Enum,
90    #[token("match")]
91    Match,
92    #[token("Option")]
93    Option,
94    #[token("record")]
95    Record,
96    #[token("self")]
97    Self_,
98    #[token("Some")]
99    Some,
100    #[token("None")]
101    None,
102    #[token("is")]
103    Is,
104    // v0.3 keywords
105    #[token("opaque")]
106    Opaque,
107    #[token("uses")]
108    Uses,
109    // v0.4 keywords
110    #[token("context")]
111    Context,
112    #[token("consumes")]
113    Consumes,
114    #[token("exports")]
115    Exports,
116    #[token("transparent")]
117    Transparent,
118    // v0.6 keywords
119    #[token("as")]
120    As,
121    // v0.7 keywords (v0.112: `assert`→`expect`, `test`→`suite`/`case`;
122    // v0.118: `mocks` retired — test doubles are `provides` at a seam)
123    #[token("expect")]
124    Expect,
125    #[token("suite")]
126    Suite,
127    #[token("case")]
128    Case,
129    // v0.114 keyword — generative tests (testing track slice 2). `for` and `all`
130    // are deliberately *not* keywords: `all` is a list combinator (`all(xs, p)`)
131    // and must stay a usable identifier. The `for all` binder is parsed
132    // contextually (two identifiers) inside a `property` body instead.
133    #[token("property")]
134    Property,
135    // v0.17 keywords
136    #[token("adapter")]
137    Adapter,
138    #[token("binding")]
139    Binding,
140    // v0.5 keywords
141    #[token("agent")]
142    Agent,
143    #[token("capability")]
144    Capability,
145    #[token("Effect")]
146    Effect,
147    // v0.146 keyword (ADR 0170): `do e` — an effect-performing expression
148    // statement (the binder-free `let _ <- e` for a unit effect).
149    #[token("do")]
150    Do,
151    #[token("given")]
152    Given,
153    #[token("on")]
154    On,
155    // v0.9 keyword
156    #[token("http")]
157    Http,
158    // v0.10a keyword
159    #[token("cron")]
160    Cron,
161    // v0.10b keyword
162    #[token("queue")]
163    Queue,
164    // v0.44 keywords: `from` heads a service's protocol clause; `protocol` is
165    // reserved (protocols are a closed, compiler-known set — no declaration kind).
166    #[token("from")]
167    From,
168    #[token("protocol")]
169    Protocol,
170    #[token("provides")]
171    Provides,
172    #[token("service")]
173    Service,
174    // v0.45 keywords: `actor` heads a boundary-contract declaration; `by`
175    // heads a handler's actor clause.
176    #[token("actor")]
177    Actor,
178    #[token("by")]
179    By,
180    // v0.80 keywords: `invariant` heads an agent invariant declaration; `implies`
181    // is the directional logical-implication operator (`P implies Q` ≡ `!P || Q`).
182    #[token("invariant")]
183    Invariant,
184    #[token("implies")]
185    Implies,
186    // v0.115 keywords — function contracts (testing track slice 3). `requires`
187    // and `ensures` head a contract clause on a `fn` signature (between the
188    // return type and the body). `result` is deliberately *not* a keyword: it is
189    // the ordinary value name outside a contract, so it stays a usable
190    // identifier; inside an `ensures` predicate it is bound contextually as the
191    // function's return value (parsed by scope, like `for`/`all` in slice 2).
192    // Distinct from ADR 0127's capability `@requires` annotation.
193    #[token("requires")]
194    Requires,
195    #[token("ensures")]
196    Ensures,
197    // v0.116 keyword — step invariants (testing track slice 4). `transition` heads
198    // an agent step-invariant declaration (beside `invariant`), a predicate over
199    // the pre- and post-commit state pair. `old` and `new` are deliberately *not*
200    // keywords: they stay ordinary value names outside a `transition`, and inside a
201    // `transition` predicate they are bound contextually to the old/new state
202    // records (parsed by scope, like `result` in an `ensures`).
203    #[token("transition")]
204    Transition,
205    /// `...` — used in record-spread expressions (v0.5).
206    #[token("...")]
207    DotDotDot,
208    /// `<-` — Effect bind operator (v0.5).
209    #[token("<-")]
210    LArrow,
211    /// `~>` — asynchronous fire-and-forget send marker (v0.79). A leading
212    /// statement marker, never on the RHS of a `let`; distinct from `<-` so the
213    /// call site shows whether the caller waits.
214    #[token("~>")]
215    TildeArrow,
216    /// `:=` — Cell write (v0.81, storage track). A handler statement
217    /// `cell := expr`; distinct from `=` (binding) and `:` (annotation). Longer
218    /// than `:`/`=` so logos matches it as one token.
219    #[token(":=")]
220    ColonEq,
221
222    /// A documentation block: `---` line ... `---` line. The token's span
223    /// covers the full block including both `---` markers. The body content
224    /// is recovered from the source via the span (see [`doc_block_content`]).
225    /// Inserted by [`tokenize`]; not lexed by logos directly.
226    DocBlock,
227
228    /// A line comment: `-- ...` running to end of line. The span starts at
229    /// the `--` marker and runs through the last character before the
230    /// terminating newline (exclusive). The trivia body (the text after the
231    /// `--` marker) is recovered from the source via the span. Inserted by
232    /// [`tokenize`]; not lexed by logos directly so it cannot be mistaken
233    /// for an `--` operator sequence.
234    Comment,
235
236    // Identifier
237    #[regex(r"[A-Za-z][A-Za-z0-9_]*")]
238    Ident,
239
240    // Literals. v0.142 (ADR 0166): an `_` digit separator may appear between
241    // digits (`1_048_576`) — never leading, trailing, or doubled (each `_` must
242    // sit between two digit groups). The separators are stripped before the value
243    // is parsed; they are purely visual.
244    #[regex(r"[0-9]+(_[0-9]+)*")]
245    IntLit,
246    // A float literal: fraction with a digit on both sides of the `.`, an
247    // exponent, or both (v0.21 §3). `1.` and `.5` are NOT float literals —
248    // the digit-both-sides rule keeps `2.5.round()` / `1.toFloat()` lexing
249    // as method calls on numeric literals. Digit separators (v0.142) may appear
250    // in any digit group, including the exponent.
251    #[regex(
252        r"[0-9]+(_[0-9]+)*\.[0-9]+(_[0-9]+)*([eE][+-]?[0-9]+(_[0-9]+)*)?|[0-9]+(_[0-9]+)*[eE][+-]?[0-9]+(_[0-9]+)*"
253    )]
254    FloatLit,
255    // A double-quoted string with simple escapes. The body excludes the closing
256    // quote; we accept any non-quote/non-backslash/non-newline char, or a
257    // backslash followed by one of the four allowed escapes.
258    #[regex(r#""([^"\\\n]|\\[nt"\\])*""#)]
259    StrLit,
260    // An interpolated string `"… \(expr) …"` (v0.43). Hand-scanned in
261    // `tokenize` (logos cannot balance the holes' parens), never produced by
262    // the logos lexer — like [`TokenKind::DocBlock`]/[`TokenKind::Comment`].
263    // The span covers the whole `"…"`; the parser splits chunks from holes.
264    InterpStr,
265
266    // Multi-char operators
267    #[token("->")]
268    Arrow,
269    #[token("==")]
270    EqEq,
271    #[token("!=")]
272    BangEq,
273    #[token("<=")]
274    LtEq,
275    #[token(">=")]
276    GtEq,
277    #[token("&&")]
278    AmpAmp,
279    #[token("||")]
280    PipePipe,
281
282    // Single-char operators
283    #[token("+")]
284    Plus,
285    #[token("-")]
286    Minus,
287    #[token("*")]
288    Star,
289    #[token("/")]
290    Slash,
291    #[token("!")]
292    Bang,
293    #[token("=")]
294    Eq,
295    #[token("<")]
296    Lt,
297    #[token(">")]
298    Gt,
299    // v0.1 postfix operator
300    #[token("?")]
301    Question,
302    // v0.2 match-arm arrow
303    #[token("=>")]
304    FatArrow,
305    // v0.2 wildcard pattern (also valid as identifier start; the lexer
306    // prefers identifier for any longer match, so `_foo` is still Ident).
307    #[token("_")]
308    Underscore,
309    // v0.2 sum-type variant separator (also used as future bitwise OR);
310    // single `|` distinct from `||`.
311    #[token("|")]
312    Pipe,
313    /// `@` — storage-annotation marker (v0.85, storage track; ADR 0111). Leads a
314    /// `@name(args)` annotation on a `store` field (`@ttl(…)`/`@indexed(…)`); it
315    /// appears only in store-field-declaration position, never as an expression
316    /// operator.
317    #[token("@")]
318    At,
319
320    // Punctuation
321    #[token("(")]
322    LParen,
323    #[token(")")]
324    RParen,
325    #[token("{")]
326    LBrace,
327    #[token("}")]
328    RBrace,
329    #[token("[")]
330    LBracket,
331    #[token("]")]
332    RBracket,
333    #[token(",")]
334    Comma,
335    #[token(":")]
336    Colon,
337    #[token(".")]
338    Dot,
339}
340
341impl TokenKind {
342    /// Human-readable display name for diagnostics.
343    pub fn describe(self) -> &'static str {
344        use TokenKind::*;
345        match self {
346            Commons => "`commons`",
347            Type => "`type`",
348            Fn => "`fn`",
349            Where => "`where`",
350            And => "`and`",
351            True => "`true`",
352            False => "`false`",
353            Int => "`Int`",
354            String => "`String`",
355            Bool => "`Bool`",
356            Float => "`Float`",
357            Duration => "`Duration`",
358            Instant => "`Instant`",
359            Bytes => "`Bytes`",
360            Let => "`let`",
361            If => "`if`",
362            Else => "`else`",
363            Ok => "`Ok`",
364            Err => "`Err`",
365            Result => "`Result`",
366            ValidationError => "`ValidationError`",
367            JsonError => "`JsonError`",
368            Enum => "`enum`",
369            Match => "`match`",
370            Option => "`Option`",
371            Record => "`record`",
372            Self_ => "`self`",
373            Some => "`Some`",
374            None => "`None`",
375            Is => "`is`",
376            Opaque => "`opaque`",
377            Uses => "`uses`",
378            Context => "`context`",
379            Consumes => "`consumes`",
380            Exports => "`exports`",
381            Transparent => "`transparent`",
382            As => "`as`",
383            Expect => "`expect`",
384            Suite => "`suite`",
385            Case => "`case`",
386            Property => "`property`",
387            Adapter => "`adapter`",
388            Binding => "`binding`",
389            Agent => "`agent`",
390            Capability => "`capability`",
391            Effect => "`Effect`",
392            Do => "`do`",
393            Given => "`given`",
394            On => "`on`",
395            Http => "`http`",
396            Cron => "`cron`",
397            Queue => "`queue`",
398            From => "`from`",
399            Protocol => "`protocol`",
400            Provides => "`provides`",
401            Service => "`service`",
402            Actor => "`actor`",
403            By => "`by`",
404            Invariant => "`invariant`",
405            Implies => "`implies`",
406            Requires => "`requires`",
407            Ensures => "`ensures`",
408            Transition => "`transition`",
409            ColonEq => "`:=`",
410            DotDotDot => "`...`",
411            LArrow => "`<-`",
412            TildeArrow => "`~>`",
413            DocBlock => "documentation block",
414            Comment => "line comment",
415            Ident => "identifier",
416            IntLit => "integer literal",
417            FloatLit => "float literal",
418            StrLit => "string literal",
419            InterpStr => "interpolated string",
420            Arrow => "`->`",
421            EqEq => "`==`",
422            BangEq => "`!=`",
423            LtEq => "`<=`",
424            GtEq => "`>=`",
425            AmpAmp => "`&&`",
426            PipePipe => "`||`",
427            Plus => "`+`",
428            Minus => "`-`",
429            Star => "`*`",
430            Slash => "`/`",
431            Bang => "`!`",
432            Eq => "`=`",
433            Lt => "`<`",
434            Gt => "`>`",
435            Question => "`?`",
436            FatArrow => "`=>`",
437            Underscore => "`_`",
438            Pipe => "`|`",
439            At => "`@`",
440            LParen => "`(`",
441            RParen => "`)`",
442            LBrace => "`{`",
443            RBrace => "`}`",
444            LBracket => "`[`",
445            RBracket => "`]`",
446            Comma => "`,`",
447            Colon => "`:`",
448            Dot => "`.`",
449        }
450    }
451}
452
453/// A token plus its source span.
454#[derive(Debug, Clone, Copy)]
455pub struct Token {
456    pub kind: TokenKind,
457    pub span: Span,
458}
459
460/// Tokenise a source string. Returns the full token vector or the first
461/// lexical error.
462///
463/// Doc blocks (`---` ... `---`) and line comments (`-- ...`) are recognised
464/// outside the logos-generated lexer: we scan the source one segment at a
465/// time, dispatching to logos for ordinary tokens between non-token spans.
466pub fn tokenize(source: &str) -> Result<Vec<Token>, CompileError> {
467    let mut tokens = Vec::new();
468    let bytes = source.as_bytes();
469    let mut pos = 0;
470    while pos < bytes.len() {
471        // Detect a `---` doc-block marker at the start of a line (the line may
472        // begin with leading whitespace; the marker itself must be alone on
473        // its line).
474        if let Some(open_end) = doc_block_open_at(source, pos) {
475            // Find the matching closing `---` line.
476            match doc_block_close(source, open_end) {
477                Some((close_start, close_end)) => {
478                    let span = Span::new(pos, close_end);
479                    tokens.push(Token {
480                        kind: TokenKind::DocBlock,
481                        span,
482                    });
483                    let _ = close_start;
484                    pos = close_end;
485                    continue;
486                }
487                None => {
488                    return Err(CompileError::new(
489                        "bynk.lex.unclosed_doc_block",
490                        Span::new(pos, open_end),
491                        "documentation block opened but never closed",
492                    )
493                    .with_note(
494                        "a doc block must be terminated by another `---` on a line by itself",
495                    ));
496                }
497            }
498        }
499        // A `--` line comment: emit a `Comment` token covering everything
500        // up to (but not including) the terminating newline. Doc-block
501        // detection above already ruled out a `---` marker at line start
502        // — and once we've consumed past the leading `--`, any further
503        // dashes are part of the comment body. Preserving comments as
504        // trivia tokens lets the parser attach them to declarations so
505        // the formatter can emit them in place (v1.1 LSP spec §3.5).
506        if pos + 1 < bytes.len() && bytes[pos] == b'-' && bytes[pos + 1] == b'-' {
507            let start = pos;
508            while pos < bytes.len() && bytes[pos] != b'\n' {
509                pos += 1;
510            }
511            tokens.push(Token {
512                kind: TokenKind::Comment,
513                span: Span::new(start, pos),
514            });
515            continue;
516        }
517        // Skip ordinary whitespace inline (logos handles it too, but we may
518        // be in the middle of the source between specials).
519        if matches!(bytes[pos], b' ' | b'\t' | b'\r' | b'\n') {
520            pos += 1;
521            continue;
522        }
523        // An interpolated string `"… \(expr) …"` (v0.43): only strings that
524        // actually contain a `\(` hole are hand-scanned here; plain strings
525        // fall through to the logos `StrLit` path unchanged. `\(` is an
526        // invalid escape in the logos grammar, so this never re-routes a
527        // currently-valid literal.
528        if bytes[pos] == b'"' && has_interp_hole(bytes, pos) {
529            let end = scan_str(bytes, source, pos)?;
530            tokens.push(Token {
531                kind: TokenKind::InterpStr,
532                span: Span::new(pos, end),
533            });
534            pos = end;
535            continue;
536        }
537        // Otherwise dispatch a single logos token starting at `pos`.
538        let mut lex = TokenKind::lexer(&source[pos..]);
539        let Some(result) = lex.next() else {
540            // No token at this position; treat as unexpected character so
541            // the user sees something useful.
542            let ch = source[pos..].chars().next().unwrap_or('\0');
543            let span = Span::new(pos, pos + ch.len_utf8());
544            return Err(CompileError::new(
545                "bynk.lex.unexpected_character",
546                span,
547                format!("unexpected character `{ch}`"),
548            ));
549        };
550        let local = lex.span();
551        let span: Span = Span::new(pos + local.start, pos + local.end);
552        match result {
553            Ok(kind) => {
554                if kind == TokenKind::IntLit {
555                    let slice = &source[span.range()];
556                    if strip_digit_separators(slice).parse::<i64>().is_err() {
557                        return Err(CompileError::new(
558                            "bynk.lex.integer_overflow",
559                            span,
560                            format!(
561                                "integer literal `{slice}` is out of range for a 64-bit signed integer"
562                            ),
563                        )
564                        .with_note("the range is -2^63 to 2^63 - 1"));
565                    }
566                }
567                if kind == TokenKind::FloatLit {
568                    let slice = &source[span.range()];
569                    match strip_digit_separators(slice).parse::<f64>() {
570                        Ok(v) if v.is_finite() => {}
571                        _ => {
572                            return Err(CompileError::new(
573                                "bynk.lex.float_literal_overflow",
574                                span,
575                                format!(
576                                    "float literal `{slice}` is out of range for a 64-bit float"
577                                ),
578                            )
579                            .with_note(
580                                "the literal does not fit a finite IEEE 754 double; \
581                                 the largest finite value is ~1.8e308",
582                            ));
583                        }
584                    }
585                }
586                tokens.push(Token { kind, span });
587                pos = span.end;
588            }
589            Err(()) => {
590                let slice = &source[span.range()];
591                let ch = slice.chars().next().unwrap_or('\0');
592                let err = if ch == '"' {
593                    CompileError::new(
594                        "bynk.lex.unterminated_string",
595                        span,
596                        "unterminated string literal",
597                    )
598                    .with_note(
599                        "string literals must close with `\"` on the same line; \
600                         supported escapes are `\\n`, `\\t`, `\\\"`, `\\\\`",
601                    )
602                } else {
603                    CompileError::new(
604                        "bynk.lex.unexpected_character",
605                        span,
606                        format!("unexpected character `{ch}`"),
607                    )
608                };
609                return Err(err);
610            }
611        }
612    }
613    Ok(tokens)
614}
615
616/// Like [`tokenize`], but with every interpolated-string token replaced by the
617/// tokens of its holes — each hole's bytes re-lexed and its token spans rebased
618/// to absolute source positions (the same rebase [`crate::parser`] applies when
619/// parsing a hole), recursing through nested interpolation. Chunk (literal) text
620/// between holes yields no tokens.
621///
622/// An interpolated string lexes to a single opaque `InterpStr` token, so the
623/// LSP's token-based cursor resolution (hover, go-to-definition, references,
624/// semantic tokens) is otherwise blind to identifiers inside `"… \(name) …"`.
625/// Expanding the holes makes those identifiers visible as ordinary `Ident`
626/// tokens with their real spans. (Issue #473.)
627///
628/// On a malformed interpolation (an `InterpStr` whose holes don't split, or a
629/// hole whose bytes don't re-lex) the offending token is kept opaque rather than
630/// dropped, so resolution degrades to the pre-fix behaviour instead of losing
631/// tokens.
632pub fn tokenize_expanding_holes(source: &str) -> Result<Vec<Token>, CompileError> {
633    let mut out = Vec::new();
634    for tok in tokenize(source)? {
635        expand_hole_token(source, tok, &mut out);
636    }
637    Ok(out)
638}
639
640/// Push `tok` onto `out`, expanding it into its holes' tokens if it is an
641/// `InterpStr` (see [`tokenize_expanding_holes`]); otherwise push it as-is.
642fn expand_hole_token(source: &str, tok: Token, out: &mut Vec<Token>) {
643    if tok.kind != TokenKind::InterpStr {
644        out.push(tok);
645        return;
646    }
647    let Ok(segments) = split_interp(source, tok.span) else {
648        out.push(tok); // malformed interpolation — keep the opaque token
649        return;
650    };
651    for segment in segments {
652        let InterpSegment::Hole(hole) = segment else {
653            continue; // chunk text carries no tokens
654        };
655        let Ok(hole_tokens) = tokenize(&source[hole.range()]) else {
656            continue;
657        };
658        for mut t in hole_tokens {
659            // Rebase the hole's local spans to absolute source positions.
660            t.span = Span::new(t.span.start + hole.start, t.span.end + hole.start);
661            expand_hole_token(source, t, out); // recurse for nested interpolation
662        }
663    }
664}
665
666/// Cheap routing pre-scan (v0.43): does the string opening at `start` contain a
667/// `\(` interpolation hole before it closes (or the line ends)? Decides whether
668/// `tokenize` hand-scans the string as an `InterpStr` or defers to logos for a
669/// plain `StrLit`. Deliberately tolerant — a malformed string with a hole is
670/// routed here so the hole-aware scanner produces the precise error.
671fn has_interp_hole(bytes: &[u8], start: usize) -> bool {
672    let mut i = start + 1;
673    while i < bytes.len() {
674        match bytes[i] {
675            b'\n' | b'"' => return false,
676            b'\\' => {
677                if bytes.get(i + 1) == Some(&b'(') {
678                    return true;
679                }
680                i += 2;
681            }
682            _ => i += 1,
683        }
684    }
685    false
686}
687
688/// Scan a double-quoted string starting at `start` (the opening `"`), returning
689/// the byte offset just past the closing `"`. Recognises the four simple
690/// escapes plus `\(…)` interpolation holes, whose parens are balanced (and
691/// whose nested strings are skipped) by [`scan_hole`]. (v0.43.)
692fn scan_str(bytes: &[u8], source: &str, start: usize) -> Result<usize, CompileError> {
693    debug_assert_eq!(bytes[start], b'"');
694    let mut i = start + 1;
695    loop {
696        if i >= bytes.len() || bytes[i] == b'\n' {
697            return Err(CompileError::new(
698                "bynk.lex.unterminated_string",
699                Span::new(start, i.min(bytes.len())),
700                "unterminated string literal",
701            )
702            .with_note(
703                "string literals must close with `\"` on the same line; \
704                 supported escapes are `\\n`, `\\t`, `\\\"`, `\\\\`, and `\\(…)` interpolation",
705            ));
706        }
707        match bytes[i] {
708            b'"' => return Ok(i + 1),
709            b'\\' => match bytes.get(i + 1) {
710                Some(b'n' | b't' | b'"' | b'\\') => i += 2,
711                Some(b'(') => i = scan_hole(bytes, source, i + 2)?,
712                other => {
713                    let shown = other.map(|b| (*b as char).to_string()).unwrap_or_default();
714                    // Cover `\` plus the whole offending char, advanced to a char
715                    // boundary so the span never splits a multibyte codepoint
716                    // (e.g. `\é`) — a fuzz invariant.
717                    let mut end = (i + 2).min(bytes.len());
718                    while end < source.len() && !source.is_char_boundary(end) {
719                        end += 1;
720                    }
721                    return Err(CompileError::new(
722                        "bynk.lex.bad_escape",
723                        Span::new(i, end),
724                        format!("invalid escape sequence `\\{shown}` in string literal"),
725                    )
726                    .with_note("supported escapes: \\n \\t \\\" \\\\ \\(…)"));
727                }
728            },
729            // Any other byte advances one position. UTF-8 continuation bytes
730            // are all >= 0x80, so they never collide with the ASCII specials.
731            _ => i += 1,
732        }
733    }
734}
735
736/// Scan an interpolation hole body. `start` points just past the `\(`; returns
737/// the offset just past the matching `)`. Tracks paren depth and skips nested
738/// strings (whose own parens must not close the hole), recursing through
739/// [`scan_str`] so nested interpolation nests correctly. (v0.43.)
740fn scan_hole(bytes: &[u8], source: &str, start: usize) -> Result<usize, CompileError> {
741    let mut i = start;
742    let mut depth = 1usize;
743    loop {
744        if i >= bytes.len() || bytes[i] == b'\n' {
745            return Err(CompileError::new(
746                "bynk.lex.unterminated_interpolation",
747                Span::new(start.saturating_sub(2), i.min(bytes.len())),
748                "unterminated interpolation hole",
749            )
750            .with_note(
751                "an interpolation hole `\\(…)` must close with a matching `)` on the same line",
752            ));
753        }
754        match bytes[i] {
755            b'(' => {
756                depth += 1;
757                i += 1;
758            }
759            b')' => {
760                depth -= 1;
761                i += 1;
762                if depth == 0 {
763                    return Ok(i);
764                }
765            }
766            b'"' => i = scan_str(bytes, source, i)?,
767            _ => i += 1,
768        }
769    }
770}
771
772/// One segment of a split interpolated string (v0.43): literal text (escapes
773/// resolved) or the absolute source span of a hole's expression (the bytes
774/// between `\(` and its matching `)`). The parser turns the latter into a real
775/// `Expr`; the lexer owns only the scanning.
776pub(crate) enum InterpSegment {
777    Chunk(String),
778    Hole(Span),
779}
780
781/// Split an `InterpStr` token (its `span` covers the whole `"…"`) into chunks
782/// and hole spans. Escapes in the chunks are resolved here (mirroring
783/// [`parse_string_literal`]); holes are returned as spans for the parser to
784/// re-lex and parse as expressions. (v0.43.)
785pub(crate) fn split_interp(source: &str, span: Span) -> Result<Vec<InterpSegment>, CompileError> {
786    let bytes = source.as_bytes();
787    let inner_end = span.end - 1; // the closing `"`
788    let mut segments = Vec::new();
789    let mut chunk = String::new();
790    let mut i = span.start + 1; // past the opening `"`
791    while i < inner_end {
792        match bytes[i] {
793            b'\\' => match bytes[i + 1] {
794                b'n' => {
795                    chunk.push('\n');
796                    i += 2;
797                }
798                b't' => {
799                    chunk.push('\t');
800                    i += 2;
801                }
802                b'"' => {
803                    chunk.push('"');
804                    i += 2;
805                }
806                b'\\' => {
807                    chunk.push('\\');
808                    i += 2;
809                }
810                b'(' => {
811                    if !chunk.is_empty() {
812                        segments.push(InterpSegment::Chunk(std::mem::take(&mut chunk)));
813                    }
814                    let hole_start = i + 2;
815                    let after = scan_hole(bytes, source, hole_start)?;
816                    // `after` is one past the matching `)`; the hole body is
817                    // everything up to that `)`.
818                    segments.push(InterpSegment::Hole(Span::new(hole_start, after - 1)));
819                    i = after;
820                }
821                // The lexer already validated every escape, so nothing else
822                // can appear here.
823                other => unreachable!("unvalidated escape `\\{}` in InterpStr", other as char),
824            },
825            _ => {
826                let ch = source[i..].chars().next().unwrap();
827                chunk.push(ch);
828                i += ch.len_utf8();
829            }
830        }
831    }
832    if !chunk.is_empty() {
833        segments.push(InterpSegment::Chunk(chunk));
834    }
835    Ok(segments)
836}
837
838/// If a `---` doc-block marker line starts at or shortly after `pos` (which
839/// must be at a line boundary), return the byte offset just past the marker
840/// line (after the terminating newline, or at EOF). The doc-block grammar
841/// requires the marker to be alone on its line; leading horizontal whitespace
842/// is allowed and ignored.
843fn doc_block_open_at(source: &str, pos: usize) -> Option<usize> {
844    let bytes = source.as_bytes();
845    if !at_line_start(source, pos) {
846        return None;
847    }
848    // Skip leading horizontal whitespace.
849    let mut i = pos;
850    while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
851        i += 1;
852    }
853    if i + 3 > bytes.len() {
854        return None;
855    }
856    if &bytes[i..i + 3] != b"---" {
857        return None;
858    }
859    i += 3;
860    // The marker may have additional trailing dashes (per spec "three or more
861    // consecutive hyphens"). Consume them.
862    while i < bytes.len() && bytes[i] == b'-' {
863        i += 1;
864    }
865    // After the dashes, allow only horizontal whitespace then newline/EOF.
866    while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t' || bytes[i] == b'\r') {
867        i += 1;
868    }
869    if i == bytes.len() {
870        return Some(i);
871    }
872    if bytes[i] == b'\n' {
873        return Some(i + 1);
874    }
875    None
876}
877
878/// Find the next closing `---` line at or after `pos`. Returns
879/// `(start_of_line, end_of_line)` (`end_of_line` is just past the
880/// terminating newline, or at EOF).
881fn doc_block_close(source: &str, mut pos: usize) -> Option<(usize, usize)> {
882    let bytes = source.as_bytes();
883    while pos < bytes.len() {
884        // Advance pos to the start of a line.
885        let line_start = pos;
886        // Find the end of this line.
887        let mut line_end = line_start;
888        while line_end < bytes.len() && bytes[line_end] != b'\n' {
889            line_end += 1;
890        }
891        // Check this line.
892        if let Some(end) = doc_block_open_at(source, line_start) {
893            return Some((line_start, end));
894        }
895        // Move to the next line.
896        pos = if line_end < bytes.len() {
897            line_end + 1
898        } else {
899            line_end
900        };
901    }
902    None
903}
904
905/// Returns true if byte offset `pos` is at a line start (column 0).
906fn at_line_start(source: &str, pos: usize) -> bool {
907    if pos == 0 {
908        return true;
909    }
910    let bytes = source.as_bytes();
911    bytes[pos - 1] == b'\n'
912}
913
914/// Extract the body content of a doc-block token from its source span.
915/// Strips the leading and trailing `---` marker lines and returns the body
916/// verbatim. If every non-empty content line begins with the same horizontal
917/// whitespace prefix (e.g., because the doc block sits inside a brace-form
918/// commons body), that common prefix is removed so the body reads naturally
919/// when emitted as JSDoc.
920pub fn doc_block_content(source: &str, span: Span) -> String {
921    let slice = &source[span.range()];
922    // Drop the first line (opening marker).
923    let after_open = match slice.find('\n') {
924        Some(i) => &slice[i + 1..],
925        None => return String::new(),
926    };
927    let bytes = after_open.as_bytes();
928    // Trim the trailing closing-marker line.
929    let mut i = bytes.len();
930    if i > 0 && bytes[i - 1] == b'\n' {
931        i -= 1;
932    }
933    while i > 0 && matches!(bytes[i - 1], b' ' | b'\t' | b'\r') {
934        i -= 1;
935    }
936    while i > 0 && bytes[i - 1] == b'-' {
937        i -= 1;
938    }
939    if i > 0 && bytes[i - 1] == b'\n' {
940        i -= 1;
941    }
942    let body = &after_open[..i];
943
944    // Compute the common leading-whitespace prefix across all non-empty lines
945    // and strip it. This lets writers indent the doc block alongside the
946    // declaration it documents without bleeding the indent into the JSDoc.
947    let common: Option<usize> = body
948        .lines()
949        .filter(|l| !l.trim().is_empty())
950        .map(|l| l.bytes().take_while(|&b| b == b' ' || b == b'\t').count())
951        .min();
952    let strip = common.unwrap_or(0);
953    if strip == 0 {
954        return body.to_string();
955    }
956    let mut out = String::with_capacity(body.len());
957    let mut first = true;
958    for line in body.lines() {
959        if !first {
960            out.push('\n');
961        }
962        first = false;
963        if line.trim().is_empty() {
964            // Preserve blank lines.
965            continue;
966        }
967        let leading: usize = line
968            .bytes()
969            .take_while(|&b| b == b' ' || b == b'\t')
970            .count();
971        let drop = strip.min(leading);
972        out.push_str(&line[drop..]);
973    }
974    out
975}
976
977/// Extract the body of a `Comment` trivia token: everything after the
978/// leading `--` marker, preserving its inline whitespace verbatim. Used by
979/// the parser when attaching comments to declarations.
980pub fn comment_body(source: &str, span: Span) -> &str {
981    let slice = &source[span.range()];
982    // Strip leading "--" if present (defensive — the lexer always emits
983    // Comment tokens whose span begins with `--`).
984    slice.strip_prefix("--").unwrap_or(slice)
985}
986
987/// Returns true if there is a blank line (a line containing only whitespace)
988/// in `source` strictly between byte offsets `from` (inclusive) and `to`
989/// (exclusive). Used by the parser to detect orphan doc blocks.
990///
991/// A doc-block token's span ends just past the closing-marker line's
992/// terminating newline. So if the next declaration begins on the immediately
993/// following line, the substring between contains no newline (only optional
994/// indentation). Any newline in the substring therefore implies at least one
995/// entirely-blank line separating the doc from the declaration.
996pub fn has_blank_line_between(source: &str, from: usize, to: usize) -> bool {
997    if to <= from {
998        return false;
999    }
1000    let bytes = source.as_bytes();
1001    let mut i = from;
1002    while i < to {
1003        if bytes[i] == b'\n' {
1004            return true;
1005        }
1006        if !matches!(bytes[i], b' ' | b'\t' | b'\r') {
1007            return false;
1008        }
1009        i += 1;
1010    }
1011    false
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016    use super::*;
1017
1018    fn kinds(source: &str) -> Vec<TokenKind> {
1019        tokenize(source)
1020            .unwrap()
1021            .into_iter()
1022            .map(|t| t.kind)
1023            .collect()
1024    }
1025
1026    #[test]
1027    fn keywords_and_idents() {
1028        use TokenKind::*;
1029        assert_eq!(
1030            kinds("commons type fn where and true false Int String Bool foo bar"),
1031            vec![
1032                Commons, Type, Fn, Where, And, True, False, Int, String, Bool, Ident, Ident
1033            ],
1034        );
1035    }
1036
1037    #[test]
1038    fn integer_and_string_literals() {
1039        use TokenKind::*;
1040        assert_eq!(
1041            kinds(r#"0 42 "hello" "with\nescape""#),
1042            vec![IntLit, IntLit, StrLit, StrLit]
1043        );
1044    }
1045
1046    #[test]
1047    fn operators() {
1048        use TokenKind::*;
1049        assert_eq!(
1050            kinds("-> == != <= >= && || + - * / ! = < > ( ) { } [ ] , : . @"),
1051            vec![
1052                Arrow, EqEq, BangEq, LtEq, GtEq, AmpAmp, PipePipe, Plus, Minus, Star, Slash, Bang,
1053                Eq, Lt, Gt, LParen, RParen, LBrace, RBrace, LBracket, RBracket, Comma, Colon, Dot,
1054                At,
1055            ],
1056        );
1057    }
1058
1059    #[test]
1060    fn line_comments_emitted_as_trivia() {
1061        // v1.1: line comments are preserved as Comment tokens so the
1062        // formatter can attach and re-emit them.
1063        use TokenKind::*;
1064        let src = "-- a comment\ntype X = Int -- trailing\n";
1065        assert_eq!(kinds(src), vec![Comment, Type, Ident, Eq, Int, Comment],);
1066    }
1067
1068    #[test]
1069    fn comment_body_extracts_text_after_marker() {
1070        let toks = tokenize("-- hello world\n").unwrap();
1071        assert_eq!(toks.len(), 1);
1072        assert_eq!(toks[0].kind, TokenKind::Comment);
1073        assert_eq!(
1074            comment_body("-- hello world\n", toks[0].span),
1075            " hello world"
1076        );
1077    }
1078
1079    #[test]
1080    fn comment_does_not_consume_newline() {
1081        // Two adjacent comment lines should produce two distinct tokens
1082        // — the newline between them is not part of either comment's span.
1083        let toks = tokenize("-- one\n-- two\n").unwrap();
1084        assert_eq!(toks.len(), 2);
1085        assert!(toks.iter().all(|t| t.kind == TokenKind::Comment));
1086    }
1087
1088    #[test]
1089    fn unterminated_string_is_error() {
1090        let err = tokenize("\"oops\n").unwrap_err();
1091        assert_eq!(err.category, "bynk.lex.unterminated_string");
1092    }
1093
1094    #[test]
1095    fn integer_overflow_is_error() {
1096        let err = tokenize("99999999999999999999").unwrap_err();
1097        assert_eq!(err.category, "bynk.lex.integer_overflow");
1098    }
1099
1100    #[test]
1101    fn digit_separators_lex_as_one_number() {
1102        use TokenKind::*;
1103        // v0.142 (ADR 0166): `_` between digit groups keeps the literal a single
1104        // token for both Int and Float.
1105        assert_eq!(kinds("1_048_576"), vec![IntLit]);
1106        assert_eq!(kinds("1_000.500_5"), vec![FloatLit]);
1107        assert_eq!(kinds("1_000e1_0"), vec![FloatLit]);
1108        // A separator-carrying literal that is in range still lexes (the value is
1109        // validated after stripping the separators).
1110        assert!(tokenize("9_223_372_036_854_775_807").is_ok());
1111        // Overflow is still caught on the separator-free value.
1112        let err = tokenize("9_999_999_999_999_999_999_9").unwrap_err();
1113        assert_eq!(err.category, "bynk.lex.integer_overflow");
1114    }
1115
1116    #[test]
1117    fn strip_digit_separators_removes_underscores() {
1118        assert_eq!(strip_digit_separators("1_048_576"), "1048576");
1119        assert_eq!(strip_digit_separators("42"), "42");
1120    }
1121
1122    #[test]
1123    fn unexpected_character_is_error() {
1124        let err = tokenize("type X = Int $").unwrap_err();
1125        assert_eq!(err.category, "bynk.lex.unexpected_character");
1126    }
1127
1128    #[test]
1129    fn v0_1_keywords() {
1130        use TokenKind::*;
1131        assert_eq!(
1132            kinds("let if else Ok Err Result ValidationError"),
1133            vec![Let, If, Else, Ok, Err, Result, ValidationError],
1134        );
1135    }
1136
1137    #[test]
1138    fn question_token() {
1139        use TokenKind::*;
1140        assert_eq!(kinds("x?"), vec![Ident, Question]);
1141    }
1142
1143    #[test]
1144    fn v0_2_keywords() {
1145        use TokenKind::*;
1146        assert_eq!(
1147            kinds("enum match Option record self Some None is"),
1148            vec![Enum, Match, Option, Record, Self_, Some, None, Is],
1149        );
1150    }
1151
1152    #[test]
1153    fn pipe_and_pipe_pipe_disambiguated() {
1154        use TokenKind::*;
1155        assert_eq!(kinds("| || |"), vec![Pipe, PipePipe, Pipe]);
1156    }
1157
1158    #[test]
1159    fn v0_7_keywords() {
1160        use TokenKind::*;
1161        assert_eq!(kinds("expect suite case"), vec![Expect, Suite, Case],);
1162        // v0.118: `mocks` and `wires` are retired — plain identifiers now.
1163        assert_eq!(kinds("mocks wires"), vec![Ident, Ident]);
1164    }
1165
1166    #[test]
1167    fn fat_arrow_and_underscore() {
1168        use TokenKind::*;
1169        assert_eq!(kinds("_ =>"), vec![Underscore, FatArrow]);
1170    }
1171
1172    // -- v0.43 string interpolation --
1173
1174    #[test]
1175    fn interp_string_is_one_token() {
1176        use TokenKind::*;
1177        assert_eq!(kinds(r#""Hello, \(name)!""#), vec![InterpStr]);
1178        // A plain string (no hole) stays a `StrLit`, via the logos path.
1179        assert_eq!(kinds(r#""Hello, world""#), vec![StrLit]);
1180    }
1181
1182    #[test]
1183    fn interp_balances_nested_parens_and_strings() {
1184        use TokenKind::*;
1185        // The `)` inside `f(x)` must not close the hole early.
1186        assert_eq!(kinds(r#""= \(f(x))""#), vec![InterpStr]);
1187        // A `)` inside a nested string inside the hole is also ignored.
1188        assert_eq!(kinds(r#""= \(label(")"))""#), vec![InterpStr]);
1189        // A nested interpolated string inside a hole.
1190        assert_eq!(kinds(r#""out \("in \(x)")""#), vec![InterpStr]);
1191    }
1192
1193    // Issue #473: hole-expanding tokenisation makes identifiers inside `\(…)`
1194    // visible to the LSP's token-based cursor resolution.
1195    #[test]
1196    fn expanding_holes_exposes_hole_identifiers() {
1197        use TokenKind::*;
1198        let expand = |src: &str| {
1199            tokenize_expanding_holes(src)
1200                .unwrap()
1201                .into_iter()
1202                .map(|t| t.kind)
1203                .collect::<Vec<_>>()
1204        };
1205        // The opaque `InterpStr` is replaced by its hole's tokens; the chunk
1206        // text (`Hello, ` / `!`) carries none.
1207        assert_eq!(expand(r#""Hello, \(name)!""#), vec![Ident]);
1208        // A call hole exposes every token of the call expression.
1209        assert_eq!(expand(r#""= \(f(x))""#), vec![Ident, LParen, Ident, RParen]);
1210        // Nested interpolation recurses to the innermost hole's identifier.
1211        assert_eq!(expand(r#""out \("in \(x)")""#), vec![Ident]);
1212        // A plain (hole-free) string is untouched.
1213        assert_eq!(expand(r#""Hello, world""#), vec![StrLit]);
1214    }
1215
1216    #[test]
1217    fn expanding_holes_rebases_spans_to_absolute() {
1218        let src = r#""Hello, \(name)!""#;
1219        let toks = tokenize_expanding_holes(src).unwrap();
1220        let ident = toks
1221            .iter()
1222            .find(|t| t.kind == TokenKind::Ident)
1223            .expect("the hole identifier is exposed");
1224        // The span points at `name` in the original source, not a hole-local 0.
1225        assert_eq!(&src[ident.span.range()], "name");
1226        assert_eq!(ident.span.start, src.find("name").unwrap());
1227    }
1228
1229    #[test]
1230    fn escaped_open_paren_is_not_a_hole() {
1231        use TokenKind::*;
1232        // `\\(` is a literal backslash followed by `(` — no hole, so the
1233        // string lexes as a plain `StrLit` on the logos path.
1234        assert_eq!(kinds(r#""a \\(b) c""#), vec![StrLit]);
1235    }
1236
1237    #[test]
1238    fn unterminated_hole_is_an_error() {
1239        // The hole runs to end of line without its closing `)`.
1240        let err = tokenize("\"value \\(x + 1\n\"").unwrap_err();
1241        assert_eq!(err.category, "bynk.lex.unterminated_interpolation");
1242    }
1243
1244    #[test]
1245    fn unterminated_interp_string_is_an_error() {
1246        // A hole closes but the string never does (newline before the `"`).
1247        let err = tokenize("\"value \\(x) more\n").unwrap_err();
1248        assert_eq!(err.category, "bynk.lex.unterminated_string");
1249    }
1250
1251    #[test]
1252    fn bad_escape_in_interp_string_is_an_error() {
1253        let err = tokenize(r#""a \q \(x)""#).unwrap_err();
1254        assert_eq!(err.category, "bynk.lex.bad_escape");
1255    }
1256}