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, and
7//! one-line docs (`--|`, #1888) as `DocLine` tokens, both lexed outside of
8//! logos (see [`tokenize`]).
9
10use logos::Logos;
11
12use crate::error::CompileError;
13use crate::span::{FileId, Span};
14
15/// #1657: the largest `Int`, `Number.MAX_SAFE_INTEGER` (2^53 − 1). An `Int`
16/// is a JS safe integer; the range is symmetric.
17pub const MAX_SAFE_INT: i64 = 9_007_199_254_740_991;
18
19/// v0.142 (ADR 0166): strip `_` digit separators from a numeric literal's lexeme
20/// before it is parsed into a value. The lexer's `IntLit`/`FloatLit` regexes only
21/// admit an `_` between two digit groups, so removing every `_` yields a plain
22/// digit string; the separators are purely visual. Allocates only when the
23/// literal actually carries a separator (the common case does not).
24pub(crate) fn strip_digit_separators(lexeme: &str) -> std::borrow::Cow<'_, str> {
25    if lexeme.as_bytes().contains(&b'_') {
26        std::borrow::Cow::Owned(lexeme.replace('_', ""))
27    } else {
28        std::borrow::Cow::Borrowed(lexeme)
29    }
30}
31
32/// Token kinds. Discriminants without payload data; the lexeme is recovered
33/// from the source string via the token's [`Span`].
34///
35/// Note: `--` line comments and `---` doc block markers are handled outside
36/// logos (see [`tokenize`]), because doc blocks are delimited by `---` lines
37/// containing only the marker and may span multiple source lines.
38#[derive(Logos, Debug, Clone, Copy, PartialEq, Eq)]
39#[logos(skip r"[ \t\r\n]+")]
40pub enum TokenKind {
41    // Keywords
42    #[token("commons")]
43    Commons,
44    #[token("type")]
45    Type,
46    // Events track, slice 0 (spine #936): `event Name = { fields }` — a
47    // context-only item declaring a typed fact. RESERVED_CONTEXTUAL, not
48    // hard, per ADR 0272's `messages` postmortem (a hard keyword broke the
49    // dotted-name case its own worked example used); `event` is at least as
50    // likely a parameter/local name (`on event(event: T)`).
51    #[token("event")]
52    Event,
53    // #1883 (static assets), slice 1a: `asset name = "path"` — a context or
54    // commons item declaring one static file the platform serves.
55    // RESERVED_CONTEXTUAL, not hard, for the same reason as `event` (ADR
56    // 0272's `messages` postmortem): `asset` is a likely local or segment name.
57    #[token("asset")]
58    Asset,
59    #[token("fn")]
60    Fn,
61    #[token("where")]
62    Where,
63    // #548: the `and` keyword was retired — refinement predicates now join with
64    // `&&` (the one conjunction spelling). `and` is a free identifier again.
65    #[token("true")]
66    True,
67    #[token("false")]
68    False,
69    #[token("Int")]
70    Int,
71    #[token("String")]
72    String,
73    #[token("Bool")]
74    Bool,
75    // v0.21 keyword
76    #[token("Float")]
77    Float,
78    // v0.86 keyword (ADR 0112): the `Duration` base type.
79    #[token("Duration")]
80    Duration,
81    // v0.90 keyword (ADR 0114): the `Instant` base type.
82    #[token("Instant")]
83    Instant,
84    // v0.110 keyword (ADR 0142): the `Bytes` base type.
85    #[token("Bytes")]
86    Bytes,
87    // v0.1 keywords
88    #[token("let")]
89    Let,
90    #[token("if")]
91    If,
92    #[token("else")]
93    Else,
94    #[token("Ok")]
95    Ok,
96    #[token("Err")]
97    Err,
98    #[token("Result")]
99    Result,
100    #[token("ValidationError")]
101    ValidationError,
102    // v0.22b keyword
103    #[token("JsonError")]
104    JsonError,
105    // v0.2 keywords
106    #[token("enum")]
107    Enum,
108    #[token("match")]
109    Match,
110    #[token("Option")]
111    Option,
112    #[token("record")]
113    Record,
114    #[token("self")]
115    Self_,
116    #[token("Some")]
117    Some,
118    #[token("None")]
119    None,
120    #[token("is")]
121    Is,
122    // v0.3 keywords
123    #[token("opaque")]
124    Opaque,
125    #[token("uses")]
126    Uses,
127    // v0.4 keywords
128    #[token("context")]
129    Context,
130    #[token("consumes")]
131    Consumes,
132    #[token("exports")]
133    Exports,
134    #[token("transparent")]
135    Transparent,
136    // v0.6 keywords
137    #[token("as")]
138    As,
139    // v0.7 keywords (v0.112: `assert`→`expect`, `test`→`suite`/`case`;
140    // v0.118: `mocks` retired — test doubles are stubs at a seam; the stub form
141    // moved off the punned `provides` keyword to its own `stub` keyword in the
142    // keyword-hygiene batch, #548)
143    #[token("expect")]
144    Expect,
145    #[token("suite")]
146    Suite,
147    #[token("case")]
148    Case,
149    // Keyword-hygiene batch (#548): the test-scope stub `stub Cap.op(…) <rhs>`,
150    // formerly the third pun on `provides`. `provides` now heads only a provider
151    // declaration / external provider.
152    #[token("stub")]
153    Stub,
154    // v0.114 keyword — generative tests (testing track slice 2). `for` and `all`
155    // are deliberately *not* keywords: `all` is a list combinator (`all(xs, p)`)
156    // and must stay a usable identifier. The `for all` binder is parsed
157    // contextually (two identifiers) inside a `property` body instead.
158    #[token("property")]
159    Property,
160    // v0.17 keywords
161    #[token("adapter")]
162    Adapter,
163    #[token("binding")]
164    Binding,
165    // v0.5 keywords
166    #[token("agent")]
167    Agent,
168    #[token("capability")]
169    Capability,
170    #[token("Effect")]
171    Effect,
172    // v0.146 keyword (ADR 0170): `do e` — an effect-performing expression
173    // statement (the binder-free `let _ <- e` for a unit effect).
174    #[token("do")]
175    Do,
176    #[token("given")]
177    Given,
178    #[token("on")]
179    On,
180    // v0.9 keyword
181    #[token("http")]
182    Http,
183    // v0.10a keyword
184    #[token("cron")]
185    Cron,
186    // v0.10b keyword
187    #[token("queue")]
188    Queue,
189    // v0.44 keywords: `from` heads a service's protocol clause; `protocol` is
190    // reserved (protocols are a closed, compiler-known set — no declaration kind).
191    #[token("from")]
192    From,
193    #[token("protocol")]
194    Protocol,
195    #[token("provides")]
196    Provides,
197    #[token("service")]
198    Service,
199    // v0.45 keywords: `actor` heads a boundary-contract declaration; `by`
200    // heads a handler's actor clause.
201    #[token("actor")]
202    Actor,
203    #[token("by")]
204    By,
205    // v0.80 keywords: `invariant` heads an agent invariant declaration; `implies`
206    // is the directional logical-implication operator (`P implies Q` ≡ `!P || Q`).
207    #[token("invariant")]
208    Invariant,
209    #[token("implies")]
210    Implies,
211    // v0.115 keywords — function contracts (testing track slice 3). `requires`
212    // and `ensures` head a contract clause on a `fn` signature (between the
213    // return type and the body). `result` is deliberately *not* a keyword: it is
214    // the ordinary value name outside a contract, so it stays a usable
215    // identifier; inside an `ensures` predicate it is bound contextually as the
216    // function's return value (parsed by scope, like `for`/`all` in slice 2).
217    // Distinct from ADR 0127's capability `@requires` annotation.
218    #[token("requires")]
219    Requires,
220    #[token("ensures")]
221    Ensures,
222    // v0.116 keyword — step invariants (testing track slice 4). `transition` heads
223    // an agent step-invariant declaration (beside `invariant`), a predicate over
224    // the pre- and post-commit state pair. `old` and `new` are deliberately *not*
225    // keywords: they stay ordinary value names outside a `transition`, and inside a
226    // `transition` predicate they are bound contextually to the old/new state
227    // records (parsed by scope, like `result` in an `ensures`).
228    #[token("transition")]
229    Transition,
230    // message-bundles track, slice 1: `messages <tag> { "code" => "template" }`
231    // — a commons item declaring one locale's message bundle.
232    #[token("messages")]
233    Messages,
234    /// `...` — used in record-spread expressions (v0.5).
235    #[token("...")]
236    DotDotDot,
237    /// `..` — the "rest of the fields" marker on an events subscription
238    /// pattern (Events track slice 1, spine #936): `from Events(E { region:
239    /// Region.Domestic, .. })`. A genuine token, not two adjacent `Dot`s —
240    /// logos maximal-munches `...`/`..`/`.` correctly once all three are
241    /// registered, and a real token keeps this in agreement with
242    /// tree-sitter's grammar (which declares `".."` as one literal), so the
243    /// two parsers cannot diverge on a whitespace-split `. .` the way ADR
244    /// 0253 D4 found a leaking `where`-check divergence once before.
245    #[token("..")]
246    DotDot,
247    /// `<-` — Effect bind operator (v0.5).
248    #[token("<-")]
249    LArrow,
250    /// `~>` — asynchronous fire-and-forget send marker (v0.79). A leading
251    /// statement marker, never on the RHS of a `let`; distinct from `<-` so the
252    /// call site shows whether the caller waits.
253    #[token("~>")]
254    TildeArrow,
255    /// `:=` — Cell write (v0.81, storage track). A handler statement
256    /// `cell := expr`; distinct from `=` (binding) and `:` (annotation). Longer
257    /// than `:`/`=` so logos matches it as one token.
258    #[token(":=")]
259    ColonEq,
260
261    /// A documentation block: `---` line ... `---` line. The token's span
262    /// covers the full block including both `---` markers. The body content
263    /// is recovered from the source via the span (see [`doc_block_content`]).
264    /// Inserted by [`tokenize`]; not lexed by logos directly.
265    DocBlock,
266
267    /// #1888: one line of the one-line doc form, `--| text`. A `--|` is a doc
268    /// line only when it is the first thing on its line (indentation aside);
269    /// anywhere else it is an ordinary [`TokenKind::Comment`] whose body starts
270    /// with `|`, so a `--|` at the end of a member's line stays a comment. Like
271    /// [`TokenKind::DocBlock`], the span runs from the marker through the
272    /// terminating newline (or EOF), so the parser's blank-line check treats
273    /// both forms alike. The parser groups consecutive lines into one doc
274    /// ([`doc_line_text`] recovers each line's text). Inserted by [`tokenize`].
275    DocLine,
276
277    /// A line comment: `-- ...` running to end of line. The span starts at
278    /// the `--` marker and runs through the last character before the
279    /// terminating newline (exclusive). The trivia body (the text after the
280    /// `--` marker) is recovered from the source via the span. Inserted by
281    /// [`tokenize`]; not lexed by logos directly so it cannot be mistaken
282    /// for an `--` operator sequence.
283    Comment,
284
285    // Identifier
286    #[regex(r"[A-Za-z][A-Za-z0-9_]*")]
287    Ident,
288
289    // Literals. v0.142 (ADR 0166): an `_` digit separator may appear between
290    // digits (`1_048_576`) — never leading, trailing, or doubled (each `_` must
291    // sit between two digit groups). The separators are stripped before the value
292    // is parsed; they are purely visual.
293    #[regex(r"[0-9]+(_[0-9]+)*")]
294    IntLit,
295    // A float literal: fraction with a digit on both sides of the `.`, an
296    // exponent, or both (v0.21 §3). `1.` and `.5` are NOT float literals —
297    // the digit-both-sides rule keeps `2.5.round()` / `1.toFloat()` lexing
298    // as method calls on numeric literals. Digit separators (v0.142) may appear
299    // in any digit group, including the exponent.
300    #[regex(
301        r"[0-9]+(_[0-9]+)*\.[0-9]+(_[0-9]+)*([eE][+-]?[0-9]+(_[0-9]+)*)?|[0-9]+(_[0-9]+)*[eE][+-]?[0-9]+(_[0-9]+)*"
302    )]
303    FloatLit,
304    // A double-quoted string with simple escapes. The body excludes the closing
305    // quote; we accept any non-quote/non-backslash/non-newline char, or a
306    // backslash followed by one of the four allowed escapes.
307    #[regex(r#""([^"\\\n]|\\[nt"\\])*""#)]
308    StrLit,
309    // An interpolated string `"… \(expr) …"` (v0.43). Hand-scanned in
310    // `tokenize` (logos cannot balance the holes' parens), never produced by
311    // the logos lexer — like [`TokenKind::DocBlock`]/[`TokenKind::Comment`].
312    // The span covers the whole `"…"`; the parser splits chunks from holes.
313    InterpStr,
314
315    // Multi-char operators
316    #[token("->")]
317    Arrow,
318    #[token("==")]
319    EqEq,
320    #[token("!=")]
321    BangEq,
322    #[token("<=")]
323    LtEq,
324    #[token(">=")]
325    GtEq,
326    #[token("&&")]
327    AmpAmp,
328    #[token("||")]
329    PipePipe,
330
331    // Single-char operators
332    #[token("+")]
333    Plus,
334    #[token("-")]
335    Minus,
336    #[token("*")]
337    Star,
338    #[token("/")]
339    Slash,
340    #[token("!")]
341    Bang,
342    #[token("=")]
343    Eq,
344    #[token("<")]
345    Lt,
346    #[token(">")]
347    Gt,
348    // v0.1 postfix operator
349    #[token("?")]
350    Question,
351    // v0.2 match-arm arrow
352    #[token("=>")]
353    FatArrow,
354    // v0.2 wildcard pattern (also valid as identifier start; the lexer
355    // prefers identifier for any longer match, so `_foo` is still Ident).
356    #[token("_")]
357    Underscore,
358    // v0.2 sum-type variant separator (also used as future bitwise OR);
359    // single `|` distinct from `||`.
360    #[token("|")]
361    Pipe,
362    /// `@` — storage-annotation marker (v0.85, storage track; ADR 0111). Leads a
363    /// `@name(args)` annotation on a `store` field (`@ttl(…)`/`@indexed(…)`); it
364    /// appears only in store-field-declaration position, never as an expression
365    /// operator.
366    #[token("@")]
367    At,
368
369    // Punctuation
370    #[token("(")]
371    LParen,
372    #[token(")")]
373    RParen,
374    #[token("{")]
375    LBrace,
376    #[token("}")]
377    RBrace,
378    #[token("[")]
379    LBracket,
380    #[token("]")]
381    RBracket,
382    #[token(",")]
383    Comma,
384    #[token(":")]
385    Colon,
386    #[token(".")]
387    Dot,
388}
389
390impl TokenKind {
391    /// Human-readable display name for diagnostics.
392    pub fn describe(self) -> &'static str {
393        use TokenKind::*;
394        match self {
395            Commons => "`commons`",
396            Type => "`type`",
397            Event => "`event`",
398            Asset => "`asset`",
399            Fn => "`fn`",
400            Where => "`where`",
401            True => "`true`",
402            False => "`false`",
403            Int => "`Int`",
404            String => "`String`",
405            Bool => "`Bool`",
406            Float => "`Float`",
407            Duration => "`Duration`",
408            Instant => "`Instant`",
409            Bytes => "`Bytes`",
410            Let => "`let`",
411            If => "`if`",
412            Else => "`else`",
413            Ok => "`Ok`",
414            Err => "`Err`",
415            Result => "`Result`",
416            ValidationError => "`ValidationError`",
417            JsonError => "`JsonError`",
418            Enum => "`enum`",
419            Match => "`match`",
420            Option => "`Option`",
421            Record => "`record`",
422            Self_ => "`self`",
423            Some => "`Some`",
424            None => "`None`",
425            Is => "`is`",
426            Opaque => "`opaque`",
427            Uses => "`uses`",
428            Context => "`context`",
429            Consumes => "`consumes`",
430            Exports => "`exports`",
431            Transparent => "`transparent`",
432            As => "`as`",
433            Expect => "`expect`",
434            Suite => "`suite`",
435            Case => "`case`",
436            Property => "`property`",
437            Adapter => "`adapter`",
438            Binding => "`binding`",
439            Agent => "`agent`",
440            Capability => "`capability`",
441            Effect => "`Effect`",
442            Do => "`do`",
443            Given => "`given`",
444            On => "`on`",
445            Http => "`http`",
446            Cron => "`cron`",
447            Queue => "`queue`",
448            From => "`from`",
449            Protocol => "`protocol`",
450            Provides => "`provides`",
451            Stub => "`stub`",
452            Service => "`service`",
453            Actor => "`actor`",
454            By => "`by`",
455            Invariant => "`invariant`",
456            Implies => "`implies`",
457            Requires => "`requires`",
458            Ensures => "`ensures`",
459            Transition => "`transition`",
460            Messages => "`messages`",
461            ColonEq => "`:=`",
462            DotDotDot => "`...`",
463            DotDot => "`..`",
464            LArrow => "`<-`",
465            TildeArrow => "`~>`",
466            DocBlock => "documentation block",
467            DocLine => "documentation line (`--|`)",
468            Comment => "line comment",
469            Ident => "identifier",
470            IntLit => "integer literal",
471            FloatLit => "float literal",
472            StrLit => "string literal",
473            InterpStr => "interpolated string",
474            Arrow => "`->`",
475            EqEq => "`==`",
476            BangEq => "`!=`",
477            LtEq => "`<=`",
478            GtEq => "`>=`",
479            AmpAmp => "`&&`",
480            PipePipe => "`||`",
481            Plus => "`+`",
482            Minus => "`-`",
483            Star => "`*`",
484            Slash => "`/`",
485            Bang => "`!`",
486            Eq => "`=`",
487            Lt => "`<`",
488            Gt => "`>`",
489            Question => "`?`",
490            FatArrow => "`=>`",
491            Underscore => "`_`",
492            Pipe => "`|`",
493            At => "`@`",
494            LParen => "`(`",
495            RParen => "`)`",
496            LBrace => "`{`",
497            RBrace => "`}`",
498            LBracket => "`[`",
499            RBracket => "`]`",
500            Comma => "`,`",
501            Colon => "`:`",
502            Dot => "`.`",
503        }
504    }
505}
506
507/// A token plus its source span.
508#[derive(Debug, Clone, Copy)]
509pub struct Token {
510    pub kind: TokenKind,
511    pub span: Span,
512}
513
514/// Tokenise a source string with no real file identity — every span's
515/// [`FileId`] defaults to [`FileId::UNKNOWN`]. See [`tokenize_in`] for the
516/// real-identity entry point production callers use.
517pub fn tokenize(source: &str) -> Result<Vec<Token>, CompileError> {
518    tokenize_in(source, FileId::UNKNOWN)
519}
520
521/// Tokenise a source string, stamping every token's span with `file`.
522/// Returns the full token vector or the first lexical error.
523///
524/// Doc blocks (`---` ... `---`) and line comments (`-- ...`) are recognised
525/// outside the logos-generated lexer: we scan the source one segment at a
526/// time, dispatching to logos for ordinary tokens between non-token spans.
527pub fn tokenize_in(source: &str, file: FileId) -> Result<Vec<Token>, CompileError> {
528    let mut tokens = Vec::new();
529    let bytes = source.as_bytes();
530    let mut pos = 0;
531    while pos < bytes.len() {
532        // Detect a `---` doc-block marker at the start of a line (the line may
533        // begin with leading whitespace; the marker itself must be alone on
534        // its line).
535        if let Some((open_end, dashes)) = doc_block_open_at(source, pos) {
536            // Find the matching closing marker: a line of exactly as many
537            // dashes as the opener (#1885), so a `----` block can hold `---`.
538            match doc_block_close(source, open_end, dashes) {
539                Some((close_start, close_end)) => {
540                    let span = Span::new_in(file, pos, close_end);
541                    tokens.push(Token {
542                        kind: TokenKind::DocBlock,
543                        span,
544                    });
545                    let _ = close_start;
546                    pos = close_end;
547                    continue;
548                }
549                None => {
550                    return Err(CompileError::new(
551                        "bynk.lex.unclosed_doc_block",
552                        Span::new_in(file, pos, open_end),
553                        "documentation block opened but never closed",
554                    )
555                    .with_note(format!(
556                        "a doc block is closed by a line of exactly {dashes} dashes on its own; \
557                         a line of three or more dashes is a doc-block marker, not a divider, so \
558                         for a divider use a line comment with text, such as `-- Helpers --`"
559                    )));
560                }
561            }
562        }
563        // A `--` line comment: emit a `Comment` token covering everything
564        // up to (but not including) the terminating newline. Doc-block
565        // detection above already ruled out a `---` marker at line start
566        // — and once we've consumed past the leading `--`, any further
567        // dashes are part of the comment body. Preserving comments as
568        // trivia tokens lets the parser attach them to declarations so
569        // the formatter can emit them in place (v1.1 LSP spec §3.5).
570        //
571        // #548 (keyword-hygiene batch): a `--` opens a comment only when it is
572        // at the start of input or **preceded by whitespace**. Adjacent to a
573        // preceding token (`a--b`), the `--` is *not* a comment — it lexes as two
574        // `-` operators (`a - -b`), so a subtraction-of-negation is never
575        // silently swallowed as a line comment. This resolves the `a--b`
576        // "comment vs subtraction" ambiguity in favour of subtraction.
577        let comment_eligible = pos == 0 || matches!(bytes[pos - 1], b' ' | b'\t' | b'\r' | b'\n');
578        // #1888: a `--|` that is the first thing on its line is a one-line doc,
579        // its span running through the newline as a doc block's does. Anywhere
580        // else (after code on the line) it falls through to a `--` comment.
581        if bytes[pos..].starts_with(b"--|") && only_indent_before(bytes, pos) {
582            let start = pos;
583            while pos < bytes.len() && bytes[pos] != b'\n' {
584                pos += 1;
585            }
586            if pos < bytes.len() {
587                pos += 1;
588            }
589            tokens.push(Token {
590                kind: TokenKind::DocLine,
591                span: Span::new_in(file, start, pos),
592            });
593            continue;
594        }
595        if comment_eligible && pos + 1 < bytes.len() && bytes[pos] == b'-' && bytes[pos + 1] == b'-'
596        {
597            let start = pos;
598            while pos < bytes.len() && bytes[pos] != b'\n' {
599                pos += 1;
600            }
601            tokens.push(Token {
602                kind: TokenKind::Comment,
603                span: Span::new_in(file, start, pos),
604            });
605            continue;
606        }
607        // Skip ordinary whitespace inline (logos handles it too, but we may
608        // be in the middle of the source between specials).
609        if matches!(bytes[pos], b' ' | b'\t' | b'\r' | b'\n') {
610            pos += 1;
611            continue;
612        }
613        // An interpolated string `"… \(expr) …"` (v0.43): only strings that
614        // actually contain a `\(` hole are hand-scanned here; plain strings
615        // fall through to the logos `StrLit` path unchanged. `\(` is an
616        // invalid escape in the logos grammar, so this never re-routes a
617        // currently-valid literal.
618        if bytes[pos] == b'"' && has_interp_hole(bytes, pos) {
619            let end = scan_str(bytes, source, pos, 0, file)?;
620            tokens.push(Token {
621                kind: TokenKind::InterpStr,
622                span: Span::new_in(file, pos, end),
623            });
624            pos = end;
625            continue;
626        }
627        // Otherwise dispatch a single logos token starting at `pos`.
628        let mut lex = TokenKind::lexer(&source[pos..]);
629        let Some(result) = lex.next() else {
630            // No token at this position; treat as unexpected character so
631            // the user sees something useful.
632            let ch = source[pos..].chars().next().unwrap_or('\0');
633            let span = Span::new_in(file, pos, pos + ch.len_utf8());
634            return Err(CompileError::new(
635                "bynk.lex.unexpected_character",
636                span,
637                format!("unexpected character `{ch}`"),
638            ));
639        };
640        let local = lex.span();
641        let span: Span = Span::new_in(file, pos + local.start, pos + local.end);
642        match result {
643            Ok(kind) => {
644                // #1657 (runtime-semantics track §3.4): an `Int` is a JS safe
645                // integer, ±(2^53 − 1), the range the runtime represents
646                // exactly. A larger literal would round silently
647                // (`9007199254740993 == 9007199254740992`). The sign is a
648                // separate operator, and the bound is symmetric, so the
649                // magnitude decides.
650                if kind == TokenKind::IntLit {
651                    let slice = &source[span.range()];
652                    if !strip_digit_separators(slice)
653                        .parse::<i64>()
654                        .is_ok_and(|n| n <= MAX_SAFE_INT)
655                    {
656                        return Err(CompileError::new(
657                            "bynk.lex.integer_overflow",
658                            span,
659                            format!("integer literal `{slice}` is out of range for an `Int`"),
660                        )
661                        .with_note(
662                            "an `Int` is a safe integer, -(2^53 - 1) to 2^53 - 1 \
663                             (±9007199254740991); use a `Float` or a `String` for a larger value",
664                        ));
665                    }
666                }
667                if kind == TokenKind::FloatLit {
668                    let slice = &source[span.range()];
669                    match strip_digit_separators(slice).parse::<f64>() {
670                        Ok(v) if v.is_finite() => {}
671                        _ => {
672                            return Err(CompileError::new(
673                                "bynk.lex.float_literal_overflow",
674                                span,
675                                format!(
676                                    "float literal `{slice}` is out of range for a 64-bit float"
677                                ),
678                            )
679                            .with_note(
680                                "the literal does not fit a finite IEEE 754 double; \
681                                 the largest finite value is ~1.8e308",
682                            ));
683                        }
684                    }
685                }
686                tokens.push(Token { kind, span });
687                pos = span.end;
688            }
689            Err(()) => {
690                let slice = &source[span.range()];
691                let ch = slice.chars().next().unwrap_or('\0');
692                let err = if ch == '"' {
693                    CompileError::new(
694                        "bynk.lex.unterminated_string",
695                        span,
696                        "unterminated string literal",
697                    )
698                    .with_note(
699                        "string literals must close with `\"` on the same line; \
700                         supported escapes are `\\n`, `\\t`, `\\\"`, `\\\\`",
701                    )
702                } else {
703                    CompileError::new(
704                        "bynk.lex.unexpected_character",
705                        span,
706                        format!("unexpected character `{ch}`"),
707                    )
708                };
709                return Err(err);
710            }
711        }
712    }
713    Ok(tokens)
714}
715
716/// Like [`tokenize`], but with every interpolated-string token replaced by the
717/// tokens of its holes — each hole's bytes re-lexed and its token spans rebased
718/// to absolute source positions (the same rebase [`crate::parser`] applies when
719/// parsing a hole), recursing through nested interpolation. Chunk (literal) text
720/// between holes yields no tokens.
721///
722/// An interpolated string lexes to a single opaque `InterpStr` token, so the
723/// LSP's token-based cursor resolution (hover, go-to-definition, references,
724/// semantic tokens) is otherwise blind to identifiers inside `"… \(name) …"`.
725/// Expanding the holes makes those identifiers visible as ordinary `Ident`
726/// tokens with their real spans. (Issue #473.)
727///
728/// On a malformed interpolation (an `InterpStr` whose holes don't split, or a
729/// hole whose bytes don't re-lex) the offending token is kept opaque rather than
730/// dropped, so resolution degrades to the pre-fix behaviour instead of losing
731/// tokens.
732pub fn tokenize_expanding_holes(source: &str) -> Result<Vec<Token>, CompileError> {
733    tokenize_expanding_holes_in(source, FileId::UNKNOWN)
734}
735
736/// Like [`tokenize_expanding_holes`], but stamping every token's span
737/// (including rebased hole tokens) with `file`.
738pub fn tokenize_expanding_holes_in(source: &str, file: FileId) -> Result<Vec<Token>, CompileError> {
739    let mut out = Vec::new();
740    for tok in tokenize_in(source, file)? {
741        expand_hole_token(source, file, tok, &mut out);
742    }
743    Ok(out)
744}
745
746/// Push `tok` onto `out`, expanding it into its holes' tokens if it is an
747/// `InterpStr` (see [`tokenize_expanding_holes`]); otherwise push it as-is.
748fn expand_hole_token(source: &str, file: FileId, tok: Token, out: &mut Vec<Token>) {
749    if tok.kind != TokenKind::InterpStr {
750        out.push(tok);
751        return;
752    }
753    let Ok(segments) = split_interp(source, tok.span) else {
754        out.push(tok); // malformed interpolation — keep the opaque token
755        return;
756    };
757    for segment in segments {
758        let InterpSegment::Hole(hole) = segment else {
759            continue; // chunk text carries no tokens
760        };
761        let Ok(hole_tokens) = tokenize_in(&source[hole.range()], file) else {
762            continue;
763        };
764        for mut t in hole_tokens {
765            // Rebase the hole's local spans to absolute source positions.
766            t.span = Span::new_in(file, t.span.start + hole.start, t.span.end + hole.start);
767            expand_hole_token(source, file, t, out); // recurse for nested interpolation
768        }
769    }
770}
771
772/// Cheap routing pre-scan (v0.43): does the string opening at `start` contain a
773/// `\(` interpolation hole before it closes (or the line ends)? Decides whether
774/// `tokenize` hand-scans the string as an `InterpStr` or defers to logos for a
775/// plain `StrLit`. Deliberately tolerant — a malformed string with a hole is
776/// routed here so the hole-aware scanner produces the precise error.
777fn has_interp_hole(bytes: &[u8], start: usize) -> bool {
778    let mut i = start + 1;
779    while i < bytes.len() {
780        match bytes[i] {
781            b'\n' | b'"' => return false,
782            b'\\' => {
783                if bytes.get(i + 1) == Some(&b'(') {
784                    return true;
785                }
786                i += 2;
787            }
788            _ => i += 1,
789        }
790    }
791    false
792}
793
794/// Scan a double-quoted string starting at `start` (the opening `"`), returning
795/// the byte offset just past the closing `"`. Recognises the four simple
796/// escapes plus `\(…)` interpolation holes, whose parens are balanced (and
797/// whose nested strings are skipped) by [`scan_hole`]. (v0.43.)
798fn scan_str(
799    bytes: &[u8],
800    source: &str,
801    start: usize,
802    depth: usize,
803    file: FileId,
804) -> Result<usize, CompileError> {
805    debug_assert_eq!(bytes[start], b'"');
806    if depth > crate::MAX_NESTING_DEPTH {
807        // Anchor on the opening `"` of the string that tipped over the limit.
808        return Err(too_deeply_nested_interpolation(Span::new_in(
809            file,
810            start,
811            start + 1,
812        )));
813    }
814    let mut i = start + 1;
815    loop {
816        if i >= bytes.len() || bytes[i] == b'\n' {
817            return Err(CompileError::new(
818                "bynk.lex.unterminated_string",
819                Span::new_in(file, start, i.min(bytes.len())),
820                "unterminated string literal",
821            )
822            .with_note(
823                "string literals must close with `\"` on the same line; \
824                 supported escapes are `\\n`, `\\t`, `\\\"`, `\\\\`, and `\\(…)` interpolation",
825            ));
826        }
827        match bytes[i] {
828            b'"' => return Ok(i + 1),
829            b'\\' => match bytes.get(i + 1) {
830                Some(b'n' | b't' | b'"' | b'\\') => i += 2,
831                Some(b'(') => i = scan_hole(bytes, source, i + 2, depth + 1, file)?,
832                other => {
833                    let shown = other.map(|b| (*b as char).to_string()).unwrap_or_default();
834                    // Cover `\` plus the whole offending char, advanced to a char
835                    // boundary so the span never splits a multibyte codepoint
836                    // (e.g. `\é`) — a fuzz invariant.
837                    let mut end = (i + 2).min(bytes.len());
838                    while end < source.len() && !source.is_char_boundary(end) {
839                        end += 1;
840                    }
841                    return Err(CompileError::new(
842                        "bynk.lex.bad_escape",
843                        Span::new_in(file, i, end),
844                        format!("invalid escape sequence `\\{shown}` in string literal"),
845                    )
846                    .with_note("supported escapes: \\n \\t \\\" \\\\ \\(…)"));
847                }
848            },
849            // Any other byte advances one position. UTF-8 continuation bytes
850            // are all >= 0x80, so they never collide with the ASCII specials.
851            _ => i += 1,
852        }
853    }
854}
855
856/// Scan an interpolation hole body. `start` points just past the `\(`; returns
857/// the offset just past the matching `)`. Tracks paren depth and skips nested
858/// strings (whose own parens must not close the hole), recursing through
859/// [`scan_str`] so nested interpolation nests correctly. (v0.43.)
860fn scan_hole(
861    bytes: &[u8],
862    source: &str,
863    start: usize,
864    nesting: usize,
865    file: FileId,
866) -> Result<usize, CompileError> {
867    if nesting > crate::MAX_NESTING_DEPTH {
868        // Anchor on the `\(` opener that tipped over the limit; it sits two
869        // bytes before `start` and is pure ASCII, so the span stays on char
870        // boundaries (a fuzz invariant).
871        return Err(too_deeply_nested_interpolation(Span::new_in(
872            file,
873            start.saturating_sub(2),
874            start,
875        )));
876    }
877    let mut i = start;
878    let mut depth = 1usize;
879    loop {
880        if i >= bytes.len() || bytes[i] == b'\n' {
881            return Err(CompileError::new(
882                "bynk.lex.unterminated_interpolation",
883                Span::new_in(file, start.saturating_sub(2), i.min(bytes.len())),
884                "unterminated interpolation hole",
885            )
886            .with_note(
887                "an interpolation hole `\\(…)` must close with a matching `)` on the same line",
888            ));
889        }
890        match bytes[i] {
891            b'(' => {
892                depth += 1;
893                i += 1;
894            }
895            b')' => {
896                depth -= 1;
897                i += 1;
898                if depth == 0 {
899                    return Ok(i);
900                }
901            }
902            b'"' => i = scan_str(bytes, source, i, nesting + 1, file)?,
903            _ => i += 1,
904        }
905    }
906}
907
908/// The bounded-depth diagnostic for interpolation that nests past
909/// [`crate::MAX_NESTING_DEPTH`]. `\("\("\(…` mutually recurses
910/// [`scan_str`] ↔ [`scan_hole`], one stack frame per level, so an unbounded
911/// scanner overflows and aborts `tokenize` (#713). `span` anchors the report on
912/// the opener that tipped over the limit (the `"` or the `\(`).
913fn too_deeply_nested_interpolation(span: Span) -> CompileError {
914    CompileError::new(
915        "bynk.lex.interpolation_too_deep",
916        span,
917        format!(
918            "string interpolation nests more than {} levels deep",
919            crate::MAX_NESTING_DEPTH
920        ),
921    )
922    .with_note(
923        "deeply nested `\\(…)` interpolation is rejected to keep the lexer from \
924         overflowing its stack and aborting; flatten or split the string",
925    )
926}
927
928/// One segment of a split interpolated string (v0.43): literal text (escapes
929/// resolved) or the absolute source span of a hole's expression (the bytes
930/// between `\(` and its matching `)`). The parser turns the latter into a real
931/// `Expr`; the lexer owns only the scanning.
932pub(crate) enum InterpSegment {
933    Chunk(String),
934    Hole(Span),
935}
936
937/// Split an `InterpStr` token (its `span` covers the whole `"…"`) into chunks
938/// and hole spans. Escapes in the chunks are resolved here (mirroring
939/// [`parse_string_literal`]); holes are returned as spans for the parser to
940/// re-lex and parse as expressions. (v0.43.)
941pub(crate) fn split_interp(source: &str, span: Span) -> Result<Vec<InterpSegment>, CompileError> {
942    let bytes = source.as_bytes();
943    let inner_end = span.end - 1; // the closing `"`
944    let mut segments = Vec::new();
945    let mut chunk = String::new();
946    let mut i = span.start + 1; // past the opening `"`
947    while i < inner_end {
948        match bytes[i] {
949            b'\\' => match bytes[i + 1] {
950                b'n' => {
951                    chunk.push('\n');
952                    i += 2;
953                }
954                b't' => {
955                    chunk.push('\t');
956                    i += 2;
957                }
958                b'"' => {
959                    chunk.push('"');
960                    i += 2;
961                }
962                b'\\' => {
963                    chunk.push('\\');
964                    i += 2;
965                }
966                b'(' => {
967                    if !chunk.is_empty() {
968                        segments.push(InterpSegment::Chunk(std::mem::take(&mut chunk)));
969                    }
970                    let hole_start = i + 2;
971                    let after = scan_hole(bytes, source, hole_start, 0, span.file)?;
972                    // `after` is one past the matching `)`; the hole body is
973                    // everything up to that `)`.
974                    segments.push(InterpSegment::Hole(Span::new_in(
975                        span.file,
976                        hole_start,
977                        after - 1,
978                    )));
979                    i = after;
980                }
981                // The lexer already validated every escape, so nothing else
982                // can appear here.
983                other => unreachable!("unvalidated escape `\\{}` in InterpStr", other as char),
984            },
985            _ => {
986                let ch = source[i..].chars().next().unwrap();
987                chunk.push(ch);
988                i += ch.len_utf8();
989            }
990        }
991    }
992    if !chunk.is_empty() {
993        segments.push(InterpSegment::Chunk(chunk));
994    }
995    Ok(segments)
996}
997
998/// If a `---` doc-block marker line starts at or shortly after `pos` (which
999/// must be at a line boundary), return the byte offset just past the marker
1000/// line (after the terminating newline, or at EOF), and the marker's dash
1001/// count (three or more). The doc-block grammar requires the marker to be
1002/// alone on its line; leading horizontal whitespace is allowed and ignored.
1003fn doc_block_open_at(source: &str, pos: usize) -> Option<(usize, usize)> {
1004    let bytes = source.as_bytes();
1005    if !at_line_start(source, pos) {
1006        return None;
1007    }
1008    // Skip leading horizontal whitespace.
1009    let mut i = pos;
1010    while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
1011        i += 1;
1012    }
1013    if i + 3 > bytes.len() {
1014        return None;
1015    }
1016    if &bytes[i..i + 3] != b"---" {
1017        return None;
1018    }
1019    let dash_start = i;
1020    i += 3;
1021    // The marker may have additional trailing dashes (per spec "three or more
1022    // consecutive hyphens"). Consume them.
1023    while i < bytes.len() && bytes[i] == b'-' {
1024        i += 1;
1025    }
1026    let dashes = i - dash_start;
1027    // After the dashes, allow only horizontal whitespace then newline/EOF.
1028    while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t' || bytes[i] == b'\r') {
1029        i += 1;
1030    }
1031    if i == bytes.len() {
1032        return Some((i, dashes));
1033    }
1034    if bytes[i] == b'\n' {
1035        return Some((i + 1, dashes));
1036    }
1037    None
1038}
1039
1040/// Find the next closing marker line at or after `pos`: a marker of exactly
1041/// `dashes` dashes, the opener's count (#1885), as with a Markdown code fence.
1042/// A marker line of any other length is content, so a longer fence can hold a
1043/// shorter one. Returns `(start_of_line, end_of_line)` (`end_of_line` is just
1044/// past the terminating newline, or at EOF).
1045fn doc_block_close(source: &str, mut pos: usize, dashes: usize) -> Option<(usize, usize)> {
1046    let bytes = source.as_bytes();
1047    while pos < bytes.len() {
1048        // Advance pos to the start of a line.
1049        let line_start = pos;
1050        // Find the end of this line.
1051        let mut line_end = line_start;
1052        while line_end < bytes.len() && bytes[line_end] != b'\n' {
1053            line_end += 1;
1054        }
1055        // Check this line.
1056        if let Some((end, n)) = doc_block_open_at(source, line_start)
1057            && n == dashes
1058        {
1059            return Some((line_start, end));
1060        }
1061        // Move to the next line.
1062        pos = if line_end < bytes.len() {
1063            line_end + 1
1064        } else {
1065            line_end
1066        };
1067    }
1068    None
1069}
1070
1071/// Returns true if byte offset `pos` is at a line start (column 0).
1072/// #1888: true when only horizontal whitespace precedes `pos` on its line.
1073fn only_indent_before(bytes: &[u8], pos: usize) -> bool {
1074    bytes[..pos]
1075        .iter()
1076        .rev()
1077        .take_while(|&&b| b != b'\n')
1078        .all(|&b| matches!(b, b' ' | b'\t' | b'\r'))
1079}
1080
1081/// #1888: the text of one `--|` doc line (a [`TokenKind::DocLine`] token):
1082/// everything after the marker, less one separating space when there is one,
1083/// and less the line ending. Further indentation is kept, so a doc line can
1084/// indent a Markdown code block or a nested list.
1085pub fn doc_line_text(source: &str, span: Span) -> &str {
1086    let slice = &source[span.range()];
1087    let slice = slice.strip_prefix("--|").unwrap_or(slice);
1088    let slice = slice.strip_prefix(' ').unwrap_or(slice);
1089    slice.trim_end_matches(['\n', '\r'])
1090}
1091
1092/// #1888: the byte range of a doc line's text within `source`, as
1093/// [`doc_line_text`] returns it, for callers that map positions in a doc
1094/// back to the source (document links).
1095pub fn doc_line_text_range(source: &str, span: Span) -> std::ops::Range<usize> {
1096    let text = doc_line_text(source, span);
1097    let start = text.as_ptr() as usize - source.as_ptr() as usize;
1098    start..start + text.len()
1099}
1100
1101fn at_line_start(source: &str, pos: usize) -> bool {
1102    if pos == 0 {
1103        return true;
1104    }
1105    let bytes = source.as_bytes();
1106    bytes[pos - 1] == b'\n'
1107}
1108
1109/// The doc-block body as a byte range into `source` — leading/trailing `---`
1110/// marker lines stripped, no further processing (unlike [`doc_block_content`],
1111/// which additionally strips a common per-line indent — not offset-preserving).
1112/// Callers that need to map a position in the body back to `source` (e.g.
1113/// document-link spans) use this instead of re-deriving it from the string
1114/// `doc_block_content` returns.
1115pub fn doc_block_body_range(source: &str, span: Span) -> Option<std::ops::Range<usize>> {
1116    let slice = &source[span.range()];
1117    // Drop the first line (opening marker).
1118    let after_open_rel = slice.find('\n')? + 1;
1119    let after_open = &slice[after_open_rel..];
1120    let bytes = after_open.as_bytes();
1121    // Trim the trailing closing-marker line.
1122    let mut i = bytes.len();
1123    if i > 0 && bytes[i - 1] == b'\n' {
1124        i -= 1;
1125    }
1126    while i > 0 && matches!(bytes[i - 1], b' ' | b'\t' | b'\r') {
1127        i -= 1;
1128    }
1129    while i > 0 && bytes[i - 1] == b'-' {
1130        i -= 1;
1131    }
1132    if i > 0 && bytes[i - 1] == b'\n' {
1133        i -= 1;
1134    }
1135    let start = span.range().start + after_open_rel;
1136    Some(start..start + i)
1137}
1138
1139/// Extract the body content of a doc-block token from its source span.
1140/// Strips the leading and trailing `---` marker lines and returns the body
1141/// verbatim. If every non-empty content line begins with the same horizontal
1142/// whitespace prefix (e.g., because the doc block sits inside a brace-form
1143/// commons body), that common prefix is removed so the body reads naturally
1144/// when emitted as JSDoc.
1145pub fn doc_block_content(source: &str, span: Span) -> String {
1146    let Some(range) = doc_block_body_range(source, span) else {
1147        return String::new();
1148    };
1149    let body = &source[range];
1150
1151    // Compute the common leading-whitespace prefix across all non-empty lines
1152    // and strip it. This lets writers indent the doc block alongside the
1153    // declaration it documents without bleeding the indent into the JSDoc.
1154    let common: Option<usize> = body
1155        .lines()
1156        .filter(|l| !l.trim().is_empty())
1157        .map(|l| l.bytes().take_while(|&b| b == b' ' || b == b'\t').count())
1158        .min();
1159    let strip = common.unwrap_or(0);
1160    if strip == 0 {
1161        return body.to_string();
1162    }
1163    let mut out = String::with_capacity(body.len());
1164    let mut first = true;
1165    for line in body.lines() {
1166        if !first {
1167            out.push('\n');
1168        }
1169        first = false;
1170        if line.trim().is_empty() {
1171            // Preserve blank lines.
1172            continue;
1173        }
1174        let leading: usize = line
1175            .bytes()
1176            .take_while(|&b| b == b' ' || b == b'\t')
1177            .count();
1178        let drop = strip.min(leading);
1179        out.push_str(&line[drop..]);
1180    }
1181    out
1182}
1183
1184/// Extract the body of a `Comment` trivia token: everything after the
1185/// leading `--` marker, preserving its inline whitespace verbatim. Used by
1186/// the parser when attaching comments to declarations.
1187pub fn comment_body(source: &str, span: Span) -> &str {
1188    let slice = &source[span.range()];
1189    // Strip leading "--" if present (defensive — the lexer always emits
1190    // Comment tokens whose span begins with `--`).
1191    slice.strip_prefix("--").unwrap_or(slice)
1192}
1193
1194/// Returns true if there is a blank line (a line containing only whitespace)
1195/// in `source` strictly between byte offsets `from` (inclusive) and `to`
1196/// (exclusive). Used by the parser to detect orphan doc blocks.
1197///
1198/// A doc-block token's span ends just past the closing-marker line's
1199/// terminating newline. So if the next declaration begins on the immediately
1200/// following line, the substring between contains no newline (only optional
1201/// indentation). Any newline in the substring therefore implies at least one
1202/// entirely-blank line separating the doc from the declaration.
1203pub fn has_blank_line_between(source: &str, from: usize, to: usize) -> bool {
1204    if to <= from {
1205        return false;
1206    }
1207    let bytes = source.as_bytes();
1208    let mut i = from;
1209    while i < to {
1210        if bytes[i] == b'\n' {
1211            return true;
1212        }
1213        if !matches!(bytes[i], b' ' | b'\t' | b'\r') {
1214            return false;
1215        }
1216        i += 1;
1217    }
1218    false
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223    use super::*;
1224
1225    fn kinds(source: &str) -> Vec<TokenKind> {
1226        tokenize(source)
1227            .unwrap()
1228            .into_iter()
1229            .map(|t| t.kind)
1230            .collect()
1231    }
1232
1233    #[test]
1234    fn keywords_and_idents() {
1235        use TokenKind::*;
1236        assert_eq!(
1237            kinds("commons type fn where true false Int String Bool foo bar"),
1238            vec![
1239                Commons, Type, Fn, Where, True, False, Int, String, Bool, Ident, Ident
1240            ],
1241        );
1242        // #548: `and` is no longer a keyword — it lexes as an ordinary identifier.
1243        assert_eq!(kinds("and"), vec![Ident]);
1244    }
1245
1246    #[test]
1247    fn deeply_nested_interpolation_is_bounded_not_overflowed() {
1248        // `"\("\("\(…` mutually recurses scan_str <-> scan_hole, one frame per
1249        // level, and an unbounded scanner overflows `tokenize` and aborts the
1250        // process (#713). Well past the limit it must return a bounded-depth
1251        // diagnostic instead. The holes are left open so the depth guard, not a
1252        // later `)`, stops the scan.
1253        let depth = crate::MAX_NESTING_DEPTH + 8;
1254        let src = format!("\"{}", "\\(\"".repeat(depth));
1255        let err = tokenize(&src).unwrap_err();
1256        assert_eq!(err.category, "bynk.lex.interpolation_too_deep");
1257    }
1258
1259    #[test]
1260    fn integer_and_string_literals() {
1261        use TokenKind::*;
1262        assert_eq!(
1263            kinds(r#"0 42 "hello" "with\nescape""#),
1264            vec![IntLit, IntLit, StrLit, StrLit]
1265        );
1266    }
1267
1268    #[test]
1269    fn operators() {
1270        use TokenKind::*;
1271        assert_eq!(
1272            kinds("-> == != <= >= && || + - * / ! = < > ( ) { } [ ] , : . @"),
1273            vec![
1274                Arrow, EqEq, BangEq, LtEq, GtEq, AmpAmp, PipePipe, Plus, Minus, Star, Slash, Bang,
1275                Eq, Lt, Gt, LParen, RParen, LBrace, RBrace, LBracket, RBracket, Comma, Colon, Dot,
1276                At,
1277            ],
1278        );
1279    }
1280
1281    #[test]
1282    fn dot_family_maximal_munch() {
1283        // Events track slice 1 (spine #936): `..` must lex as one `DotDot`
1284        // token, not two `Dot`s — a real token keeps agreement with
1285        // tree-sitter (which declares `".."` as one literal), so a
1286        // whitespace-split `. .` cannot silently parse where a real `..`
1287        // is required. Also confirms `...`/`..`/`.` don't shadow each other
1288        // regardless of declaration order (logos maximal-munch).
1289        use TokenKind::*;
1290        assert_eq!(
1291            kinds("a .. b ... c . d . ."),
1292            vec![Ident, DotDot, Ident, DotDotDot, Ident, Dot, Ident, Dot, Dot,],
1293        );
1294    }
1295
1296    #[test]
1297    fn line_comments_emitted_as_trivia() {
1298        // v1.1: line comments are preserved as Comment tokens so the
1299        // formatter can attach and re-emit them.
1300        use TokenKind::*;
1301        let src = "-- a comment\ntype X = Int -- trailing\n";
1302        assert_eq!(kinds(src), vec![Comment, Type, Ident, Eq, Int, Comment],);
1303    }
1304
1305    #[test]
1306    fn comment_body_extracts_text_after_marker() {
1307        let toks = tokenize("-- hello world\n").unwrap();
1308        assert_eq!(toks.len(), 1);
1309        assert_eq!(toks[0].kind, TokenKind::Comment);
1310        assert_eq!(
1311            comment_body("-- hello world\n", toks[0].span),
1312            " hello world"
1313        );
1314    }
1315
1316    #[test]
1317    fn comment_does_not_consume_newline() {
1318        // Two adjacent comment lines should produce two distinct tokens
1319        // — the newline between them is not part of either comment's span.
1320        let toks = tokenize("-- one\n-- two\n").unwrap();
1321        assert_eq!(toks.len(), 2);
1322        assert!(toks.iter().all(|t| t.kind == TokenKind::Comment));
1323    }
1324
1325    #[test]
1326    fn dashdash_opens_a_comment_only_when_whitespace_preceded() {
1327        // #548: a `--` opens a comment at the start of input, or when preceded by
1328        // whitespace/line-start. Adjacent to a preceding token it is *not* a
1329        // comment — `a--b` lexes as `a - -b`, never a swallowed line comment.
1330        use TokenKind::*;
1331        assert_eq!(kinds("a--b"), vec![Ident, Minus, Minus, Ident]);
1332        // A trailing decrement-looking `x--` is two operators, not a comment
1333        // that eats the rest of the line — including at end-of-input with no
1334        // trailing newline (the `pos + 1 < len` guard still holds for `x--`).
1335        assert_eq!(kinds("x--\ny"), vec![Ident, Minus, Minus, Ident]);
1336        assert_eq!(kinds("x--"), vec![Ident, Minus, Minus]);
1337        // Whitespace-preceded and start-of-input `--` are still comments.
1338        assert_eq!(kinds("a -- c"), vec![Ident, Comment]);
1339        assert_eq!(kinds("-- c"), vec![Comment]);
1340        // Start of a fresh line (newline-preceded) is a comment.
1341        assert_eq!(kinds("a\n-- c"), vec![Ident, Comment]);
1342        // The comment/doc-block asymmetry: `--` needs only whitespace before it,
1343        // so a mid-line `a ---b` is a *comment* (the leading `-` of the three is
1344        // whitespace-preceded); a `---` doc-block additionally needs line-start,
1345        // which `a ---b` is not.
1346        assert_eq!(kinds("a ---b"), vec![Ident, Comment]);
1347        // A single `-` between terms is unaffected.
1348        assert_eq!(kinds("a - b"), vec![Ident, Minus, Ident]);
1349    }
1350
1351    /// #1888: a `--|` first on its line is a doc line, its span running
1352    /// through the newline; after code on the line it is a comment (DECISION
1353    /// E), and `---|` is a comment too (a `---` marker needs the line alone).
1354    #[test]
1355    fn a_line_leading_bar_comment_is_a_doc_line() {
1356        use TokenKind::*;
1357        assert_eq!(kinds("--| doc\ntype"), vec![DocLine, Type]);
1358        assert_eq!(kinds("  --| doc\n\t--|\n"), vec![DocLine, DocLine]);
1359        assert_eq!(
1360            kinds("x: Int, --| text\n"),
1361            vec![Ident, Colon, Int, Comma, Comment]
1362        );
1363        assert_eq!(kinds("---| text\n"), vec![Comment]);
1364        assert_eq!(kinds("-- | spaced\n"), vec![Comment]);
1365        let src = "  --|  indented  \r\n--|bare\n--|";
1366        let toks = tokenize(src).unwrap();
1367        assert_eq!(&src[toks[0].span.range()], "--|  indented  \r\n");
1368        assert_eq!(doc_line_text(src, toks[0].span), " indented  ");
1369        assert_eq!(doc_line_text(src, toks[1].span), "bare");
1370        assert_eq!(doc_line_text(src, toks[2].span), "");
1371        assert_eq!(&src[doc_line_text_range(src, toks[0].span)], " indented  ");
1372    }
1373
1374    #[test]
1375    fn unterminated_string_is_error() {
1376        let err = tokenize("\"oops\n").unwrap_err();
1377        assert_eq!(err.category, "bynk.lex.unterminated_string");
1378    }
1379
1380    #[test]
1381    fn integer_overflow_is_error() {
1382        let err = tokenize("99999999999999999999").unwrap_err();
1383        assert_eq!(err.category, "bynk.lex.integer_overflow");
1384    }
1385
1386    #[test]
1387    fn an_int_literal_must_be_a_safe_integer() {
1388        // #1657: the `Int` range is ±(2^53 − 1). The largest safe integer lexes;
1389        // one more, and i64-range values above it, do not.
1390        assert!(tokenize("9007199254740991").is_ok());
1391        assert!(tokenize("9_007_199_254_740_991").is_ok());
1392        for over in [
1393            "9007199254740992",
1394            "9007199254740993",
1395            "9223372036854775807",
1396        ] {
1397            let err = tokenize(over).unwrap_err();
1398            assert_eq!(err.category, "bynk.lex.integer_overflow", "{over}");
1399        }
1400    }
1401
1402    #[test]
1403    fn digit_separators_lex_as_one_number() {
1404        use TokenKind::*;
1405        // v0.142 (ADR 0166): `_` between digit groups keeps the literal a single
1406        // token for both Int and Float.
1407        assert_eq!(kinds("1_048_576"), vec![IntLit]);
1408        assert_eq!(kinds("1_000.500_5"), vec![FloatLit]);
1409        assert_eq!(kinds("1_000e1_0"), vec![FloatLit]);
1410        // A separator-carrying literal that is in range still lexes (the value is
1411        // validated after stripping the separators).
1412        assert!(tokenize("9_007_199_254_740_991").is_ok());
1413        // Overflow is still caught on the separator-free value.
1414        let err = tokenize("9_999_999_999_999_999_999_9").unwrap_err();
1415        assert_eq!(err.category, "bynk.lex.integer_overflow");
1416    }
1417
1418    #[test]
1419    fn strip_digit_separators_removes_underscores() {
1420        assert_eq!(strip_digit_separators("1_048_576"), "1048576");
1421        assert_eq!(strip_digit_separators("42"), "42");
1422    }
1423
1424    #[test]
1425    fn unexpected_character_is_error() {
1426        let err = tokenize("type X = Int $").unwrap_err();
1427        assert_eq!(err.category, "bynk.lex.unexpected_character");
1428    }
1429
1430    #[test]
1431    fn v0_1_keywords() {
1432        use TokenKind::*;
1433        assert_eq!(
1434            kinds("let if else Ok Err Result ValidationError"),
1435            vec![Let, If, Else, Ok, Err, Result, ValidationError],
1436        );
1437    }
1438
1439    #[test]
1440    fn question_token() {
1441        use TokenKind::*;
1442        assert_eq!(kinds("x?"), vec![Ident, Question]);
1443    }
1444
1445    #[test]
1446    fn v0_2_keywords() {
1447        use TokenKind::*;
1448        assert_eq!(
1449            kinds("enum match Option record self Some None is"),
1450            vec![Enum, Match, Option, Record, Self_, Some, None, Is],
1451        );
1452    }
1453
1454    #[test]
1455    fn pipe_and_pipe_pipe_disambiguated() {
1456        use TokenKind::*;
1457        assert_eq!(kinds("| || |"), vec![Pipe, PipePipe, Pipe]);
1458    }
1459
1460    #[test]
1461    fn v0_7_keywords() {
1462        use TokenKind::*;
1463        assert_eq!(kinds("expect suite case"), vec![Expect, Suite, Case],);
1464        // v0.118: `mocks` and `wires` are retired — plain identifiers now.
1465        assert_eq!(kinds("mocks wires"), vec![Ident, Ident]);
1466    }
1467
1468    #[test]
1469    fn fat_arrow_and_underscore() {
1470        use TokenKind::*;
1471        assert_eq!(kinds("_ =>"), vec![Underscore, FatArrow]);
1472    }
1473
1474    // -- v0.43 string interpolation --
1475
1476    #[test]
1477    fn interp_string_is_one_token() {
1478        use TokenKind::*;
1479        assert_eq!(kinds(r#""Hello, \(name)!""#), vec![InterpStr]);
1480        // A plain string (no hole) stays a `StrLit`, via the logos path.
1481        assert_eq!(kinds(r#""Hello, world""#), vec![StrLit]);
1482    }
1483
1484    #[test]
1485    fn interp_balances_nested_parens_and_strings() {
1486        use TokenKind::*;
1487        // The `)` inside `f(x)` must not close the hole early.
1488        assert_eq!(kinds(r#""= \(f(x))""#), vec![InterpStr]);
1489        // A `)` inside a nested string inside the hole is also ignored.
1490        assert_eq!(kinds(r#""= \(label(")"))""#), vec![InterpStr]);
1491        // A nested interpolated string inside a hole.
1492        assert_eq!(kinds(r#""out \("in \(x)")""#), vec![InterpStr]);
1493    }
1494
1495    // Issue #473: hole-expanding tokenisation makes identifiers inside `\(…)`
1496    // visible to the LSP's token-based cursor resolution.
1497    #[test]
1498    fn expanding_holes_exposes_hole_identifiers() {
1499        use TokenKind::*;
1500        let expand = |src: &str| {
1501            tokenize_expanding_holes(src)
1502                .unwrap()
1503                .into_iter()
1504                .map(|t| t.kind)
1505                .collect::<Vec<_>>()
1506        };
1507        // The opaque `InterpStr` is replaced by its hole's tokens; the chunk
1508        // text (`Hello, ` / `!`) carries none.
1509        assert_eq!(expand(r#""Hello, \(name)!""#), vec![Ident]);
1510        // A call hole exposes every token of the call expression.
1511        assert_eq!(expand(r#""= \(f(x))""#), vec![Ident, LParen, Ident, RParen]);
1512        // Nested interpolation recurses to the innermost hole's identifier.
1513        assert_eq!(expand(r#""out \("in \(x)")""#), vec![Ident]);
1514        // A plain (hole-free) string is untouched.
1515        assert_eq!(expand(r#""Hello, world""#), vec![StrLit]);
1516    }
1517
1518    #[test]
1519    fn expanding_holes_rebases_spans_to_absolute() {
1520        let src = r#""Hello, \(name)!""#;
1521        let toks = tokenize_expanding_holes(src).unwrap();
1522        let ident = toks
1523            .iter()
1524            .find(|t| t.kind == TokenKind::Ident)
1525            .expect("the hole identifier is exposed");
1526        // The span points at `name` in the original source, not a hole-local 0.
1527        assert_eq!(&src[ident.span.range()], "name");
1528        assert_eq!(ident.span.start, src.find("name").unwrap());
1529    }
1530
1531    #[test]
1532    fn escaped_open_paren_is_not_a_hole() {
1533        use TokenKind::*;
1534        // `\\(` is a literal backslash followed by `(` — no hole, so the
1535        // string lexes as a plain `StrLit` on the logos path.
1536        assert_eq!(kinds(r#""a \\(b) c""#), vec![StrLit]);
1537    }
1538
1539    #[test]
1540    fn unterminated_hole_is_an_error() {
1541        // The hole runs to end of line without its closing `)`.
1542        let err = tokenize("\"value \\(x + 1\n\"").unwrap_err();
1543        assert_eq!(err.category, "bynk.lex.unterminated_interpolation");
1544    }
1545
1546    #[test]
1547    fn unterminated_interp_string_is_an_error() {
1548        // A hole closes but the string never does (newline before the `"`).
1549        let err = tokenize("\"value \\(x) more\n").unwrap_err();
1550        assert_eq!(err.category, "bynk.lex.unterminated_string");
1551    }
1552
1553    #[test]
1554    fn bad_escape_in_interp_string_is_an_error() {
1555        let err = tokenize(r#""a \q \(x)""#).unwrap_err();
1556        assert_eq!(err.category, "bynk.lex.bad_escape");
1557    }
1558
1559    fn doc_block_span(source: &str) -> Span {
1560        tokenize(source)
1561            .unwrap()
1562            .into_iter()
1563            .find(|t| t.kind == TokenKind::DocBlock)
1564            .expect("a DocBlock token")
1565            .span
1566    }
1567
1568    #[test]
1569    fn doc_block_body_range_slices_to_the_same_bytes_doc_block_content_would_strip() {
1570        let src = "---\nHello there.\n---\nfn f() -> Int = 1\n";
1571        let span = doc_block_span(src);
1572        let range = doc_block_body_range(src, span).unwrap();
1573        assert_eq!(&src[range], "Hello there.");
1574    }
1575
1576    #[test]
1577    fn doc_block_body_range_is_offset_preserving_unlike_doc_block_content() {
1578        // A content line indented relative to its (unindented) markers:
1579        // doc_block_content strips the common indent (not offset-preserving),
1580        // doc_block_body_range does not — its slice still contains the raw
1581        // indentation, so span-based callers can map a position in the raw
1582        // body straight back to `src`.
1583        let src = "---\n  See [Foo].\n---\nfn f() -> Int = 1\n";
1584        let span = doc_block_span(src);
1585        let range = doc_block_body_range(src, span).unwrap();
1586        assert_eq!(&src[range.clone()], "  See [Foo].");
1587        assert_eq!(doc_block_content(src, span), "See [Foo].");
1588        // The raw range still locates `[Foo]` correctly within `src`.
1589        let bracket_rel = src[range.clone()].find('[').unwrap();
1590        assert_eq!(
1591            &src[range.start + bracket_rel..range.start + bracket_rel + 5],
1592            "[Foo]"
1593        );
1594    }
1595
1596    #[test]
1597    fn doc_block_content_and_body_range_agree_on_empty_body() {
1598        let src = "---\n---\nfn f() -> Int = 1\n";
1599        let span = doc_block_span(src);
1600        let range = doc_block_body_range(src, span).unwrap();
1601        assert_eq!(&src[range], "");
1602        assert_eq!(doc_block_content(src, span), "");
1603    }
1604
1605    /// #1885 (DECISION A): a `----` block closes only on `----`, so the `---`
1606    /// lines inside it are content, a nested doc example among them.
1607    #[test]
1608    fn a_longer_fence_holds_shorter_marker_lines() {
1609        let src = "----\nIntro.\n---\nInner doc.\n---\nTail.\n----\nfn f() -> Int = 1\n";
1610        let tokens = tokenize(src).unwrap();
1611        let docs: Vec<_> = tokens
1612            .iter()
1613            .filter(|t| t.kind == TokenKind::DocBlock)
1614            .collect();
1615        assert_eq!(docs.len(), 1, "one block, not three");
1616        assert_eq!(
1617            doc_block_content(src, docs[0].span),
1618            "Intro.\n---\nInner doc.\n---\nTail."
1619        );
1620    }
1621
1622    /// The trailing-dash trim in `doc_block_body_range` stops at the closer's
1623    /// line, so a body that ends in a shorter marker line keeps it.
1624    #[test]
1625    fn a_body_ending_in_a_shorter_marker_line_keeps_it() {
1626        let src = "----\nAbove.\n---\n----\nfn f() -> Int = 1\n";
1627        let span = doc_block_span(src);
1628        assert_eq!(doc_block_content(src, span), "Above.\n---");
1629    }
1630
1631    /// #1885: a marker of another length does not close the block, so a
1632    /// `----` opened and only `---` seen after is unclosed at EOF, and the note
1633    /// names the length that would close it.
1634    #[test]
1635    fn a_marker_of_another_length_does_not_close_the_block() {
1636        let err = tokenize("----\nText.\n---\nfn f() -> Int = 1\n").unwrap_err();
1637        assert_eq!(err.category, "bynk.lex.unclosed_doc_block");
1638        let note = err.notes.join(" ");
1639        assert!(note.contains("exactly 4 dashes"), "{note}");
1640        assert!(note.contains("not a divider"), "{note}");
1641        // The other way round: a `---` block does not close on `----`.
1642        let err = tokenize("---\nText.\n----\n").unwrap_err();
1643        assert_eq!(err.category, "bynk.lex.unclosed_doc_block");
1644    }
1645}