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