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