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 (`--- text`, #2203) as `DocLine` tokens, both lexed outside
8//! of logos (see [`tokenize`]).
9//!
10//! #2203: the legacy spellings, a `--` comment and a `--|` doc line, still lex
11//! as `Comment` and `DocLine` tokens, so every tool keeps working on an
12//! unmigrated file and the formatter can rewrite it. The parser reports each
13//! as an error with a machine-applicable fix ([`legacy_comment_error`],
14//! [`legacy_doc_line_error`]); [`comment_is_legacy`] and
15//! [`doc_line_is_legacy`] tell the spellings apart.
16
17use logos::Logos;
18
19use crate::error::{Applicability, CompileError};
20use crate::span::{FileId, Span};
21
22/// #1657: the largest `Int`, `Number.MAX_SAFE_INTEGER` (2^53 − 1). An `Int`
23/// is a JS safe integer; the range is symmetric.
24pub const MAX_SAFE_INT: i64 = 9_007_199_254_740_991;
25
26/// v0.142 (ADR 0166): strip `_` digit separators from a numeric literal's lexeme
27/// before it is parsed into a value. The lexer's `IntLit`/`FloatLit` regexes only
28/// admit an `_` between two digit groups, so removing every `_` yields a plain
29/// digit string; the separators are purely visual. Allocates only when the
30/// literal actually carries a separator (the common case does not).
31pub(crate) fn strip_digit_separators(lexeme: &str) -> std::borrow::Cow<'_, str> {
32    if lexeme.as_bytes().contains(&b'_') {
33        std::borrow::Cow::Owned(lexeme.replace('_', ""))
34    } else {
35        std::borrow::Cow::Borrowed(lexeme)
36    }
37}
38
39/// Token kinds. Discriminants without payload data; the lexeme is recovered
40/// from the source string via the token's [`Span`].
41///
42/// Note: `--` line comments and `---` doc block markers are handled outside
43/// logos (see [`tokenize`]), because doc blocks are delimited by `---` lines
44/// containing only the marker and may span multiple source lines.
45#[derive(Logos, Debug, Clone, Copy, PartialEq, Eq)]
46#[logos(skip r"[ \t\r\n]+")]
47pub enum TokenKind {
48    // Keywords
49    #[token("commons")]
50    Commons,
51    #[token("type")]
52    Type,
53    // Events track, slice 0 (spine #936): `event Name = { fields }` — a
54    // context-only item declaring a typed fact. RESERVED_CONTEXTUAL, not
55    // hard, per ADR 0272's `messages` postmortem (a hard keyword broke the
56    // dotted-name case its own worked example used); `event` is at least as
57    // likely a parameter/local name (`on event(event: T)`).
58    #[token("event")]
59    Event,
60    // #1883 (static assets), slice 1a: `asset name = "path"` — a context or
61    // commons item declaring one static file the platform serves.
62    // RESERVED_CONTEXTUAL, not hard, for the same reason as `event` (ADR
63    // 0272's `messages` postmortem): `asset` is a likely local or segment name.
64    #[token("asset")]
65    Asset,
66    // #2387 (embedded text), slice 1: `embed name = "path"` — a context or
67    // commons item whose value is a project file's text. RESERVED_CONTEXTUAL,
68    // for the same reason as `asset`: `embed` is a likely local name.
69    #[token("embed")]
70    Embed,
71    // #2617 (named constants): `const Name: Type = <static expression>` — a
72    // context or commons item naming a value. RESERVED_CONTEXTUAL, for the
73    // same reason as `embed`: `const` is a plausible local or field name, and
74    // it was a free identifier before this keyword existed.
75    #[token("const")]
76    Const,
77    #[token("fn")]
78    Fn,
79    #[token("where")]
80    Where,
81    // #548: the `and` keyword was retired — refinement predicates now join with
82    // `&&` (the one conjunction spelling). `and` is a free identifier again.
83    #[token("true")]
84    True,
85    #[token("false")]
86    False,
87    #[token("Int")]
88    Int,
89    #[token("String")]
90    String,
91    #[token("Bool")]
92    Bool,
93    // v0.21 keyword
94    #[token("Float")]
95    Float,
96    // v0.86 keyword (ADR 0112): the `Duration` base type.
97    #[token("Duration")]
98    Duration,
99    // v0.90 keyword (ADR 0114): the `Instant` base type.
100    #[token("Instant")]
101    Instant,
102    // v0.110 keyword (ADR 0142): the `Bytes` base type.
103    #[token("Bytes")]
104    Bytes,
105    // ADR (#2622): the `Decimal` base type.
106    #[token("Decimal")]
107    Decimal,
108    // v0.1 keywords
109    #[token("let")]
110    Let,
111    #[token("if")]
112    If,
113    #[token("else")]
114    Else,
115    #[token("Ok")]
116    Ok,
117    #[token("Err")]
118    Err,
119    #[token("Result")]
120    Result,
121    #[token("ValidationError")]
122    ValidationError,
123    // v0.22b keyword
124    #[token("JsonError")]
125    JsonError,
126    // v0.2 keywords
127    #[token("enum")]
128    Enum,
129    #[token("match")]
130    Match,
131    #[token("Option")]
132    Option,
133    #[token("record")]
134    Record,
135    #[token("self")]
136    Self_,
137    #[token("Some")]
138    Some,
139    #[token("None")]
140    None,
141    #[token("is")]
142    Is,
143    // v0.3 keywords
144    #[token("opaque")]
145    Opaque,
146    #[token("uses")]
147    Uses,
148    // v0.4 keywords
149    #[token("context")]
150    Context,
151    #[token("consumes")]
152    Consumes,
153    #[token("exports")]
154    Exports,
155    #[token("transparent")]
156    Transparent,
157    // v0.6 keywords
158    #[token("as")]
159    As,
160    // v0.7 keywords (v0.112: `assert`→`expect`, `test`→`suite`/`case`;
161    // v0.118: `mocks` retired — test doubles are stubs at a seam; the stub form
162    // moved off the punned `provides` keyword to its own `stub` keyword in the
163    // keyword-hygiene batch, #548)
164    #[token("expect")]
165    Expect,
166    #[token("suite")]
167    Suite,
168    #[token("case")]
169    Case,
170    // Keyword-hygiene batch (#548): the test-scope stub `stub Cap.op(…) <rhs>`,
171    // formerly the third pun on `provides`. `provides` now heads only a provider
172    // declaration / external provider.
173    #[token("stub")]
174    Stub,
175    // v0.114 keyword — generative tests (testing track slice 2). `for` and `all`
176    // are deliberately *not* keywords: `all` is a list combinator (`all(xs, p)`)
177    // and must stay a usable identifier. The `for all` binder is parsed
178    // contextually (two identifiers) inside a `property` body instead.
179    #[token("property")]
180    Property,
181    // v0.17 keywords
182    #[token("adapter")]
183    Adapter,
184    #[token("binding")]
185    Binding,
186    // v0.5 keywords
187    #[token("agent")]
188    Agent,
189    #[token("capability")]
190    Capability,
191    #[token("Effect")]
192    Effect,
193    // v0.146 keyword (ADR 0170): `do e` — an effect-performing expression
194    // statement (the binder-free `let _ <- e` for a unit effect).
195    #[token("do")]
196    Do,
197    #[token("given")]
198    Given,
199    #[token("on")]
200    On,
201    // v0.9 keyword
202    #[token("http")]
203    Http,
204    // v0.10a keyword
205    #[token("cron")]
206    Cron,
207    // v0.10b keyword
208    #[token("queue")]
209    Queue,
210    // v0.44 keywords: `from` heads a service's protocol clause; `protocol` is
211    // reserved (protocols are a closed, compiler-known set — no declaration kind).
212    #[token("from")]
213    From,
214    #[token("protocol")]
215    Protocol,
216    #[token("provides")]
217    Provides,
218    #[token("service")]
219    Service,
220    // v0.45 keywords: `actor` heads a boundary-contract declaration; `by`
221    // heads a handler's actor clause.
222    #[token("actor")]
223    Actor,
224    #[token("by")]
225    By,
226    // v0.80 keywords: `invariant` heads an agent invariant declaration; `implies`
227    // is the directional logical-implication operator (`P implies Q` ≡ `!P || Q`).
228    #[token("invariant")]
229    Invariant,
230    #[token("implies")]
231    Implies,
232    // v0.115 keywords — function contracts (testing track slice 3). `requires`
233    // and `ensures` head a contract clause on a `fn` signature (between the
234    // return type and the body). `result` is deliberately *not* a keyword: it is
235    // the ordinary value name outside a contract, so it stays a usable
236    // identifier; inside an `ensures` predicate it is bound contextually as the
237    // function's return value (parsed by scope, like `for`/`all` in slice 2).
238    // Distinct from ADR 0127's capability `@requires` annotation.
239    #[token("requires")]
240    Requires,
241    #[token("ensures")]
242    Ensures,
243    // v0.116 keyword — step invariants (testing track slice 4). `transition` heads
244    // an agent step-invariant declaration (beside `invariant`), a predicate over
245    // the pre- and post-commit state pair. `old` and `new` are deliberately *not*
246    // keywords: they stay ordinary value names outside a `transition`, and inside a
247    // `transition` predicate they are bound contextually to the old/new state
248    // records (parsed by scope, like `result` in an `ensures`).
249    #[token("transition")]
250    Transition,
251    // message-bundles track, slice 1: `messages <tag> { "code" => "template" }`
252    // — a commons item declaring one locale's message bundle.
253    #[token("messages")]
254    Messages,
255    /// `...` — used in record-spread expressions (v0.5).
256    #[token("...")]
257    DotDotDot,
258    /// `..` — the "rest of the fields" marker on an events subscription
259    /// pattern (Events track slice 1, spine #936): `from Events(E { region:
260    /// Region.Domestic, .. })`. A genuine token, not two adjacent `Dot`s —
261    /// logos maximal-munches `...`/`..`/`.` correctly once all three are
262    /// registered, and a real token keeps this in agreement with
263    /// tree-sitter's grammar (which declares `".."` as one literal), so the
264    /// two parsers cannot diverge on a whitespace-split `. .` the way ADR
265    /// 0253 D4 found a leaking `where`-check divergence once before.
266    #[token("..")]
267    DotDot,
268    /// `<-` — Effect bind operator (v0.5).
269    #[token("<-")]
270    LArrow,
271    /// `~>` — asynchronous fire-and-forget send marker (v0.79). A leading
272    /// statement marker, never on the RHS of a `let`; distinct from `<-` so the
273    /// call site shows whether the caller waits.
274    #[token("~>")]
275    TildeArrow,
276    /// `:=` — Cell write (v0.81, storage track). A handler statement
277    /// `cell := expr`; distinct from `=` (binding) and `:` (annotation). Longer
278    /// than `:`/`=` so logos matches it as one token.
279    #[token(":=")]
280    ColonEq,
281
282    /// A documentation block: `---` line ... `---` line. The token's span
283    /// covers the full block including both `---` markers. The body content
284    /// is recovered from the source via the span (see [`doc_block_content`]).
285    /// Inserted by [`tokenize`]; not lexed by logos directly.
286    DocBlock,
287
288    /// #2203: the one-line doc form, `--- text`: a marker of three or more
289    /// dashes, at least one space or tab, then text, first on its line
290    /// (indentation aside). An optional closer, a final run of exactly the
291    /// opener's dashes preceded by whitespace, is not part of the doc. A
292    /// marker alone on its line is a [`TokenKind::DocBlock`] fence instead.
293    /// Like [`TokenKind::DocBlock`], the span runs from the marker through the
294    /// terminating newline (or EOF), so the parser's blank-line check treats
295    /// both forms alike ([`doc_line_text`] recovers the text). The legacy
296    /// `--| text` spelling (#1888) lexes as this token too, and the parser
297    /// reports it ([`doc_line_is_legacy`]). Inserted by [`tokenize`].
298    DocLine,
299
300    /// A line comment: `// ...` running to end of line, anywhere (#2203). The
301    /// span starts at the `//` marker and runs through the last character
302    /// before the terminating newline (exclusive). The trivia body (the text
303    /// after the marker) is recovered from the source via the span
304    /// ([`comment_body`]). The legacy `-- ...` spelling lexes as this token
305    /// too, and the parser reports it ([`comment_is_legacy`]). Inserted by
306    /// [`tokenize`]; not lexed by logos directly.
307    Comment,
308
309    // Identifier
310    #[regex(r"[A-Za-z][A-Za-z0-9_]*")]
311    Ident,
312
313    // Literals. v0.142 (ADR 0166): an `_` digit separator may appear between
314    // digits (`1_048_576`) — never leading, trailing, or doubled (each `_` must
315    // sit between two digit groups). The separators are stripped before the value
316    // is parsed; they are purely visual.
317    #[regex(r"[0-9]+(_[0-9]+)*")]
318    IntLit,
319    // A float literal: fraction with a digit on both sides of the `.`, an
320    // exponent, or both (v0.21 §3). `1.` and `.5` are NOT float literals —
321    // the digit-both-sides rule keeps `2.5.round()` / `1.toFloat()` lexing
322    // as method calls on numeric literals. Digit separators (v0.142) may appear
323    // in any digit group, including the exponent.
324    #[regex(
325        r"[0-9]+(_[0-9]+)*\.[0-9]+(_[0-9]+)*([eE][+-]?[0-9]+(_[0-9]+)*)?|[0-9]+(_[0-9]+)*[eE][+-]?[0-9]+(_[0-9]+)*"
326    )]
327    FloatLit,
328    // A decimal literal (#2622): an integer or fraction with a trailing `d`
329    // (`1.20d`, `5d`). The as-written scale is the number of fraction digits.
330    // No exponent form. Digit separators may appear in either group.
331    #[regex(r"[0-9]+(_[0-9]+)*(\.[0-9]+(_[0-9]+)*)?d")]
332    DecimalLit,
333    // A string literal with no interpolation hole: `"…"`, the raw forms
334    // `r"…"` / `r#"…"#`, and the multi-line forms `"""…"""` / `r"""…"""`
335    // (#2619). Hand-scanned in `tokenize` (never produced by logos) so one
336    // scanner owns every form; the span covers the whole lexeme and
337    // [`decode_string_literal`] recovers the value.
338    StrLit,
339    // An interpolated string `"… \(expr) …"` (v0.43). Hand-scanned in
340    // `tokenize` (logos cannot balance the holes' parens), never produced by
341    // the logos lexer — like [`TokenKind::DocBlock`]/[`TokenKind::Comment`].
342    // The span covers the whole `"…"`; the parser splits chunks from holes.
343    InterpStr,
344
345    // Multi-char operators
346    #[token("->")]
347    Arrow,
348    #[token("==")]
349    EqEq,
350    #[token("!=")]
351    BangEq,
352    #[token("<=")]
353    LtEq,
354    #[token(">=")]
355    GtEq,
356    #[token("&&")]
357    AmpAmp,
358    #[token("||")]
359    PipePipe,
360
361    // Single-char operators
362    #[token("+")]
363    Plus,
364    #[token("-")]
365    Minus,
366    #[token("*")]
367    Star,
368    #[token("/")]
369    Slash,
370    /// #2804: not an operator. Bynk has no `%`; it lexes so the parser can say
371    /// so and point at `Int.rem` / `Int.mod`, not report a stray character.
372    #[token("%")]
373    Percent,
374    #[token("!")]
375    Bang,
376    #[token("=")]
377    Eq,
378    #[token("<")]
379    Lt,
380    #[token(">")]
381    Gt,
382    // v0.1 postfix operator
383    #[token("?")]
384    Question,
385    // v0.2 match-arm arrow
386    #[token("=>")]
387    FatArrow,
388    // v0.2 wildcard pattern. An identifier must start with a letter, so
389    // `_foo` lexes as `_` then `foo`, never as one Ident.
390    #[token("_")]
391    Underscore,
392    // v0.2 sum-type variant separator (also used as future bitwise OR);
393    // single `|` distinct from `||`.
394    #[token("|")]
395    Pipe,
396    /// `@` — storage-annotation marker (v0.85, storage track; ADR 0111). Leads a
397    /// `@name(args)` annotation on a `store` field (`@ttl(…)`/`@indexed(…)`); it
398    /// appears only in store-field-declaration position, never as an expression
399    /// operator.
400    #[token("@")]
401    At,
402
403    // Punctuation
404    #[token("(")]
405    LParen,
406    #[token(")")]
407    RParen,
408    #[token("{")]
409    LBrace,
410    #[token("}")]
411    RBrace,
412    #[token("[")]
413    LBracket,
414    #[token("]")]
415    RBracket,
416    #[token(",")]
417    Comma,
418    #[token(":")]
419    Colon,
420    #[token(".")]
421    Dot,
422}
423
424impl TokenKind {
425    /// Human-readable display name for diagnostics.
426    pub fn describe(self) -> &'static str {
427        use TokenKind::*;
428        match self {
429            Commons => "`commons`",
430            Type => "`type`",
431            Event => "`event`",
432            Asset => "`asset`",
433            Embed => "`embed`",
434            Const => "`const`",
435            Fn => "`fn`",
436            Where => "`where`",
437            True => "`true`",
438            False => "`false`",
439            Int => "`Int`",
440            String => "`String`",
441            Bool => "`Bool`",
442            Float => "`Float`",
443            Duration => "`Duration`",
444            Instant => "`Instant`",
445            Bytes => "`Bytes`",
446            Decimal => "`Decimal`",
447            Let => "`let`",
448            If => "`if`",
449            Else => "`else`",
450            Ok => "`Ok`",
451            Err => "`Err`",
452            Result => "`Result`",
453            ValidationError => "`ValidationError`",
454            JsonError => "`JsonError`",
455            Enum => "`enum`",
456            Match => "`match`",
457            Option => "`Option`",
458            Record => "`record`",
459            Self_ => "`self`",
460            Some => "`Some`",
461            None => "`None`",
462            Is => "`is`",
463            Opaque => "`opaque`",
464            Uses => "`uses`",
465            Context => "`context`",
466            Consumes => "`consumes`",
467            Exports => "`exports`",
468            Transparent => "`transparent`",
469            As => "`as`",
470            Expect => "`expect`",
471            Suite => "`suite`",
472            Case => "`case`",
473            Property => "`property`",
474            Adapter => "`adapter`",
475            Binding => "`binding`",
476            Agent => "`agent`",
477            Capability => "`capability`",
478            Effect => "`Effect`",
479            Do => "`do`",
480            Given => "`given`",
481            On => "`on`",
482            Http => "`http`",
483            Cron => "`cron`",
484            Queue => "`queue`",
485            From => "`from`",
486            Protocol => "`protocol`",
487            Provides => "`provides`",
488            Stub => "`stub`",
489            Service => "`service`",
490            Actor => "`actor`",
491            By => "`by`",
492            Invariant => "`invariant`",
493            Implies => "`implies`",
494            Requires => "`requires`",
495            Ensures => "`ensures`",
496            Transition => "`transition`",
497            Messages => "`messages`",
498            ColonEq => "`:=`",
499            DotDotDot => "`...`",
500            DotDot => "`..`",
501            LArrow => "`<-`",
502            TildeArrow => "`~>`",
503            DocBlock => "documentation block",
504            DocLine => "one-line documentation (`--- text`)",
505            Comment => "line comment",
506            Ident => "identifier",
507            IntLit => "integer literal",
508            FloatLit => "float literal",
509            DecimalLit => "decimal literal",
510            StrLit => "string literal",
511            InterpStr => "interpolated string",
512            Arrow => "`->`",
513            EqEq => "`==`",
514            BangEq => "`!=`",
515            LtEq => "`<=`",
516            GtEq => "`>=`",
517            AmpAmp => "`&&`",
518            PipePipe => "`||`",
519            Plus => "`+`",
520            Minus => "`-`",
521            Star => "`*`",
522            Slash => "`/`",
523            Percent => "`%`",
524            Bang => "`!`",
525            Eq => "`=`",
526            Lt => "`<`",
527            Gt => "`>`",
528            Question => "`?`",
529            FatArrow => "`=>`",
530            Underscore => "`_`",
531            Pipe => "`|`",
532            At => "`@`",
533            LParen => "`(`",
534            RParen => "`)`",
535            LBrace => "`{`",
536            RBrace => "`}`",
537            LBracket => "`[`",
538            RBracket => "`]`",
539            Comma => "`,`",
540            Colon => "`:`",
541            Dot => "`.`",
542        }
543    }
544}
545
546/// A token plus its source span.
547#[derive(Debug, Clone, Copy)]
548pub struct Token {
549    pub kind: TokenKind,
550    pub span: Span,
551}
552
553/// Tokenise a source string with no real file identity — every span's
554/// [`FileId`] defaults to [`FileId::UNKNOWN`]. See [`tokenize_in`] for the
555/// real-identity entry point production callers use.
556pub fn tokenize(source: &str) -> Result<Vec<Token>, CompileError> {
557    tokenize_in(source, FileId::UNKNOWN)
558}
559
560/// Tokenise a source string, stamping every token's span with `file`.
561/// Returns the full token vector or the first lexical error
562/// ([`tokenize_recovering_in`] keeps going past it).
563///
564/// Doc blocks (`---` ... `---`), one-line docs (`--- text`) and line comments
565/// (`// ...`) are recognised outside the logos-generated lexer: we scan the
566/// source one segment at a time, dispatching to logos for ordinary tokens
567/// between non-token spans.
568pub fn tokenize_in(source: &str, file: FileId) -> Result<Vec<Token>, CompileError> {
569    tokenize_mode(source, file, false)
570}
571
572/// #2203: tokenise `source` as the lexer read it before `//` comments, for
573/// the formatter's migration of a file that still uses the legacy spellings.
574/// A `--` opens a comment wherever it starts the input or follows whitespace
575/// (ADR 0188 D1), so `--note` and a `--- Helpers ---` divider are comments, as
576/// they were; `--|` first on its line is a doc line. `//` comments and `---`
577/// blocks lex as in [`tokenize`], so a half-migrated file reads too. Every
578/// legacy token keeps its legacy spelling, so [`comment_body`] and
579/// [`doc_line_text`] read it and the formatter prints the new one.
580pub fn tokenize_legacy(source: &str) -> Result<Vec<Token>, CompileError> {
581    tokenize_mode(source, FileId::UNKNOWN, true)
582}
583
584fn tokenize_mode(source: &str, file: FileId, legacy: bool) -> Result<Vec<Token>, CompileError> {
585    let (tokens, errors) = tokenize_mode_recovering(source, file, legacy);
586    match errors.into_iter().next() {
587        Some(first) => Err(first),
588        None => Ok(tokens),
589    }
590}
591
592/// #2807: tokenise `source`, recovering from lexical errors instead of
593/// stopping at the first. Every error is returned, in source order, beside the
594/// tokens of everything the lexer could read: a bad character, a malformed or
595/// out-of-range literal, an unterminated string, an unclosed doc block and a
596/// `///` doc habit each yield an error and are then skipped (a string to the end
597/// of its line), so the text after them still lexes. The skipped text produces
598/// no token, which the parser sees as a gap; [`without_lex_echoes`] drops the
599/// syntax errors that gap causes.
600///
601/// The strict entry points ([`tokenize`] and kin) are this function with the
602/// first error kept and the tokens dropped. Callers that must not act on a file
603/// holding any lex error (the formatter, which would delete the skipped text)
604/// use those.
605pub fn tokenize_recovering_in(source: &str, file: FileId) -> (Vec<Token>, Vec<CompileError>) {
606    tokenize_mode_recovering(source, file, false)
607}
608
609/// [`tokenize_recovering_in`] with no real file identity.
610pub fn tokenize_recovering(source: &str) -> (Vec<Token>, Vec<CompileError>) {
611    tokenize_recovering_in(source, FileId::UNKNOWN)
612}
613
614/// The offset just past the line holding `from` (its newline included), or the
615/// end of `bytes`. A newline is ASCII, so the result is a character boundary.
616fn skip_line(bytes: &[u8], from: usize) -> usize {
617    match bytes[from.min(bytes.len())..]
618        .iter()
619        .position(|&b| b == b'\n')
620    {
621        Some(i) => from + i + 1,
622        None => bytes.len(),
623    }
624}
625
626/// Where lexing resumes after `err`, a failed string literal opened at `pos`.
627/// An unterminated literal has no closer, so the rest of its line goes. Any
628/// other fault (a bad escape, an under-indented body line, a bad hole) leaves a
629/// literal that does close: resume after its real closer, found by rescanning
630/// with escapes skipped, so the body and the closing quote are not lexed as code.
631/// No closer on the line (or at all, for a multi-line literal) falls back to
632/// skipping the line.
633fn string_error_resume(bytes: &[u8], pos: usize, err: &CompileError) -> usize {
634    let fallback = skip_line(bytes, err.span.end.max(pos + 1));
635    if err.category == "bynk.lex.unterminated_string" {
636        return fallback;
637    }
638    let Some(open) = string_open(bytes, pos) else {
639        return fallback;
640    };
641    let mut p = open.body;
642    while p < bytes.len() {
643        match bytes[p] {
644            b'\\' if !open.raw && bytes.get(p + 1) == Some(&b'(') => {
645                // An interpolation hole: its own strings and parens are not
646                // the literal's closer.
647                match hole_end(bytes, p + 2) {
648                    Some(end) => p = end,
649                    None => return fallback,
650                }
651            }
652            b'\\' if !open.raw => p += 2,
653            b'\n' if !open.multi => return fallback,
654            b'"' if open.multi => {
655                if bytes[p..].starts_with(b"\"\"\"") {
656                    return p + 3;
657                }
658                p += 1;
659            }
660            b'"' => {
661                let hashes = &bytes[p + 1..];
662                if hashes.len() >= open.hashes && hashes[..open.hashes].iter().all(|&b| b == b'#') {
663                    return p + 1 + open.hashes;
664                }
665                p += 1;
666            }
667            _ => p += 1,
668        }
669    }
670    fallback
671}
672
673/// The offset just past the `)` closing an interpolation hole whose body starts
674/// at `from`, skipping nested parens and string literals. `None` if it does not
675/// close before a newline or the end.
676fn hole_end(bytes: &[u8], from: usize) -> Option<usize> {
677    let (mut p, mut depth) = (from, 1usize);
678    while p < bytes.len() {
679        match bytes[p] {
680            b'\n' => return None,
681            b'(' => depth += 1,
682            b')' => {
683                depth -= 1;
684                if depth == 0 {
685                    return Some(p + 1);
686                }
687            }
688            b'"' => {
689                p += 1;
690                while p < bytes.len() && bytes[p] != b'"' {
691                    match bytes[p] {
692                        b'\n' => return None,
693                        b'\\' => p += 1,
694                        _ => {}
695                    }
696                    p += 1;
697                }
698            }
699            _ => {}
700        }
701        p += 1;
702    }
703    None
704}
705
706/// Record `err`, folding a run of adjacent stray characters into one error (a
707/// pasted block of them would otherwise be one diagnostic per character).
708fn push_lex_error(errors: &mut Vec<CompileError>, err: CompileError) {
709    const UNEXPECTED: &str = "bynk.lex.unexpected_character";
710    if err.category == UNEXPECTED
711        && let Some(prev) = errors.last_mut()
712        && prev.category == UNEXPECTED
713        && prev.span.end == err.span.start
714    {
715        prev.span.end = err.span.end;
716        prev.message = "unexpected characters".to_string();
717        return;
718    }
719    errors.push(err);
720}
721
722fn tokenize_mode_recovering(
723    source: &str,
724    file: FileId,
725    legacy: bool,
726) -> (Vec<Token>, Vec<CompileError>) {
727    let mut tokens = Vec::new();
728    let mut errors: Vec<CompileError> = Vec::new();
729    let bytes = source.as_bytes();
730    let mut pos = 0;
731    while pos < bytes.len() {
732        // Detect a `---` doc-block marker at the start of a line (the line may
733        // begin with leading whitespace; the marker itself must be alone on
734        // its line).
735        if let Some((open_end, dashes)) = doc_block_open_at(source, pos) {
736            // Find the matching closing marker: a line of exactly as many
737            // dashes as the opener (#1885), so a `----` block can hold `---`.
738            match doc_block_close(source, open_end, dashes) {
739                Some((close_start, close_end)) => {
740                    let span = Span::new_in(file, pos, close_end);
741                    tokens.push(Token {
742                        kind: TokenKind::DocBlock,
743                        span,
744                    });
745                    let _ = close_start;
746                    pos = close_end;
747                    continue;
748                }
749                None => {
750                    errors.push(CompileError::new(
751                        "bynk.lex.unclosed_doc_block",
752                        Span::new_in(file, pos, open_end),
753                        "documentation block opened but never closed",
754                    )
755                    .with_note(format!(
756                        "a doc block is closed by a line of exactly {dashes} dashes on its own; \
757                         a line of three or more dashes is a doc-block marker, not a divider, so \
758                         for a divider use a line comment, such as `// ---------`"
759                    )));
760                    // The block is the rest of the file, as the error says: its
761                    // prose lexed as code would only add errors.
762                    pos = bytes.len();
763                    continue;
764                }
765            }
766        }
767        let line_first = only_indent_before(bytes, pos);
768        // #2203: a `--- text` line is a one-line doc, its span running through
769        // the newline as a doc block's does. Before #2203 it was a `--`
770        // comment, and a legacy read keeps it one.
771        if line_first && doc_line_marker(bytes, pos) {
772            if legacy {
773                pos = push_comment(&mut tokens, bytes, pos, file);
774            } else {
775                pos = push_doc_line(&mut tokens, bytes, pos, file);
776            }
777            continue;
778        }
779        // #2203 (DECISION E): `///` first on its line is a Rust or C# doc
780        // habit. It never lexed (Bynk has no prefix `/`), so it is a plain
781        // lexical error pointing at `---`. A longer run of slashes is a `//`
782        // comment, as `////` is in Rust.
783        if line_first && bytes[pos..].starts_with(b"///") && bytes.get(pos + 3) != Some(&b'/') {
784            errors.push(slash_doc_error(bytes, pos, file));
785            pos = skip_line(bytes, pos);
786            continue;
787        }
788        // #1888: the legacy `--|` doc line, first on its line. It still lexes
789        // as a doc line; the parser reports its spelling.
790        if line_first && bytes[pos..].starts_with(b"--|") {
791            pos = push_doc_line(&mut tokens, bytes, pos, file);
792            continue;
793        }
794        // #2203: a `//` line comment, anywhere: emit a `Comment` token covering
795        // everything up to (but not including) the terminating newline.
796        // Preserving comments as trivia tokens lets the parser attach them to
797        // declarations so the formatter can emit them in place (v1.1 LSP spec
798        // §3.5). There is no prefix `/`, so `a//b` cannot mean anything else
799        // and needs no whitespace rule.
800        if bytes[pos..].starts_with(b"//") {
801            pos = push_comment(&mut tokens, bytes, pos, file);
802            continue;
803        }
804        // The legacy `--` comment (DECISION E): at the start of the input or
805        // after whitespace, followed by a space or the end of the line, or by
806        // `|` (a `--|` after code was a comment, #1888 DECISION E). It
807        // still lexes as a comment, so an old `x = 1 -- note` is never read as
808        // `1 - -note`; the parser reports its spelling. Any other `--` is two
809        // `-` operators, so `a--b` and `x--` are subtraction. A legacy read
810        // takes ADR 0188 D1's rule instead: any whitespace-preceded `--`.
811        let after_space = pos == 0 || matches!(bytes[pos - 1], b' ' | b'\t' | b'\r' | b'\n');
812        if after_space
813            && bytes[pos..].starts_with(b"--")
814            && (legacy
815                || matches!(
816                    bytes.get(pos + 2),
817                    None | Some(b' ' | b'\t' | b'\r' | b'\n' | b'|')
818                ))
819        {
820            pos = push_comment(&mut tokens, bytes, pos, file);
821            continue;
822        }
823        // Skip ordinary whitespace inline (logos handles it too, but we may
824        // be in the middle of the source between specials).
825        if matches!(bytes[pos], b' ' | b'\t' | b'\r' | b'\n') {
826            pos += 1;
827            continue;
828        }
829        // A string literal in any of its forms (#2619): plain, raw, or
830        // multi-line. One hand-scanner validates and decodes the literal; a
831        // literal holding a `\(` hole lexes as `InterpStr`, any other as
832        // `StrLit`.
833        if string_open(bytes, pos).is_some() {
834            let (end, segments) = match lex_string(source, pos, 0, file) {
835                Ok(lexed) => lexed,
836                Err(e) => {
837                    // The literal's remains are not code; lexing them as such
838                    // would only echo the error.
839                    pos = string_error_resume(bytes, pos, &e);
840                    errors.push(e);
841                    continue;
842                }
843            };
844            let kind = if segments.iter().any(|s| matches!(s, InterpSegment::Hole(_))) {
845                TokenKind::InterpStr
846            } else {
847                TokenKind::StrLit
848            };
849            tokens.push(Token {
850                kind,
851                span: Span::new_in(file, pos, end),
852            });
853            pos = end;
854            continue;
855        }
856        // Otherwise dispatch a single logos token starting at `pos`.
857        let mut lex = TokenKind::lexer(&source[pos..]);
858        let Some(result) = lex.next() else {
859            // No token at this position; treat as unexpected character so
860            // the user sees something useful.
861            let ch = source[pos..].chars().next().unwrap_or('\0');
862            let span = Span::new_in(file, pos, pos + ch.len_utf8());
863            push_lex_error(
864                &mut errors,
865                CompileError::new(
866                    "bynk.lex.unexpected_character",
867                    span,
868                    format!("unexpected character `{ch}`"),
869                ),
870            );
871            pos += ch.len_utf8();
872            continue;
873        };
874        let local = lex.span();
875        let span: Span = Span::new_in(file, pos + local.start, pos + local.end);
876        match result {
877            Ok(kind) => {
878                // #1657 (runtime-semantics track §3.4): an `Int` is a JS safe
879                // integer, ±(2^53 − 1), the range the runtime represents
880                // exactly. A larger literal would round silently
881                // (`9007199254740993 == 9007199254740992`). The sign is a
882                // separate operator, and the bound is symmetric, so the
883                // magnitude decides.
884                if kind == TokenKind::IntLit {
885                    let slice = &source[span.range()];
886                    if !strip_digit_separators(slice)
887                        .parse::<i64>()
888                        .is_ok_and(|n| n <= MAX_SAFE_INT)
889                    {
890                        errors.push(
891                            CompileError::new(
892                                "bynk.lex.integer_overflow",
893                                span,
894                                format!("integer literal `{slice}` is out of range for an `Int`"),
895                            )
896                            .with_note(
897                                "an `Int` is a safe integer, -(2^53 - 1) to 2^53 - 1 \
898                             (±9007199254740991); use a `Float` or a `String` for a larger value",
899                            ),
900                        );
901                        pos = span.end;
902                        continue;
903                    }
904                }
905                if kind == TokenKind::DecimalLit {
906                    // #2622: the limits every `Decimal` obeys, so a literal
907                    // always encodes to text the wire decoder accepts.
908                    let slice = &source[span.range()];
909                    let body = strip_digit_separators(&slice[..slice.len() - 1]).into_owned();
910                    let frac = body.split_once('.').map_or(0, |(_, f)| f.len());
911                    if frac > 100 || body.len() > 1000 {
912                        errors.push(
913                            CompileError::new(
914                                "bynk.lex.decimal_literal_too_large",
915                                span,
916                                "decimal literal is too large",
917                            )
918                            .with_note(
919                                "a `Decimal` has at most 100 fraction digits and 1000 characters",
920                            ),
921                        );
922                        pos = span.end;
923                        continue;
924                    }
925                }
926                if kind == TokenKind::FloatLit {
927                    let slice = &source[span.range()];
928                    match strip_digit_separators(slice).parse::<f64>() {
929                        Ok(v) if v.is_finite() => {}
930                        _ => {
931                            errors.push(
932                                CompileError::new(
933                                    "bynk.lex.float_literal_overflow",
934                                    span,
935                                    format!(
936                                        "float literal `{slice}` is out of range for a 64-bit float"
937                                    ),
938                                )
939                                .with_note(
940                                    "the literal does not fit a finite IEEE 754 double; \
941                                 the largest finite value is ~1.8e308",
942                                ),
943                            );
944                            pos = span.end;
945                            continue;
946                        }
947                    }
948                }
949                tokens.push(Token { kind, span });
950                pos = span.end;
951            }
952            Err(()) => {
953                let slice = &source[span.range()];
954                let ch = slice.chars().next().unwrap_or('\0');
955                let err = if ch == '"' {
956                    CompileError::new(
957                        "bynk.lex.unterminated_string",
958                        span,
959                        "unterminated string literal",
960                    )
961                    .with_note(
962                        "string literals must close with `\"` on the same line; \
963                         supported escapes are `\\n`, `\\t`, `\\\"`, `\\\\`",
964                    )
965                } else {
966                    CompileError::new(
967                        "bynk.lex.unexpected_character",
968                        span,
969                        format!("unexpected character `{ch}`"),
970                    )
971                };
972                push_lex_error(&mut errors, err);
973                pos = if ch == '"' {
974                    skip_line(bytes, span.end.max(pos + 1))
975                } else {
976                    span.end.max(pos + ch.len_utf8())
977                };
978            }
979        }
980    }
981    (tokens, errors)
982}
983
984/// #2807: `parse_errors` without the ones a lexical error caused. Recovery
985/// skips the text a lex error covers, so the parser meets a gap where the
986/// literal or character stood and complains about it at the same spot. An error
987/// that starts in a lex error's span, or at the token right after it, is that
988/// echo and is dropped; every other syntax error stays. `tokens` is the
989/// recovering lex's token list. A heuristic: a genuine syntax error at that next
990/// token (the `fn` after a stray character on its own line) is dropped with the
991/// echo, and surfaces once the lexical error is fixed.
992pub fn without_lex_echoes(
993    lex_errors: &[CompileError],
994    tokens: &[Token],
995    parse_errors: Vec<CompileError>,
996) -> Vec<CompileError> {
997    let echo_ranges: Vec<(usize, usize)> = lex_errors
998        .iter()
999        .map(|le| {
1000            // `tokens` are in source order. Trivia is not a token the parser
1001            // sees, and with no token after the error the gap runs to the end
1002            // of the file (an EOF error is its echo).
1003            let first = tokens.partition_point(|t| t.span.start < le.span.end);
1004            let next_end = tokens[first..]
1005                .iter()
1006                .find(|t| !matches!(t.kind, TokenKind::Comment | TokenKind::DocLine))
1007                .map_or(usize::MAX, |t| t.span.end);
1008            (le.span.start, next_end)
1009        })
1010        .collect();
1011    parse_errors
1012        .into_iter()
1013        .filter(|e| {
1014            !echo_ranges
1015                .iter()
1016                .any(|&(lo, hi)| e.span.start >= lo && e.span.start <= hi)
1017        })
1018        .collect()
1019}
1020
1021/// Like [`tokenize`], but with every interpolated-string token replaced by the
1022/// tokens of its holes — each hole's bytes re-lexed and its token spans rebased
1023/// to absolute source positions (the same rebase [`crate::parser`] applies when
1024/// parsing a hole), recursing through nested interpolation. Chunk (literal) text
1025/// between holes yields no tokens.
1026///
1027/// An interpolated string lexes to a single opaque `InterpStr` token, so the
1028/// LSP's token-based cursor resolution (hover, go-to-definition, references,
1029/// semantic tokens) is otherwise blind to identifiers inside `"… \(name) …"`.
1030/// Expanding the holes makes those identifiers visible as ordinary `Ident`
1031/// tokens with their real spans. (Issue #473.)
1032///
1033/// On a malformed interpolation (an `InterpStr` whose holes don't split, or a
1034/// hole whose bytes don't re-lex) the offending token is kept opaque rather than
1035/// dropped, so resolution degrades to the pre-fix behaviour instead of losing
1036/// tokens.
1037pub fn tokenize_expanding_holes(source: &str) -> Result<Vec<Token>, CompileError> {
1038    tokenize_expanding_holes_in(source, FileId::UNKNOWN)
1039}
1040
1041/// Like [`tokenize_expanding_holes`], but stamping every token's span
1042/// (including rebased hole tokens) with `file`.
1043pub fn tokenize_expanding_holes_in(source: &str, file: FileId) -> Result<Vec<Token>, CompileError> {
1044    let mut out = Vec::new();
1045    for tok in tokenize_in(source, file)? {
1046        expand_hole_token(source, file, tok, &mut out);
1047    }
1048    Ok(out)
1049}
1050
1051/// Push `tok` onto `out`, expanding it into its holes' tokens if it is an
1052/// `InterpStr` (see [`tokenize_expanding_holes`]); otherwise push it as-is.
1053fn expand_hole_token(source: &str, file: FileId, tok: Token, out: &mut Vec<Token>) {
1054    if tok.kind != TokenKind::InterpStr {
1055        out.push(tok);
1056        return;
1057    }
1058    let Ok(segments) = split_interp(source, tok.span) else {
1059        out.push(tok); // malformed interpolation — keep the opaque token
1060        return;
1061    };
1062    for segment in segments {
1063        let InterpSegment::Hole(hole) = segment else {
1064            continue; // chunk text carries no tokens
1065        };
1066        let Ok(hole_tokens) = tokenize_in(&source[hole.range()], file) else {
1067            continue;
1068        };
1069        for mut t in hole_tokens {
1070            // Rebase the hole's local spans to absolute source positions.
1071            t.span = Span::new_in(file, t.span.start + hole.start, t.span.end + hole.start);
1072            expand_hole_token(source, file, t, out); // recurse for nested interpolation
1073        }
1074    }
1075}
1076
1077// ---- String literals (v0.43 interpolation; #2619 raw / multi-line / more escapes) ----
1078
1079/// The shape of a string literal opening at some offset.
1080#[derive(Clone, Copy)]
1081struct StringOpen {
1082    /// `r"…"`, `r#"…"#`, `r"""…"""`: no escapes, no interpolation.
1083    raw: bool,
1084    /// `"""…"""` / `r"""…"""`: the body spans lines.
1085    multi: bool,
1086    /// Number of `#` between the `r` and the opening quote (single-line raw only).
1087    hashes: usize,
1088    /// Offset just past the opening delimiter (`"`, `"""`, `r#"`, …).
1089    body: usize,
1090}
1091
1092fn is_ident_byte(b: u8) -> bool {
1093    b.is_ascii_alphanumeric() || b == b'_'
1094}
1095
1096/// Does a string literal open at `start`? `r` opens a raw string only when it
1097/// is *immediately* followed by `#`s and a `"`, so an identifier `r` followed by
1098/// a (spaced) string is unaffected.
1099fn string_open(bytes: &[u8], start: usize) -> Option<StringOpen> {
1100    let raw = bytes.get(start) == Some(&b'r');
1101    let mut p = start + usize::from(raw);
1102    let mut hashes = 0;
1103    if raw {
1104        while bytes.get(p) == Some(&b'#') {
1105            hashes += 1;
1106            p += 1;
1107        }
1108    }
1109    if bytes.get(p) != Some(&b'"') {
1110        return None;
1111    }
1112    let multi = hashes == 0 && bytes[p..].starts_with(b"\"\"\"");
1113    Some(StringOpen {
1114        raw,
1115        multi,
1116        hashes,
1117        body: if multi { p + 3 } else { p + 1 },
1118    })
1119}
1120
1121fn unterminated_string(file: FileId, start: usize, end: usize, multi: bool) -> CompileError {
1122    let note = if multi {
1123        "a multi-line string closes with `\"\"\"` on a line of its own"
1124    } else {
1125        "string literals must close with `\"` on the same line (use `\"\"\"` for a multi-line string); \
1126         supported escapes: \\n \\r \\t \\0 \\\" \\\\ \\u{…} and `\\(…)` interpolation"
1127    };
1128    CompileError::new(
1129        "bynk.lex.unterminated_string",
1130        Span::new_in(file, start, end),
1131        "unterminated string literal",
1132    )
1133    .with_note(note)
1134}
1135
1136/// Span of the byte range `from..to`, advanced to a char boundary so it never
1137/// splits a multibyte codepoint (a fuzz invariant).
1138fn boundary_span(source: &str, file: FileId, from: usize, to: usize) -> Span {
1139    let mut end = to.min(source.len());
1140    while end < source.len() && !source.is_char_boundary(end) {
1141        end += 1;
1142    }
1143    Span::new_in(file, from, end)
1144}
1145
1146const ESCAPE_NOTE: &str = "supported escapes: \\n \\r \\t \\0 \\\" \\\\ \\u{…} \\(…)";
1147
1148/// Scan and decode the string literal opening at `start`, returning the offset
1149/// just past it and its segments (literal chunks with escapes resolved, and the
1150/// spans of any `\(…)` holes). This is the single scanner for every form.
1151fn lex_string(
1152    source: &str,
1153    start: usize,
1154    depth: usize,
1155    file: FileId,
1156) -> Result<(usize, Vec<InterpSegment>), CompileError> {
1157    let bytes = source.as_bytes();
1158    let open = string_open(bytes, start).expect("caller checked string_open");
1159    if depth > crate::MAX_NESTING_DEPTH {
1160        // Anchor on the opening `"` of the string that tipped over the limit.
1161        return Err(too_deeply_nested_interpolation(Span::new_in(
1162            file,
1163            start,
1164            start + 1,
1165        )));
1166    }
1167    let mut segments = Vec::new();
1168    let mut chunk = String::new();
1169    if open.multi {
1170        let end = lex_multiline(source, start, open, depth, file, &mut segments, &mut chunk)?;
1171        if !chunk.is_empty() {
1172            segments.push(InterpSegment::Chunk(chunk));
1173        }
1174        return Ok((end, segments));
1175    }
1176    if open.raw {
1177        // `r#"…"#`: the body runs to the first `"` followed by `hashes` `#`s.
1178        let mut i = open.body;
1179        loop {
1180            if i >= bytes.len() || bytes[i] == b'\n' {
1181                return Err(unterminated_string(file, start, i.min(bytes.len()), false));
1182            }
1183            if bytes[i] == b'"' && (1..=open.hashes).all(|k| bytes.get(i + k) == Some(&b'#')) {
1184                break;
1185            }
1186            i += 1;
1187        }
1188        chunk.push_str(&source[open.body..i]);
1189        if !chunk.is_empty() {
1190            segments.push(InterpSegment::Chunk(chunk));
1191        }
1192        return Ok((i + 1 + open.hashes, segments));
1193    }
1194    let end = decode_run(
1195        source,
1196        open.body,
1197        Stop::Quote(start),
1198        depth,
1199        file,
1200        &mut segments,
1201        &mut chunk,
1202    )?;
1203    if !chunk.is_empty() {
1204        segments.push(InterpSegment::Chunk(chunk));
1205    }
1206    Ok((end + 1, segments))
1207}
1208
1209/// Where a [`decode_run`] stops.
1210#[derive(Clone, Copy)]
1211enum Stop {
1212    /// At the closing `"` of a single-line string (the payload is the literal's
1213    /// start, for the unterminated error); a newline or end of input first is an
1214    /// error.
1215    Quote(usize),
1216    /// At a fixed offset: one line of a multi-line string, where a bare `"""`
1217    /// inside the line is an error (the closing delimiter has a line to itself).
1218    End(usize),
1219}
1220
1221/// Decode non-raw string text from `from`, appending to `chunk` / `segments`.
1222/// Returns the offset where it stopped (the closing `"` for [`Stop::Quote`]).
1223fn decode_run(
1224    source: &str,
1225    from: usize,
1226    stop: Stop,
1227    depth: usize,
1228    file: FileId,
1229    segments: &mut Vec<InterpSegment>,
1230    chunk: &mut String,
1231) -> Result<usize, CompileError> {
1232    let bytes = source.as_bytes();
1233    let limit = match stop {
1234        Stop::Quote(_) => bytes.len(),
1235        Stop::End(e) => e,
1236    };
1237    let mut i = from;
1238    loop {
1239        if i >= limit {
1240            match stop {
1241                Stop::End(_) => return Ok(i),
1242                Stop::Quote(start) => {
1243                    return Err(unterminated_string(file, start, i.min(bytes.len()), false));
1244                }
1245            }
1246        }
1247        match bytes[i] {
1248            b'\n' if matches!(stop, Stop::Quote(_)) => {
1249                let Stop::Quote(start) = stop else {
1250                    unreachable!()
1251                };
1252                return Err(unterminated_string(file, start, i, false));
1253            }
1254            b'"' => match stop {
1255                Stop::Quote(_) => return Ok(i),
1256                Stop::End(e) => {
1257                    if bytes[i..e].starts_with(b"\"\"\"") {
1258                        return Err(CompileError::new(
1259                            "bynk.lex.multiline_close_not_own_line",
1260                            Span::new_in(file, i, i + 3),
1261                            "a `\"\"\"` inside a multi-line string closes it, but it is not on a line of its own",
1262                        )
1263                        .with_note(
1264                            "put the closing `\"\"\"` on its own line, or escape a quote as `\\\"`",
1265                        ));
1266                    }
1267                    chunk.push('"');
1268                    i += 1;
1269                }
1270            },
1271            b'\\' => {
1272                let next = if i + 1 < limit {
1273                    Some(bytes[i + 1])
1274                } else {
1275                    None
1276                };
1277                match next {
1278                    Some(b'n') => {
1279                        chunk.push('\n');
1280                        i += 2;
1281                    }
1282                    Some(b't') => {
1283                        chunk.push('\t');
1284                        i += 2;
1285                    }
1286                    Some(b'r') => {
1287                        chunk.push('\r');
1288                        i += 2;
1289                    }
1290                    Some(b'0') => {
1291                        chunk.push('\0');
1292                        i += 2;
1293                    }
1294                    Some(b'"') => {
1295                        chunk.push('"');
1296                        i += 2;
1297                    }
1298                    Some(b'\\') => {
1299                        chunk.push('\\');
1300                        i += 2;
1301                    }
1302                    Some(b'u') => {
1303                        let (ch, after) = decode_unicode_escape(source, i, limit, file)?;
1304                        chunk.push(ch);
1305                        i = after;
1306                    }
1307                    Some(b'(') => {
1308                        if !chunk.is_empty() {
1309                            segments.push(InterpSegment::Chunk(std::mem::take(chunk)));
1310                        }
1311                        let hole_start = i + 2;
1312                        let after = scan_hole(bytes, source, hole_start, depth + 1, file)?;
1313                        // `after` is one past the matching `)`; the hole body is
1314                        // everything up to that `)`.
1315                        segments.push(InterpSegment::Hole(Span::new_in(
1316                            file,
1317                            hole_start,
1318                            after - 1,
1319                        )));
1320                        i = after;
1321                    }
1322                    other => {
1323                        let shown = other.map(|b| (b as char).to_string()).unwrap_or_default();
1324                        // Cover `\` plus the whole offending char, advanced to a
1325                        // char boundary.
1326                        return Err(CompileError::new(
1327                            "bynk.lex.bad_escape",
1328                            boundary_span(source, file, i, i + 2),
1329                            format!("invalid escape sequence `\\{shown}` in string literal"),
1330                        )
1331                        .with_note(ESCAPE_NOTE));
1332                    }
1333                }
1334            }
1335            // Any other byte: copy the whole char. UTF-8 continuation bytes are
1336            // all >= 0x80, so they never collide with the ASCII specials.
1337            _ => {
1338                let ch = source[i..].chars().next().unwrap();
1339                chunk.push(ch);
1340                i += ch.len_utf8();
1341            }
1342        }
1343    }
1344}
1345
1346/// Decode `\u{…}` at `i` (the `\`): 1–6 hex digits naming a Unicode scalar
1347/// value. Returns the char and the offset past the `}`. A lone surrogate
1348/// (D800–DFFF) or a value above 10FFFF is a lexical error, so every literal is
1349/// valid UTF-8 text.
1350fn decode_unicode_escape(
1351    source: &str,
1352    i: usize,
1353    limit: usize,
1354    file: FileId,
1355) -> Result<(char, usize), CompileError> {
1356    let bytes = source.as_bytes();
1357    let malformed = |end: usize| {
1358        CompileError::new(
1359            "bynk.lex.bad_escape",
1360            Span::new_in(file, i, end),
1361            "invalid `\\u{…}` escape in string literal",
1362        )
1363        .with_note(
1364            "a Unicode escape is `\\u{` followed by 1 to 6 hex digits and `}`, e.g. `\\u{1F600}`",
1365        )
1366    };
1367    let mut p = i + 2; // past `\u`
1368    if p >= limit || bytes[p] != b'{' {
1369        return Err(malformed(p));
1370    }
1371    p += 1;
1372    let digits_start = p;
1373    while p < limit && bytes[p].is_ascii_hexdigit() {
1374        p += 1;
1375    }
1376    let digits = p - digits_start;
1377    if digits == 0 || digits > 6 || p >= limit || bytes[p] != b'}' {
1378        return Err(malformed(p.min(limit)));
1379    }
1380    let value = u32::from_str_radix(&source[digits_start..p], 16).expect("hex digits");
1381    p += 1; // past `}`
1382    match char::from_u32(value) {
1383        Some(ch) => Ok((ch, p)),
1384        None => Err(CompileError::new(
1385            "bynk.lex.bad_unicode_escape",
1386            Span::new_in(file, i, p),
1387            format!("`\\u{{{value:X}}}` is not a Unicode scalar value"),
1388        )
1389        .with_note(
1390            "surrogate code points (D800 to DFFF) and values above 10FFFF cannot appear in a string",
1391        )),
1392    }
1393}
1394
1395/// Scan and decode a multi-line literal (`"""` / `r"""`) opening at `start`.
1396/// Returns the offset just past the closing `"""`. The closing delimiter sits on
1397/// a line of its own; its indentation is stripped from every body line, and a
1398/// non-blank line indented less is an error. Newlines in the value are always
1399/// `\n`, whatever the file's line endings.
1400fn lex_multiline(
1401    source: &str,
1402    start: usize,
1403    open: StringOpen,
1404    depth: usize,
1405    file: FileId,
1406    segments: &mut Vec<InterpSegment>,
1407    chunk: &mut String,
1408) -> Result<usize, CompileError> {
1409    let bytes = source.as_bytes();
1410    // The opening `"""` is followed by a newline (`\n` or `\r\n`).
1411    let mut pos = match (bytes.get(open.body), bytes.get(open.body + 1)) {
1412        (Some(b'\n'), _) => open.body + 1,
1413        (Some(b'\r'), Some(b'\n')) => open.body + 2,
1414        _ => {
1415            return Err(CompileError::new(
1416                "bynk.lex.multiline_open",
1417                Span::new_in(file, start, open.body),
1418                "the opening `\"\"\"` of a multi-line string must be followed by a newline",
1419            )
1420            .with_note("the text starts on the next line; write `\"\"\"` then press enter"));
1421        }
1422    };
1423    // Pass 1: split into lines up to the closing line.
1424    let mut lines: Vec<(usize, usize)> = Vec::new(); // (start, end) excluding the `\n` / `\r\n`
1425    let (indent_len, end) = loop {
1426        if pos >= bytes.len() {
1427            return Err(unterminated_string(file, start, bytes.len(), true));
1428        }
1429        let line_end = bytes[pos..]
1430            .iter()
1431            .position(|&b| b == b'\n')
1432            .map_or(bytes.len(), |n| pos + n);
1433        let mut ws = pos;
1434        while ws < line_end && matches!(bytes[ws], b' ' | b'\t') {
1435            ws += 1;
1436        }
1437        // A closing delimiter ends its line's text: `"""quoted"""` is body text
1438        // holding a stray `"""`, which `decode_run` reports.
1439        if bytes[ws..line_end].starts_with(b"\"\"\"")
1440            && !bytes
1441                .get(ws + 3)
1442                .is_some_and(|&b| is_ident_byte(b) || b == b'"')
1443        {
1444            break (ws - pos, ws + 3);
1445        }
1446        let trimmed_end = if line_end > pos && bytes[line_end - 1] == b'\r' {
1447            line_end - 1
1448        } else {
1449            line_end
1450        };
1451        lines.push((pos, trimmed_end));
1452        if line_end >= bytes.len() {
1453            return Err(unterminated_string(file, start, bytes.len(), true));
1454        }
1455        pos = line_end + 1;
1456    };
1457    let closing_line_start = end - 3 - indent_len;
1458    let indent = &bytes[closing_line_start..closing_line_start + indent_len];
1459    // Pass 2: strip the indentation and decode each line.
1460    for (n, &(ls, le)) in lines.iter().enumerate() {
1461        if n > 0 {
1462            chunk.push('\n');
1463        }
1464        let line = &bytes[ls..le];
1465        let blank = line.iter().all(|b| matches!(b, b' ' | b'\t'));
1466        let content = if line.starts_with(indent) {
1467            ls + indent_len
1468        } else if blank {
1469            le // a blank line shorter than the indentation is empty
1470        } else {
1471            return Err(CompileError::new(
1472                "bynk.lex.multiline_indent",
1473                boundary_span(source, file, ls, le),
1474                "a line of this multi-line string is indented less than its closing `\"\"\"`",
1475            )
1476            .with_note(if line.len() >= indent.len() {
1477                "the indentation must match the closing delimiter's character for character: \
1478                 a tab does not match a space, so mixed tabs and spaces fail here"
1479            } else {
1480                "every non-blank line must start with the closing delimiter's indentation, \
1481                 which is stripped from the value"
1482            }));
1483        };
1484        if open.raw {
1485            let text = &source[content..le];
1486            if let Some(at) = text.find("\"\"\"") {
1487                return Err(CompileError::new(
1488                    "bynk.lex.multiline_close_not_own_line",
1489                    Span::new_in(file, content + at, content + at + 3),
1490                    "a `\"\"\"` inside a multi-line string closes it, but it is not on a line of its own",
1491                )
1492                .with_note("put the closing `\"\"\"` on its own line"));
1493            }
1494            chunk.push_str(text);
1495        } else {
1496            decode_run(source, content, Stop::End(le), depth, file, segments, chunk)?;
1497        }
1498    }
1499    Ok(end)
1500}
1501
1502/// Scan an interpolation hole body. `start` points just past the `\(`; returns
1503/// the offset just past the matching `)`. Tracks paren depth and skips nested
1504/// strings (whose own parens must not close the hole), recursing through
1505/// [`lex_string`] so nested interpolation nests correctly. (v0.43.)
1506fn scan_hole(
1507    bytes: &[u8],
1508    source: &str,
1509    start: usize,
1510    nesting: usize,
1511    file: FileId,
1512) -> Result<usize, CompileError> {
1513    if nesting > crate::MAX_NESTING_DEPTH {
1514        // Anchor on the `\(` opener that tipped over the limit; it sits two
1515        // bytes before `start` and is pure ASCII, so the span stays on char
1516        // boundaries (a fuzz invariant).
1517        return Err(too_deeply_nested_interpolation(Span::new_in(
1518            file,
1519            start.saturating_sub(2),
1520            start,
1521        )));
1522    }
1523    let mut i = start;
1524    let mut depth = 1usize;
1525    loop {
1526        if i >= bytes.len() || bytes[i] == b'\n' {
1527            return Err(CompileError::new(
1528                "bynk.lex.unterminated_interpolation",
1529                Span::new_in(file, start.saturating_sub(2), i.min(bytes.len())),
1530                "unterminated interpolation hole",
1531            )
1532            .with_note(
1533                "an interpolation hole `\\(…)` must close with a matching `)` on the same line",
1534            ));
1535        }
1536        match bytes[i] {
1537            b'(' => {
1538                depth += 1;
1539                i += 1;
1540            }
1541            b')' => {
1542                depth -= 1;
1543                i += 1;
1544                if depth == 0 {
1545                    return Ok(i);
1546                }
1547            }
1548            b'"' => i = nested_string(source, i, nesting, file)?,
1549            // A nested raw string: `r"…"` (not the tail of an identifier).
1550            b'r' if (i == start || !is_ident_byte(bytes[i - 1]))
1551                && string_open(bytes, i).is_some() =>
1552            {
1553                i = nested_string(source, i, nesting, file)?
1554            }
1555            _ => i += 1,
1556        }
1557    }
1558}
1559
1560/// Skip a string literal nested in an interpolation hole. A hole closes on the
1561/// line it opens on, so a multi-line string cannot sit inside one (#2619).
1562fn nested_string(
1563    source: &str,
1564    at: usize,
1565    nesting: usize,
1566    file: FileId,
1567) -> Result<usize, CompileError> {
1568    if string_open(source.as_bytes(), at).is_some_and(|o| o.multi) {
1569        return Err(CompileError::new(
1570            "bynk.lex.multiline_in_hole",
1571            Span::new_in(file, at, at + 3),
1572            "a multi-line string cannot appear inside an interpolation hole",
1573        )
1574        .with_note(
1575            "an interpolation hole `\\(…)` closes on the line it opens on; \
1576             bind the multi-line text to a name and interpolate that",
1577        ));
1578    }
1579    Ok(lex_string(source, at, nesting + 1, file)?.0)
1580}
1581
1582/// The bounded-depth diagnostic for interpolation that nests past
1583/// [`crate::MAX_NESTING_DEPTH`]. `\("\("\(…` mutually recurses
1584/// [`lex_string`] ↔ [`scan_hole`], one stack frame per level, so an unbounded
1585/// scanner overflows and aborts `tokenize` (#713). `span` anchors the report on
1586/// the opener that tipped over the limit (the `"` or the `\(`).
1587fn too_deeply_nested_interpolation(span: Span) -> CompileError {
1588    CompileError::new(
1589        "bynk.lex.interpolation_too_deep",
1590        span,
1591        format!(
1592            "string interpolation nests more than {} levels deep",
1593            crate::MAX_NESTING_DEPTH
1594        ),
1595    )
1596    .with_note(
1597        "deeply nested `\\(…)` interpolation is rejected to keep the lexer from \
1598         overflowing its stack and aborting; flatten or split the string",
1599    )
1600}
1601
1602/// One segment of a split interpolated string (v0.43): literal text (escapes
1603/// resolved) or the absolute source span of a hole's expression (the bytes
1604/// between `\(` and its matching `)`). The parser turns the latter into a real
1605/// `Expr`; the lexer owns only the scanning.
1606pub(crate) enum InterpSegment {
1607    Chunk(String),
1608    Hole(Span),
1609}
1610
1611/// Split a string token (its `span` covers the whole lexeme) into chunks and
1612/// hole spans. Escapes in the chunks are resolved by [`lex_string`], the one
1613/// decoder every form shares; holes are returned as spans for the parser to
1614/// re-lex and parse as expressions. (v0.43, #2619.)
1615pub(crate) fn split_interp(source: &str, span: Span) -> Result<Vec<InterpSegment>, CompileError> {
1616    lex_string(source, span.start, 0, span.file).map(|(_, segments)| segments)
1617}
1618
1619/// A highlighted part of a string literal (#2619), for the semantic tokens.
1620#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1621pub enum StringPart {
1622    /// An escape sequence (`\n`, `\u{1F600}`, …).
1623    Escape,
1624    /// A raw literal's delimiter (`r#"` or `"#`), which the TextMate grammar
1625    /// cannot tell from an identifier `r` and a quote.
1626    RawDelimiter,
1627}
1628
1629/// The escape sequences and raw delimiters of every string literal in `source`
1630/// (including those inside interpolation holes), for the editor's semantic
1631/// tokens. Empty when the file does not lex.
1632pub fn string_parts(source: &str) -> Vec<(Span, StringPart)> {
1633    let mut out = Vec::new();
1634    if let Ok(tokens) = tokenize(source) {
1635        for tok in tokens {
1636            string_parts_of(source, tok, &mut out);
1637        }
1638    }
1639    out
1640}
1641
1642/// [`string_parts`] for one token, recursing into the holes of an
1643/// interpolated string (each hole re-lexed and rebased to absolute offsets).
1644fn string_parts_of(source: &str, tok: Token, out: &mut Vec<(Span, StringPart)>) {
1645    if !matches!(tok.kind, TokenKind::StrLit | TokenKind::InterpStr) {
1646        return;
1647    }
1648    let bytes = source.as_bytes();
1649    let file = tok.span.file;
1650    let Some(open) = string_open(bytes, tok.span.start) else {
1651        return;
1652    };
1653    if open.raw {
1654        // `r#"` and the closing `"#`; a multi-line raw literal's `r"""` /
1655        // `"""` likewise.
1656        let close = if open.multi { 3 } else { 1 + open.hashes };
1657        out.push((
1658            Span::new_in(file, tok.span.start, open.body),
1659            StringPart::RawDelimiter,
1660        ));
1661        out.push((
1662            Span::new_in(file, tok.span.end - close, tok.span.end),
1663            StringPart::RawDelimiter,
1664        ));
1665        return;
1666    }
1667    let Ok(segments) = split_interp(source, tok.span) else {
1668        return;
1669    };
1670    let holes: Vec<Span> = segments
1671        .iter()
1672        .filter_map(|s| match s {
1673            InterpSegment::Hole(h) => Some(*h),
1674            InterpSegment::Chunk(_) => None,
1675        })
1676        .collect();
1677    let mut i = open.body;
1678    let end = tok.span.end - if open.multi { 3 } else { 1 };
1679    while i < end {
1680        if bytes[i] != b'\\' {
1681            i += 1;
1682            continue;
1683        }
1684        if let Some(hole) = holes.iter().find(|h| h.start == i + 2) {
1685            i = hole.end + 1; // past the hole's `)`
1686            continue;
1687        }
1688        let len = if bytes.get(i + 1) == Some(&b'u') {
1689            bytes[i..end]
1690                .iter()
1691                .position(|&b| b == b'}')
1692                .map_or(2, |n| n + 1)
1693        } else {
1694            2
1695        };
1696        out.push((Span::new_in(file, i, i + len), StringPart::Escape));
1697        i += len;
1698    }
1699    for hole in holes {
1700        let Ok(hole_tokens) = tokenize_in(&source[hole.range()], file) else {
1701            continue;
1702        };
1703        for mut t in hole_tokens {
1704            t.span = Span::new_in(file, t.span.start + hole.start, t.span.end + hole.start);
1705            string_parts_of(source, t, out);
1706        }
1707    }
1708}
1709
1710/// The value of a hole-free string lexeme (any form), for callers that hold the
1711/// lexeme rather than the surrounding source. `span` locates the lexeme in the
1712/// file so a (normally unreachable — the lexer already validated it) error
1713/// points at the right place.
1714pub fn decode_string_literal(lexeme: &str, span: Span) -> Result<String, CompileError> {
1715    let (_, segments) =
1716        lex_string(lexeme, 0, 0, span.file).map_err(|e| e.offset_spans(span.start))?;
1717    let mut out = String::with_capacity(lexeme.len());
1718    for segment in segments {
1719        if let InterpSegment::Chunk(text) = segment {
1720            out.push_str(&text);
1721        }
1722    }
1723    Ok(out)
1724}
1725
1726/// If a `---` doc-block marker line starts at or shortly after `pos` (which
1727/// must be at a line boundary), return the byte offset just past the marker
1728/// line (after the terminating newline, or at EOF), and the marker's dash
1729/// count (three or more). The doc-block grammar requires the marker to be
1730/// alone on its line; leading horizontal whitespace is allowed and ignored.
1731fn doc_block_open_at(source: &str, pos: usize) -> Option<(usize, usize)> {
1732    let bytes = source.as_bytes();
1733    if !at_line_start(source, pos) {
1734        return None;
1735    }
1736    // Skip leading horizontal whitespace.
1737    let mut i = pos;
1738    while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
1739        i += 1;
1740    }
1741    if i + 3 > bytes.len() {
1742        return None;
1743    }
1744    if &bytes[i..i + 3] != b"---" {
1745        return None;
1746    }
1747    let dash_start = i;
1748    i += 3;
1749    // The marker may have additional trailing dashes (per spec "three or more
1750    // consecutive hyphens"). Consume them.
1751    while i < bytes.len() && bytes[i] == b'-' {
1752        i += 1;
1753    }
1754    let dashes = i - dash_start;
1755    // After the dashes, allow only horizontal whitespace then newline/EOF.
1756    while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t' || bytes[i] == b'\r') {
1757        i += 1;
1758    }
1759    if i == bytes.len() {
1760        return Some((i, dashes));
1761    }
1762    if bytes[i] == b'\n' {
1763        return Some((i + 1, dashes));
1764    }
1765    None
1766}
1767
1768/// Find the next closing marker line at or after `pos`: a marker of exactly
1769/// `dashes` dashes, the opener's count (#1885), as with a Markdown code fence.
1770/// A marker line of any other length is content, so a longer fence can hold a
1771/// shorter one. Returns `(start_of_line, end_of_line)` (`end_of_line` is just
1772/// past the terminating newline, or at EOF).
1773fn doc_block_close(source: &str, mut pos: usize, dashes: usize) -> Option<(usize, usize)> {
1774    let bytes = source.as_bytes();
1775    while pos < bytes.len() {
1776        // Advance pos to the start of a line.
1777        let line_start = pos;
1778        // Find the end of this line.
1779        let mut line_end = line_start;
1780        while line_end < bytes.len() && bytes[line_end] != b'\n' {
1781            line_end += 1;
1782        }
1783        // Check this line.
1784        if let Some((end, n)) = doc_block_open_at(source, line_start)
1785            && n == dashes
1786        {
1787            return Some((line_start, end));
1788        }
1789        // Move to the next line.
1790        pos = if line_end < bytes.len() {
1791            line_end + 1
1792        } else {
1793            line_end
1794        };
1795    }
1796    None
1797}
1798
1799/// Returns true if byte offset `pos` is at a line start (column 0).
1800/// #1888: true when only horizontal whitespace precedes `pos` on its line.
1801fn only_indent_before(bytes: &[u8], pos: usize) -> bool {
1802    bytes[..pos]
1803        .iter()
1804        .rev()
1805        .take_while(|&&b| b != b'\n')
1806        .all(|&b| matches!(b, b' ' | b'\t' | b'\r'))
1807}
1808
1809/// #2203: a `--- text` one-line doc starts at `pos`: three or more dashes, a
1810/// space or tab, then something other than whitespace on the line. A marker
1811/// with only whitespace after it is a doc-block fence, which
1812/// [`doc_block_open_at`] has already taken.
1813fn doc_line_marker(bytes: &[u8], pos: usize) -> bool {
1814    let dashes = bytes[pos..].iter().take_while(|&&b| b == b'-').count();
1815    if dashes < 3 || !matches!(bytes.get(pos + dashes), Some(b' ' | b'\t')) {
1816        return false;
1817    }
1818    bytes[pos + dashes..]
1819        .iter()
1820        .take_while(|&&b| b != b'\n')
1821        .any(|b| !b.is_ascii_whitespace())
1822}
1823
1824/// Push a [`TokenKind::DocLine`] from `start` through the end of its line,
1825/// newline included, and return the position after it.
1826fn push_doc_line(tokens: &mut Vec<Token>, bytes: &[u8], start: usize, file: FileId) -> usize {
1827    let mut pos = start;
1828    while pos < bytes.len() && bytes[pos] != b'\n' {
1829        pos += 1;
1830    }
1831    if pos < bytes.len() {
1832        pos += 1;
1833    }
1834    tokens.push(Token {
1835        kind: TokenKind::DocLine,
1836        span: Span::new_in(file, start, pos),
1837    });
1838    pos
1839}
1840
1841/// Push a [`TokenKind::Comment`] from `start` up to (not including) the end of
1842/// its line, and return the position after it.
1843fn push_comment(tokens: &mut Vec<Token>, bytes: &[u8], start: usize, file: FileId) -> usize {
1844    let mut pos = start;
1845    while pos < bytes.len() && bytes[pos] != b'\n' {
1846        pos += 1;
1847    }
1848    tokens.push(Token {
1849        kind: TokenKind::Comment,
1850        span: Span::new_in(file, start, pos),
1851    });
1852    pos
1853}
1854
1855/// #2203: the `bynk.lex.slash_doc` error for a `///` at `pos`, with the fix to
1856/// `---` (and a separating space when text follows straight on).
1857fn slash_doc_error(bytes: &[u8], pos: usize, file: FileId) -> CompileError {
1858    let marker = Span::new_in(file, pos, pos + 3);
1859    let fix = match bytes.get(pos + 3) {
1860        None | Some(b' ' | b'\t' | b'\r' | b'\n') => "---",
1861        Some(_) => "--- ",
1862    };
1863    CompileError::new(
1864        "bynk.lex.slash_doc",
1865        marker,
1866        "`///` is not a doc comment in Bynk; a doc is written with dashes",
1867    )
1868    .with_note(
1869        "a one-line doc is `--- text`, and a longer doc is a block between two `---` lines; \
1870         a comment is `//`",
1871    )
1872    .with_suggestion(
1873        "write `---`",
1874        vec![(marker, fix.to_string())],
1875        Applicability::MachineApplicable,
1876    )
1877}
1878
1879/// The text of a [`TokenKind::DocLine`] token: everything after the marker,
1880/// less one separating space or tab, less a closer (#2203: a final run of
1881/// exactly the opener's dashes, preceded by whitespace, with that whitespace),
1882/// and less the line ending. Further indentation is kept, so a doc line can
1883/// indent a Markdown code block. A legacy `--|` line (#1888) has no closer.
1884pub fn doc_line_text(source: &str, span: Span) -> &str {
1885    let slice = source[span.range()].trim_end_matches(['\n', '\r']);
1886    if let Some(rest) = slice.strip_prefix("--|") {
1887        return rest.strip_prefix(' ').unwrap_or(rest);
1888    }
1889    let dashes = slice.bytes().take_while(|&b| b == b'-').count();
1890    let rest = &slice[dashes..];
1891    let rest = rest.strip_prefix([' ', '\t']).unwrap_or(rest);
1892    let body = rest.trim_end_matches([' ', '\t']);
1893    let run = body.len() - body.trim_end_matches('-').len();
1894    if run == dashes {
1895        let before = &body[..body.len() - run];
1896        if before.is_empty() || before.ends_with([' ', '\t']) {
1897            return before.trim_end_matches([' ', '\t']);
1898        }
1899    }
1900    rest
1901}
1902
1903/// #2203: the source spelling of a one-line doc whose text is `text`: `--- `
1904/// and the text, or a `----` marker when the text ends in a whitespace-preceded
1905/// run of three dashes (which a `---` marker would read as its closer). Empty
1906/// text is spelled with a closer, `--- ---`, since a bare `---` opens a block.
1907pub fn one_line_doc(text: &str) -> String {
1908    let body = text.trim_end_matches([' ', '\t']);
1909    if body.trim().is_empty() {
1910        return "--- ---".to_string();
1911    }
1912    let run = body.len() - body.trim_end_matches('-').len();
1913    let before = &body[..body.len() - run];
1914    let closer_shaped = run == 3 && (before.is_empty() || before.ends_with([' ', '\t']));
1915    let marker = if closer_shaped { "----" } else { "---" };
1916    format!("{marker} {text}")
1917}
1918
1919/// #1885: the dash count of the fence a doc block holding `doc` is printed
1920/// with: one more than the longest marker-shaped line in it (a line of three
1921/// or more dashes alone, surrounding whitespace aside), and never fewer than
1922/// three. A shorter fence would close at that line.
1923pub fn doc_fence_len(doc: &str) -> usize {
1924    doc.lines()
1925        .map(str::trim)
1926        .filter(|l| l.len() >= 3 && l.bytes().all(|b| b == b'-'))
1927        .map(|l| l.len() + 1)
1928        .fold(3, usize::max)
1929}
1930
1931/// #2203: whether a [`TokenKind::DocLine`] token is spelled the legacy way,
1932/// `--|` (#1888), rather than `--- text`.
1933pub fn doc_line_is_legacy(source: &str, span: Span) -> bool {
1934    source[span.range()].starts_with("--|")
1935}
1936
1937/// #2203: whether a [`TokenKind::Comment`] token is spelled the legacy way,
1938/// `--`, rather than `//`.
1939pub fn comment_is_legacy(source: &str, span: Span) -> bool {
1940    source[span.range()].starts_with("--")
1941}
1942
1943/// #2203 (DECISION E): the `bynk.lex.legacy_comment` error for the legacy `--`
1944/// comment at `span` (a [`TokenKind::Comment`] token), with the fix to `//`.
1945pub fn legacy_comment_error(span: Span) -> CompileError {
1946    let marker = Span::new_in(span.file, span.start, span.start + 2);
1947    CompileError::new(
1948        "bynk.lex.legacy_comment",
1949        marker,
1950        "`--` no longer starts a comment; a line comment is `//`",
1951    )
1952    .with_note(
1953        "`bynk fmt` rewrites every legacy comment; for subtraction of a negation, write `a - -b`",
1954    )
1955    .with_suggestion(
1956        "write `//`",
1957        vec![(marker, "//".to_string())],
1958        Applicability::MachineApplicable,
1959    )
1960}
1961
1962/// #2203 (DECISION E): the `bynk.lex.legacy_doc_line` error for the legacy
1963/// `--|` doc line at `span` (a [`TokenKind::DocLine`] token), with the fix to
1964/// `--- text`. A `--|` with no text has no one-line spelling (a bare `---`
1965/// opens a block), so it gets no fix.
1966pub fn legacy_doc_line_error(source: &str, span: Span) -> CompileError {
1967    let marker = Span::new_in(span.file, span.start, span.start + 3);
1968    let err = CompileError::new(
1969        "bynk.lex.legacy_doc_line",
1970        marker,
1971        "`--|` no longer starts a doc; a one-line doc is `--- text`",
1972    );
1973    let text = doc_line_text(source, span);
1974    if text.trim().is_empty() {
1975        return err.with_note(
1976            "a one-line doc holds text; a doc of more than one paragraph is a block between \
1977             two `---` lines",
1978        );
1979    }
1980    let line = Span::new_in(span.file, span.start, doc_line_text_range(source, span).end);
1981    err.with_note("`bynk fmt` rewrites every legacy doc line")
1982        .with_suggestion(
1983            "write `--- `",
1984            vec![(line, one_line_doc(text))],
1985            Applicability::MachineApplicable,
1986        )
1987}
1988
1989/// #1888: the byte range of a doc line's text within `source`, as
1990/// [`doc_line_text`] returns it, for callers that map positions in a doc
1991/// back to the source (document links).
1992pub fn doc_line_text_range(source: &str, span: Span) -> std::ops::Range<usize> {
1993    let text = doc_line_text(source, span);
1994    let start = text.as_ptr() as usize - source.as_ptr() as usize;
1995    start..start + text.len()
1996}
1997
1998fn at_line_start(source: &str, pos: usize) -> bool {
1999    if pos == 0 {
2000        return true;
2001    }
2002    let bytes = source.as_bytes();
2003    bytes[pos - 1] == b'\n'
2004}
2005
2006/// The doc-block body as a byte range into `source` — leading/trailing `---`
2007/// marker lines stripped, no further processing (unlike [`doc_block_content`],
2008/// which additionally strips a common per-line indent — not offset-preserving).
2009/// Callers that need to map a position in the body back to `source` (e.g.
2010/// document-link spans) use this instead of re-deriving it from the string
2011/// `doc_block_content` returns.
2012pub fn doc_block_body_range(source: &str, span: Span) -> Option<std::ops::Range<usize>> {
2013    let slice = &source[span.range()];
2014    // Drop the first line (opening marker).
2015    let after_open_rel = slice.find('\n')? + 1;
2016    let after_open = &slice[after_open_rel..];
2017    let bytes = after_open.as_bytes();
2018    // Trim the trailing closing-marker line.
2019    let mut i = bytes.len();
2020    if i > 0 && bytes[i - 1] == b'\n' {
2021        i -= 1;
2022    }
2023    while i > 0 && matches!(bytes[i - 1], b' ' | b'\t' | b'\r') {
2024        i -= 1;
2025    }
2026    while i > 0 && bytes[i - 1] == b'-' {
2027        i -= 1;
2028    }
2029    if i > 0 && bytes[i - 1] == b'\n' {
2030        i -= 1;
2031    }
2032    let start = span.range().start + after_open_rel;
2033    Some(start..start + i)
2034}
2035
2036/// Extract the body content of a doc-block token from its source span.
2037/// Strips the leading and trailing `---` marker lines and returns the body
2038/// verbatim. If every non-empty content line begins with the same horizontal
2039/// whitespace prefix (e.g., because the doc block sits inside a brace-form
2040/// commons body), that common prefix is removed so the body reads naturally
2041/// when emitted as JSDoc.
2042pub fn doc_block_content(source: &str, span: Span) -> String {
2043    let Some(range) = doc_block_body_range(source, span) else {
2044        return String::new();
2045    };
2046    let body = &source[range];
2047
2048    // Compute the common leading-whitespace prefix across all non-empty lines
2049    // and strip it. This lets writers indent the doc block alongside the
2050    // declaration it documents without bleeding the indent into the JSDoc.
2051    let common: Option<usize> = body
2052        .lines()
2053        .filter(|l| !l.trim().is_empty())
2054        .map(|l| l.bytes().take_while(|&b| b == b' ' || b == b'\t').count())
2055        .min();
2056    let strip = common.unwrap_or(0);
2057    if strip == 0 {
2058        return body.to_string();
2059    }
2060    let mut out = String::with_capacity(body.len());
2061    let mut first = true;
2062    for line in body.lines() {
2063        if !first {
2064            out.push('\n');
2065        }
2066        first = false;
2067        if line.trim().is_empty() {
2068            // Preserve blank lines.
2069            continue;
2070        }
2071        let leading: usize = line
2072            .bytes()
2073            .take_while(|&b| b == b' ' || b == b'\t')
2074            .count();
2075        let drop = strip.min(leading);
2076        out.push_str(&line[drop..]);
2077    }
2078    out
2079}
2080
2081/// Extract the body of a `Comment` trivia token: everything after the
2082/// leading `//` marker, preserving its inline whitespace verbatim. Used by
2083/// the parser when attaching comments to declarations.
2084///
2085/// #2203: a legacy `--` comment's body is what follows its `--`, so the
2086/// formatter prints it after `//`. A legacy body that starts with a dash (a
2087/// `--- Helpers ---` divider, read by [`tokenize_legacy`]) keeps all of its
2088/// dashes behind a space instead, as `// --- Helpers ---`, and a legacy
2089/// `--|` after code (#1888) drops its `|`.
2090pub fn comment_body(source: &str, span: Span) -> std::borrow::Cow<'_, str> {
2091    let slice = &source[span.range()];
2092    if let Some(rest) = slice.strip_prefix("//") {
2093        return rest.into();
2094    }
2095    match slice.strip_prefix("--") {
2096        Some(rest) if rest.starts_with('-') => format!(" {slice}").into(),
2097        // A `--|` after code was a comment (#1888); its `|` was not text.
2098        Some(rest) => rest.strip_prefix('|').unwrap_or(rest).into(),
2099        None => slice.into(),
2100    }
2101}
2102
2103/// Returns true if there is a blank line (a line containing only whitespace)
2104/// in `source` strictly between byte offsets `from` (inclusive) and `to`
2105/// (exclusive). Used by the parser to detect orphan doc blocks.
2106///
2107/// A doc-block token's span ends just past the closing-marker line's
2108/// terminating newline. So if the next declaration begins on the immediately
2109/// following line, the substring between contains no newline (only optional
2110/// indentation). Any newline in the substring therefore implies at least one
2111/// entirely-blank line separating the doc from the declaration.
2112pub fn has_blank_line_between(source: &str, from: usize, to: usize) -> bool {
2113    if to <= from {
2114        return false;
2115    }
2116    let bytes = source.as_bytes();
2117    let mut i = from;
2118    while i < to {
2119        if bytes[i] == b'\n' {
2120            return true;
2121        }
2122        if !matches!(bytes[i], b' ' | b'\t' | b'\r') {
2123            return false;
2124        }
2125        i += 1;
2126    }
2127    false
2128}
2129
2130#[cfg(test)]
2131mod tests {
2132    use super::*;
2133
2134    fn kinds(source: &str) -> Vec<TokenKind> {
2135        tokenize(source)
2136            .unwrap()
2137            .into_iter()
2138            .map(|t| t.kind)
2139            .collect()
2140    }
2141
2142    #[test]
2143    fn keywords_and_idents() {
2144        use TokenKind::*;
2145        assert_eq!(
2146            kinds("commons type fn where true false Int String Bool foo bar"),
2147            vec![
2148                Commons, Type, Fn, Where, True, False, Int, String, Bool, Ident, Ident
2149            ],
2150        );
2151        // #548: `and` is no longer a keyword — it lexes as an ordinary identifier.
2152        assert_eq!(kinds("and"), vec![Ident]);
2153    }
2154
2155    #[test]
2156    fn deeply_nested_interpolation_is_bounded_not_overflowed() {
2157        // `"\("\("\(…` mutually recurses lex_string <-> scan_hole, one frame per
2158        // level, and an unbounded scanner overflows `tokenize` and aborts the
2159        // process (#713). Well past the limit it must return a bounded-depth
2160        // diagnostic instead. The holes are left open so the depth guard, not a
2161        // later `)`, stops the scan.
2162        let depth = crate::MAX_NESTING_DEPTH + 8;
2163        let src = format!("\"{}", "\\(\"".repeat(depth));
2164        let err = tokenize(&src).unwrap_err();
2165        assert_eq!(err.category, "bynk.lex.interpolation_too_deep");
2166    }
2167
2168    #[test]
2169    fn integer_and_string_literals() {
2170        use TokenKind::*;
2171        assert_eq!(
2172            kinds(r#"0 42 "hello" "with\nescape""#),
2173            vec![IntLit, IntLit, StrLit, StrLit]
2174        );
2175    }
2176
2177    #[test]
2178    fn operators() {
2179        use TokenKind::*;
2180        assert_eq!(
2181            kinds("-> == != <= >= && || + - * / % ! = < > ( ) { } [ ] , : . @"),
2182            vec![
2183                Arrow, EqEq, BangEq, LtEq, GtEq, AmpAmp, PipePipe, Plus, Minus, Star, Slash,
2184                Percent, Bang, Eq, Lt, Gt, LParen, RParen, LBrace, RBrace, LBracket, RBracket,
2185                Comma, Colon, Dot, At,
2186            ],
2187        );
2188    }
2189
2190    #[test]
2191    fn dot_family_maximal_munch() {
2192        // Events track slice 1 (spine #936): `..` must lex as one `DotDot`
2193        // token, not two `Dot`s — a real token keeps agreement with
2194        // tree-sitter (which declares `".."` as one literal), so a
2195        // whitespace-split `. .` cannot silently parse where a real `..`
2196        // is required. Also confirms `...`/`..`/`.` don't shadow each other
2197        // regardless of declaration order (logos maximal-munch).
2198        use TokenKind::*;
2199        assert_eq!(
2200            kinds("a .. b ... c . d . ."),
2201            vec![Ident, DotDot, Ident, DotDotDot, Ident, Dot, Ident, Dot, Dot,],
2202        );
2203    }
2204
2205    #[test]
2206    fn line_comments_emitted_as_trivia() {
2207        // v1.1: line comments are preserved as Comment tokens so the
2208        // formatter can attach and re-emit them.
2209        use TokenKind::*;
2210        let src = "// a comment\ntype X = Int // trailing\n";
2211        assert_eq!(kinds(src), vec![Comment, Type, Ident, Eq, Int, Comment],);
2212    }
2213
2214    #[test]
2215    fn comment_body_extracts_text_after_marker() {
2216        let toks = tokenize("// hello world\n").unwrap();
2217        assert_eq!(toks.len(), 1);
2218        assert_eq!(toks[0].kind, TokenKind::Comment);
2219        assert_eq!(
2220            comment_body("// hello world\n", toks[0].span),
2221            " hello world"
2222        );
2223    }
2224
2225    #[test]
2226    fn comment_does_not_consume_newline() {
2227        // Two adjacent comment lines should produce two distinct tokens
2228        // — the newline between them is not part of either comment's span.
2229        let toks = tokenize("// one\n// two\n").unwrap();
2230        assert_eq!(toks.len(), 2);
2231        assert!(toks.iter().all(|t| t.kind == TokenKind::Comment));
2232    }
2233
2234    /// #2203: `//` is a comment anywhere, with no whitespace rule, and `--` is
2235    /// two `-` operators unless it is spelled like a legacy comment.
2236    #[test]
2237    fn slash_slash_is_a_comment_and_dash_dash_is_subtraction() {
2238        use TokenKind::*;
2239        assert_eq!(kinds("a//b"), vec![Ident, Comment]);
2240        assert_eq!(kinds("a--b"), vec![Ident, Minus, Minus, Ident]);
2241        assert_eq!(kinds("x--\ny"), vec![Ident, Minus, Minus, Ident]);
2242        assert_eq!(kinds("x--"), vec![Ident, Minus, Minus]);
2243        assert_eq!(kinds("a - -b"), vec![Ident, Minus, Minus, Ident]);
2244        assert_eq!(kinds("a --b"), vec![Ident, Minus, Minus, Ident]);
2245        // A `////` ruler is a comment, as in Rust.
2246        assert_eq!(kinds("////////\n"), vec![Comment]);
2247        assert_eq!(kinds("// -------\n"), vec![Comment]);
2248    }
2249
2250    /// #2203 (DECISION E): a `--` at the start of a line or after whitespace,
2251    /// followed by a space or the end of the line, lexes as a comment still,
2252    /// spelled the legacy way, so it is never read as `- -`.
2253    #[test]
2254    fn a_legacy_dash_dash_comment_still_lexes_as_a_comment() {
2255        use TokenKind::*;
2256        assert_eq!(kinds("a -- c"), vec![Ident, Comment]);
2257        assert_eq!(kinds("-- c"), vec![Comment]);
2258        assert_eq!(kinds("a\n--\nb"), vec![Ident, Comment, Ident]);
2259        assert_eq!(kinds("x --"), vec![Ident, Comment]);
2260        assert_eq!(
2261            kinds("a: Int, --| note"),
2262            vec![Ident, Colon, Int, Comma, Comment]
2263        );
2264        let src = "x = 1 -- note\n";
2265        let toks = tokenize(src).unwrap();
2266        let c = toks.last().unwrap().span;
2267        assert!(comment_is_legacy(src, c));
2268        assert_eq!(comment_body(src, c), " note");
2269        let err = legacy_comment_error(c);
2270        assert_eq!(err.category, "bynk.lex.legacy_comment");
2271        assert_eq!(err.suggestions[0].edits[0].1, "//");
2272        assert_eq!(&src[err.suggestions[0].edits[0].0.range()], "--");
2273    }
2274
2275    /// #2203 (DECISION C): `--- text` first on its line is a one-line doc; its
2276    /// closer is a final run of exactly the opener's dashes after whitespace.
2277    #[test]
2278    fn a_dash_marker_with_text_is_a_doc_line() {
2279        use TokenKind::*;
2280        assert_eq!(kinds("--- doc\ntype"), vec![DocLine, Type]);
2281        assert_eq!(kinds("  ---- doc\n\t--- x"), vec![DocLine, DocLine]);
2282        let text = |src: &str| {
2283            let toks = tokenize(src).unwrap();
2284            assert_eq!(toks[0].kind, DocLine, "{src}");
2285            doc_line_text(src, toks[0].span).to_string()
2286        };
2287        assert_eq!(text("--- The repo.\n"), "The repo.");
2288        assert_eq!(text("--- The repo. ---\n"), "The repo.");
2289        assert_eq!(text("---- The repo. ----\r\n"), "The repo.");
2290        // A trailing run of another length is text.
2291        assert_eq!(text("--- a -- b ----\n"), "a -- b ----");
2292        assert_eq!(text("---- a ---\n"), "a ---");
2293        assert_eq!(text("--- a---\n"), "a---");
2294        // Further indentation is kept.
2295        assert_eq!(text("---   indented\n"), "  indented");
2296        // A marker alone is a block fence, and a `---` with no space after it
2297        // is not a doc.
2298        assert_eq!(kinds("---\nx\n---\n"), vec![DocBlock]);
2299        assert_eq!(kinds("---x\n"), vec![Minus, Minus, Minus, Ident]);
2300        // After code, `---` is operators (the parser rejects it).
2301        assert_eq!(kinds("a --- b"), vec![Ident, Minus, Minus, Minus, Ident]);
2302        let src = "  --- A doc.  \nx";
2303        let toks = tokenize(src).unwrap();
2304        assert_eq!(&src[doc_line_text_range(src, toks[0].span)], "A doc.  ");
2305    }
2306
2307    /// #2203: the one-line spelling of a doc's text never reads its own end as
2308    /// a closer.
2309    #[test]
2310    fn one_line_doc_spelling_round_trips() {
2311        for text in [
2312            "The repo.",
2313            "a ---",
2314            "---",
2315            "----",
2316            "a -- b",
2317            "  indented",
2318            "",
2319        ] {
2320            let src = format!("{}\n", one_line_doc(text));
2321            let toks = tokenize(&src).unwrap();
2322            assert_eq!(toks[0].kind, TokenKind::DocLine, "{src}");
2323            assert_eq!(doc_line_text(&src, toks[0].span), text, "{src}");
2324        }
2325    }
2326
2327    /// #1888's `--|` first on its line is a doc line still, spelled the legacy
2328    /// way; its fix is `--- text`.
2329    #[test]
2330    fn a_legacy_bar_doc_line_still_lexes_as_a_doc_line() {
2331        use TokenKind::{DocLine, Type};
2332        assert_eq!(kinds("--| doc\ntype"), vec![DocLine, Type]);
2333        assert_eq!(kinds("  --| doc\n\t--|\n"), vec![DocLine, DocLine]);
2334        let src = "  --|  indented  \r\n--|bare\n--|";
2335        let toks = tokenize(src).unwrap();
2336        assert!(doc_line_is_legacy(src, toks[0].span));
2337        assert_eq!(&src[toks[0].span.range()], "--|  indented  \r\n");
2338        assert_eq!(doc_line_text(src, toks[0].span), " indented  ");
2339        assert_eq!(doc_line_text(src, toks[1].span), "bare");
2340        assert_eq!(doc_line_text(src, toks[2].span), "");
2341        let fix = |i: usize| {
2342            let err = legacy_doc_line_error(src, toks[i].span);
2343            assert_eq!(err.category, "bynk.lex.legacy_doc_line");
2344            err.suggestions
2345                .first()
2346                .map(|s| (src[s.edits[0].0.range()].to_string(), s.edits[0].1.clone()))
2347        };
2348        assert_eq!(
2349            fix(1),
2350            Some(("--|bare".to_string(), "--- bare".to_string()))
2351        );
2352        assert_eq!(fix(2), None, "an empty `--|` has no one-line spelling");
2353    }
2354
2355    /// #2203: `///` first on its line is `bynk.lex.slash_doc`, fixed to `---`.
2356    #[test]
2357    fn a_slash_doc_is_an_error_with_a_fix() {
2358        for (src, fix) in [
2359            ("/// A doc.\n", "---"),
2360            ("///A doc.\n", "--- "),
2361            ("  ///", "---"),
2362        ] {
2363            let err = tokenize(src).unwrap_err();
2364            assert_eq!(err.category, "bynk.lex.slash_doc");
2365            let (span, text) = &err.suggestions[0].edits[0];
2366            assert_eq!(&src[span.range()], "///");
2367            assert_eq!(text, fix);
2368        }
2369    }
2370
2371    /// #2203: a legacy read takes ADR 0188 D1's rule, so a `--- Helpers ---`
2372    /// divider and a `--note` are comments, as they were before `//`.
2373    #[test]
2374    fn a_legacy_read_keeps_the_old_comment_rule() {
2375        let src = "--- Helpers ---\nx --note\na--b\n--| doc\n// new\n";
2376        let toks = tokenize_legacy(src).unwrap();
2377        let kinds: Vec<_> = toks.iter().map(|t| t.kind).collect();
2378        use TokenKind::*;
2379        assert_eq!(
2380            kinds,
2381            vec![
2382                Comment, Ident, Comment, Ident, Minus, Minus, Ident, DocLine, Comment
2383            ]
2384        );
2385        assert_eq!(comment_body(src, toks[0].span), " --- Helpers ---");
2386        assert_eq!(comment_body(src, toks[2].span), "note");
2387    }
2388
2389    #[test]
2390    fn unterminated_string_is_error() {
2391        let err = tokenize("\"oops\n").unwrap_err();
2392        assert_eq!(err.category, "bynk.lex.unterminated_string");
2393    }
2394
2395    #[test]
2396    fn integer_overflow_is_error() {
2397        let err = tokenize("99999999999999999999").unwrap_err();
2398        assert_eq!(err.category, "bynk.lex.integer_overflow");
2399    }
2400
2401    #[test]
2402    fn an_int_literal_must_be_a_safe_integer() {
2403        // #1657: the `Int` range is ±(2^53 − 1). The largest safe integer lexes;
2404        // one more, and i64-range values above it, do not.
2405        assert!(tokenize("9007199254740991").is_ok());
2406        assert!(tokenize("9_007_199_254_740_991").is_ok());
2407        for over in [
2408            "9007199254740992",
2409            "9007199254740993",
2410            "9223372036854775807",
2411        ] {
2412            let err = tokenize(over).unwrap_err();
2413            assert_eq!(err.category, "bynk.lex.integer_overflow", "{over}");
2414        }
2415    }
2416
2417    #[test]
2418    fn range_bounds_never_lex_as_a_float() {
2419        use TokenKind::*;
2420        // #2699: a float needs a digit on both sides of its `.`, so every range
2421        // spelling is `IntLit DotDot IntLit`, with the sign a separate `Minus`.
2422        assert_eq!(kinds("0..9"), vec![IntLit, DotDot, IntLit]);
2423        assert_eq!(kinds("18.."), vec![IntLit, DotDot]);
2424        assert_eq!(kinds("..-1"), vec![DotDot, Minus, IntLit]);
2425        assert_eq!(kinds("-5..-1"), vec![Minus, IntLit, DotDot, Minus, IntLit]);
2426        assert_eq!(kinds("1_000..2_000"), vec![IntLit, DotDot, IntLit]);
2427    }
2428
2429    #[test]
2430    fn a_decimal_literal_is_one_token_with_a_d_suffix() {
2431        use TokenKind::*;
2432        // #2622: digits, an optional fraction, a trailing `d`.
2433        assert_eq!(kinds("1.20d"), vec![DecimalLit]);
2434        assert_eq!(kinds("5d"), vec![DecimalLit]);
2435        assert_eq!(kinds("1_000.50d"), vec![DecimalLit]);
2436        // No `d` is still an `Int` or a `Float`, and the other numeric forms are
2437        // unchanged (a duration unit, a method on a literal, a range).
2438        assert_eq!(kinds("1.20"), vec![FloatLit]);
2439        assert_eq!(kinds("5.days"), vec![IntLit, Dot, Ident]);
2440        assert_eq!(
2441            kinds("2.5.round()"),
2442            vec![FloatLit, Dot, Ident, LParen, RParen]
2443        );
2444        assert_eq!(kinds("0..9"), vec![IntLit, DotDot, IntLit]);
2445        // The sign stays a separate operator; there is no exponent form, so an
2446        // exponent is a float followed by an identifier.
2447        assert_eq!(kinds("-1.5d"), vec![Minus, DecimalLit]);
2448        assert_eq!(kinds("1e5d"), vec![FloatLit, Ident]);
2449        // `Decimal` is a keyword.
2450        assert_eq!(kinds("Decimal"), vec![Decimal]);
2451    }
2452
2453    #[test]
2454    fn a_decimal_literal_obeys_the_decimal_limits() {
2455        let frac = |n: usize| format!("0.{}d", "1".repeat(n));
2456        assert!(tokenize(&frac(100)).is_ok());
2457        assert!(tokenize(&frac(101)).is_err());
2458        assert!(tokenize(&format!("{}d", "9".repeat(1000))).is_ok());
2459        assert!(tokenize(&format!("{}d", "9".repeat(1001))).is_err());
2460    }
2461
2462    #[test]
2463    fn digit_separators_lex_as_one_number() {
2464        use TokenKind::*;
2465        // v0.142 (ADR 0166): `_` between digit groups keeps the literal a single
2466        // token for both Int and Float.
2467        assert_eq!(kinds("1_048_576"), vec![IntLit]);
2468        assert_eq!(kinds("1_000.500_5"), vec![FloatLit]);
2469        assert_eq!(kinds("1_000e1_0"), vec![FloatLit]);
2470        // A separator-carrying literal that is in range still lexes (the value is
2471        // validated after stripping the separators).
2472        assert!(tokenize("9_007_199_254_740_991").is_ok());
2473        // Overflow is still caught on the separator-free value.
2474        let err = tokenize("9_999_999_999_999_999_999_9").unwrap_err();
2475        assert_eq!(err.category, "bynk.lex.integer_overflow");
2476    }
2477
2478    #[test]
2479    fn strip_digit_separators_removes_underscores() {
2480        assert_eq!(strip_digit_separators("1_048_576"), "1048576");
2481        assert_eq!(strip_digit_separators("42"), "42");
2482    }
2483
2484    #[test]
2485    fn unexpected_character_is_error() {
2486        let err = tokenize("type X = Int $").unwrap_err();
2487        assert_eq!(err.category, "bynk.lex.unexpected_character");
2488    }
2489
2490    #[test]
2491    fn v0_1_keywords() {
2492        use TokenKind::*;
2493        assert_eq!(
2494            kinds("let if else Ok Err Result ValidationError"),
2495            vec![Let, If, Else, Ok, Err, Result, ValidationError],
2496        );
2497    }
2498
2499    #[test]
2500    fn question_token() {
2501        use TokenKind::*;
2502        assert_eq!(kinds("x?"), vec![Ident, Question]);
2503    }
2504
2505    #[test]
2506    fn v0_2_keywords() {
2507        use TokenKind::*;
2508        assert_eq!(
2509            kinds("enum match Option record self Some None is"),
2510            vec![Enum, Match, Option, Record, Self_, Some, None, Is],
2511        );
2512    }
2513
2514    #[test]
2515    fn pipe_and_pipe_pipe_disambiguated() {
2516        use TokenKind::*;
2517        assert_eq!(kinds("| || |"), vec![Pipe, PipePipe, Pipe]);
2518    }
2519
2520    #[test]
2521    fn v0_7_keywords() {
2522        use TokenKind::*;
2523        assert_eq!(kinds("expect suite case"), vec![Expect, Suite, Case],);
2524        // v0.118: `mocks` and `wires` are retired — plain identifiers now.
2525        assert_eq!(kinds("mocks wires"), vec![Ident, Ident]);
2526    }
2527
2528    #[test]
2529    fn fat_arrow_and_underscore() {
2530        use TokenKind::*;
2531        assert_eq!(kinds("_ =>"), vec![Underscore, FatArrow]);
2532    }
2533
2534    // -- v0.43 string interpolation --
2535
2536    #[test]
2537    fn interp_string_is_one_token() {
2538        use TokenKind::*;
2539        assert_eq!(kinds(r#""Hello, \(name)!""#), vec![InterpStr]);
2540        // A plain string (no hole) stays a `StrLit`, via the logos path.
2541        assert_eq!(kinds(r#""Hello, world""#), vec![StrLit]);
2542    }
2543
2544    #[test]
2545    fn interp_balances_nested_parens_and_strings() {
2546        use TokenKind::*;
2547        // The `)` inside `f(x)` must not close the hole early.
2548        assert_eq!(kinds(r#""= \(f(x))""#), vec![InterpStr]);
2549        // A `)` inside a nested string inside the hole is also ignored.
2550        assert_eq!(kinds(r#""= \(label(")"))""#), vec![InterpStr]);
2551        // A nested interpolated string inside a hole.
2552        assert_eq!(kinds(r#""out \("in \(x)")""#), vec![InterpStr]);
2553    }
2554
2555    // Issue #473: hole-expanding tokenisation makes identifiers inside `\(…)`
2556    // visible to the LSP's token-based cursor resolution.
2557    #[test]
2558    fn expanding_holes_exposes_hole_identifiers() {
2559        use TokenKind::*;
2560        let expand = |src: &str| {
2561            tokenize_expanding_holes(src)
2562                .unwrap()
2563                .into_iter()
2564                .map(|t| t.kind)
2565                .collect::<Vec<_>>()
2566        };
2567        // The opaque `InterpStr` is replaced by its hole's tokens; the chunk
2568        // text (`Hello, ` / `!`) carries none.
2569        assert_eq!(expand(r#""Hello, \(name)!""#), vec![Ident]);
2570        // A call hole exposes every token of the call expression.
2571        assert_eq!(expand(r#""= \(f(x))""#), vec![Ident, LParen, Ident, RParen]);
2572        // Nested interpolation recurses to the innermost hole's identifier.
2573        assert_eq!(expand(r#""out \("in \(x)")""#), vec![Ident]);
2574        // A plain (hole-free) string is untouched.
2575        assert_eq!(expand(r#""Hello, world""#), vec![StrLit]);
2576    }
2577
2578    #[test]
2579    fn expanding_holes_rebases_spans_to_absolute() {
2580        let src = r#""Hello, \(name)!""#;
2581        let toks = tokenize_expanding_holes(src).unwrap();
2582        let ident = toks
2583            .iter()
2584            .find(|t| t.kind == TokenKind::Ident)
2585            .expect("the hole identifier is exposed");
2586        // The span points at `name` in the original source, not a hole-local 0.
2587        assert_eq!(&src[ident.span.range()], "name");
2588        assert_eq!(ident.span.start, src.find("name").unwrap());
2589    }
2590
2591    #[test]
2592    fn escaped_open_paren_is_not_a_hole() {
2593        use TokenKind::*;
2594        // `\\(` is a literal backslash followed by `(` — no hole, so the
2595        // string lexes as a plain `StrLit` on the logos path.
2596        assert_eq!(kinds(r#""a \\(b) c""#), vec![StrLit]);
2597    }
2598
2599    #[test]
2600    fn unterminated_hole_is_an_error() {
2601        // The hole runs to end of line without its closing `)`.
2602        let err = tokenize("\"value \\(x + 1\n\"").unwrap_err();
2603        assert_eq!(err.category, "bynk.lex.unterminated_interpolation");
2604    }
2605
2606    #[test]
2607    fn unterminated_interp_string_is_an_error() {
2608        // A hole closes but the string never does (newline before the `"`).
2609        let err = tokenize("\"value \\(x) more\n").unwrap_err();
2610        assert_eq!(err.category, "bynk.lex.unterminated_string");
2611    }
2612
2613    #[test]
2614    fn bad_escape_in_interp_string_is_an_error() {
2615        let err = tokenize(r#""a \q \(x)""#).unwrap_err();
2616        assert_eq!(err.category, "bynk.lex.bad_escape");
2617    }
2618
2619    /// The decoded value of the one string token in `src`.
2620    fn string_value(src: &str) -> String {
2621        let toks = tokenize(src).unwrap();
2622        assert_eq!(toks.len(), 1, "{src:?}");
2623        assert_eq!(toks[0].kind, TokenKind::StrLit, "{src:?}");
2624        decode_string_literal(&src[toks[0].span.range()], toks[0].span).unwrap()
2625    }
2626
2627    /// #2619: `\r`, `\0` and `\u{…}` join the escape set.
2628    #[test]
2629    fn new_escapes_decode() {
2630        assert_eq!(string_value(r#""a\r\nb""#), "a\r\nb");
2631        assert_eq!(string_value(r#""\0""#), "\0");
2632        assert_eq!(string_value(r#""\u{1F600}""#), "\u{1F600}");
2633        assert_eq!(string_value(r#""a\u{00A0}b""#), "a\u{A0}b");
2634        assert_eq!(string_value(r#""\u{41}\u{000041}""#), "AA");
2635        assert_eq!(string_value(r#""\u{10FFFF}""#), "\u{10FFFF}");
2636    }
2637
2638    #[test]
2639    fn bad_unicode_escapes_are_lexical_errors() {
2640        for src in [r#""\u{D800}""#, r#""\u{DFFF}""#, r#""\u{110000}""#] {
2641            assert_eq!(
2642                tokenize(src).unwrap_err().category,
2643                "bynk.lex.bad_unicode_escape",
2644                "{src}"
2645            );
2646        }
2647        for src in [
2648            r#""\u""#,
2649            r#""\u{}""#,
2650            r#""\u{1234567}""#,
2651            r#""\u{12""#,
2652            r#""\u{zz}""#,
2653        ] {
2654            assert_eq!(
2655                tokenize(src).unwrap_err().category,
2656                "bynk.lex.bad_escape",
2657                "{src}"
2658            );
2659        }
2660    }
2661
2662    #[test]
2663    fn raw_strings_keep_backslashes_and_do_not_interpolate() {
2664        assert_eq!(string_value(r#"r"\d+\.\d+""#), "\\d+\\.\\d+");
2665        assert_eq!(string_value(r#"r"a\(b)""#), "a\\(b)");
2666        assert_eq!(string_value(r###"r#"say "hi""#"###), "say \"hi\"");
2667        assert_eq!(string_value(r#"r"""#), "");
2668        // `r` followed by a space is an identifier then a string.
2669        use TokenKind::*;
2670        assert_eq!(kinds(r#"r "x""#), vec![Ident, StrLit]);
2671        assert_eq!(kinds(r#"bar"x""#), vec![Ident, StrLit]);
2672        // A raw string is not terminated by a newline.
2673        assert_eq!(
2674            tokenize("r\"abc\n\"").unwrap_err().category,
2675            "bynk.lex.unterminated_string"
2676        );
2677    }
2678
2679    #[test]
2680    fn multiline_strings_strip_the_closing_indentation() {
2681        let src = "\"\"\"\n    SELECT id\n      FROM t\n\n    \"\"\"";
2682        assert_eq!(string_value(src), "SELECT id\n  FROM t\n");
2683        // No indentation, and escapes still apply.
2684        assert_eq!(string_value("\"\"\"\na\\tb\n\"\"\""), "a\tb");
2685        // An empty body.
2686        assert_eq!(string_value("\"\"\"\n\"\"\""), "");
2687        // Quotes inside are fine; the raw form keeps backslashes.
2688        assert_eq!(
2689            string_value("\"\"\"\n  a \"q\" \"\"b\n  \"\"\""),
2690            "a \"q\" \"\"b"
2691        );
2692        assert_eq!(string_value("r\"\"\"\n  \\d+\n  \"\"\""), "\\d+");
2693    }
2694
2695    /// Decision D: newlines are `\n` whatever the source's line endings.
2696    #[test]
2697    fn multiline_newlines_are_normalised() {
2698        assert_eq!(string_value("\"\"\"\r\n  a\r\n  b\r\n  \"\"\""), "a\nb");
2699    }
2700
2701    #[test]
2702    fn multiline_with_a_hole_is_an_interpolated_string() {
2703        use TokenKind::*;
2704        assert_eq!(kinds("\"\"\"\n  hi \\(name)\n  \"\"\""), vec![InterpStr]);
2705    }
2706
2707    /// Pinned by review of #2619: edge cases of the multi-line rules.
2708    #[test]
2709    fn multiline_edge_cases() {
2710        // A hole at the start of an indented line: the margin is stripped
2711        // first, never inside the hole.
2712        let src = "\"\"\"\n    \\(a)\n      \\(b) x\n    \"\"\"";
2713        let toks = tokenize(src).unwrap();
2714        assert_eq!(toks[0].kind, TokenKind::InterpStr);
2715        let segs = split_interp(src, toks[0].span).unwrap();
2716        let shape: Vec<String> = segs
2717            .iter()
2718            .map(|s| match s {
2719                InterpSegment::Chunk(c) => format!("{c:?}"),
2720                InterpSegment::Hole(h) => src[h.range()].to_string(),
2721            })
2722            .collect();
2723        assert_eq!(shape, ["a", "\"\\n  \"", "b", "\" x\""]);
2724        // A whitespace-only line shallower than the margin is empty.
2725        assert_eq!(
2726            string_value("\"\"\"\n    a\n  \n    b\n    \"\"\""),
2727            "a\n\nb"
2728        );
2729        // Mixed tabs and spaces that do not match the margin exactly.
2730        let err = tokenize("\"\"\"\n\t  a\n    \"\"\"").unwrap_err();
2731        assert_eq!(err.category, "bynk.lex.multiline_indent");
2732        assert!(err.notes[0].contains("character for character"));
2733        // CRLF inside a raw multi-line string; a lone CR is not a line break.
2734        assert_eq!(
2735            string_value("r\"\"\"\r\n  a\\\r\n  b\r\n  \"\"\""),
2736            "a\\\nb"
2737        );
2738        assert_eq!(string_value("\"\"\"\n  a\rb\n  \"\"\""), "a\rb");
2739        // The raw form holds `"` and `""` but not `"""`.
2740        assert_eq!(string_value("r\"\"\"\n  \"a\"\" b\n  \"\"\""), "\"a\"\" b");
2741        // Empty `\u{}` and an eight-digit escape.
2742        assert!(tokenize(r#""\u{}""#).is_err());
2743        assert!(tokenize(r#""\u{00000041}""#).is_err());
2744    }
2745
2746    /// Review of #2619: a multi-line string cannot nest in a hole, and a
2747    /// `"""` followed by text is not a closing delimiter.
2748    #[test]
2749    fn multiline_in_hole_and_stray_delimiters() {
2750        let cat = |src: &str| tokenize(src).unwrap_err().category;
2751        assert_eq!(
2752            cat("\"a \\(f(\"\"\"\n  x\n  \"\"\")) b\""),
2753            "bynk.lex.multiline_in_hole"
2754        );
2755        assert_eq!(
2756            cat("\"\"\"\n  \\(g(\"\"\"\n  x\n  \"\"\"))\n  \"\"\""),
2757            "bynk.lex.multiline_in_hole"
2758        );
2759        // `"""quoted"""` on a body line is a stray delimiter, not a close.
2760        assert_eq!(
2761            cat("\"\"\"\n  \"\"\"quoted\"\"\"\n  \"\"\""),
2762            "bynk.lex.multiline_close_not_own_line"
2763        );
2764        // A closing delimiter may be followed by punctuation.
2765        let toks = tokenize("\"\"\"\n  a\n  \"\"\", x)").unwrap();
2766        assert_eq!(toks.len(), 4);
2767    }
2768
2769    #[test]
2770    fn multiline_errors() {
2771        let cat = |src: &str| tokenize(src).unwrap_err().category;
2772        // Under-indented line.
2773        assert_eq!(
2774            cat("\"\"\"\n    a\n  b\n    \"\"\""),
2775            "bynk.lex.multiline_indent"
2776        );
2777        // Text after the opening delimiter.
2778        assert_eq!(cat("\"\"\"abc\n\"\"\""), "bynk.lex.multiline_open");
2779        // Never closed.
2780        assert_eq!(cat("\"\"\"\n  a\n"), "bynk.lex.unterminated_string");
2781        // A closing delimiter that shares a line with text.
2782        assert_eq!(
2783            cat("\"\"\"\n  a \"\"\" b\n  \"\"\""),
2784            "bynk.lex.multiline_close_not_own_line"
2785        );
2786    }
2787
2788    /// #2619: the semantic-token parts of the three string forms.
2789    #[test]
2790    fn string_parts_cover_escapes_and_raw_delimiters() {
2791        let src = "\"a\\n\\u{41}\" r#\"x\\n\"# \"\"\"\n  \\t \\(\"\\r\")\n  \"\"\"";
2792        let texts: Vec<(&str, StringPart)> = string_parts(src)
2793            .into_iter()
2794            .map(|(s, p)| (&src[s.range()], p))
2795            .collect();
2796        use StringPart::*;
2797        assert_eq!(
2798            texts,
2799            vec![
2800                ("\\n", Escape),
2801                ("\\u{41}", Escape),
2802                ("r#\"", RawDelimiter),
2803                ("\"#", RawDelimiter),
2804                ("\\t", Escape),
2805                ("\\r", Escape),
2806            ]
2807        );
2808    }
2809
2810    fn doc_block_span(source: &str) -> Span {
2811        tokenize(source)
2812            .unwrap()
2813            .into_iter()
2814            .find(|t| t.kind == TokenKind::DocBlock)
2815            .expect("a DocBlock token")
2816            .span
2817    }
2818
2819    #[test]
2820    fn doc_block_body_range_slices_to_the_same_bytes_doc_block_content_would_strip() {
2821        let src = "---\nHello there.\n---\nfn f() -> Int = 1\n";
2822        let span = doc_block_span(src);
2823        let range = doc_block_body_range(src, span).unwrap();
2824        assert_eq!(&src[range], "Hello there.");
2825    }
2826
2827    #[test]
2828    fn doc_block_body_range_is_offset_preserving_unlike_doc_block_content() {
2829        // A content line indented relative to its (unindented) markers:
2830        // doc_block_content strips the common indent (not offset-preserving),
2831        // doc_block_body_range does not — its slice still contains the raw
2832        // indentation, so span-based callers can map a position in the raw
2833        // body straight back to `src`.
2834        let src = "---\n  See [Foo].\n---\nfn f() -> Int = 1\n";
2835        let span = doc_block_span(src);
2836        let range = doc_block_body_range(src, span).unwrap();
2837        assert_eq!(&src[range.clone()], "  See [Foo].");
2838        assert_eq!(doc_block_content(src, span), "See [Foo].");
2839        // The raw range still locates `[Foo]` correctly within `src`.
2840        let bracket_rel = src[range.clone()].find('[').unwrap();
2841        assert_eq!(
2842            &src[range.start + bracket_rel..range.start + bracket_rel + 5],
2843            "[Foo]"
2844        );
2845    }
2846
2847    #[test]
2848    fn doc_block_content_and_body_range_agree_on_empty_body() {
2849        let src = "---\n---\nfn f() -> Int = 1\n";
2850        let span = doc_block_span(src);
2851        let range = doc_block_body_range(src, span).unwrap();
2852        assert_eq!(&src[range], "");
2853        assert_eq!(doc_block_content(src, span), "");
2854    }
2855
2856    /// #1885 (DECISION A): a `----` block closes only on `----`, so the `---`
2857    /// lines inside it are content, a nested doc example among them.
2858    #[test]
2859    fn a_longer_fence_holds_shorter_marker_lines() {
2860        let src = "----\nIntro.\n---\nInner doc.\n---\nTail.\n----\nfn f() -> Int = 1\n";
2861        let tokens = tokenize(src).unwrap();
2862        let docs: Vec<_> = tokens
2863            .iter()
2864            .filter(|t| t.kind == TokenKind::DocBlock)
2865            .collect();
2866        assert_eq!(docs.len(), 1, "one block, not three");
2867        assert_eq!(
2868            doc_block_content(src, docs[0].span),
2869            "Intro.\n---\nInner doc.\n---\nTail."
2870        );
2871    }
2872
2873    /// The trailing-dash trim in `doc_block_body_range` stops at the closer's
2874    /// line, so a body that ends in a shorter marker line keeps it.
2875    #[test]
2876    fn a_body_ending_in_a_shorter_marker_line_keeps_it() {
2877        let src = "----\nAbove.\n---\n----\nfn f() -> Int = 1\n";
2878        let span = doc_block_span(src);
2879        assert_eq!(doc_block_content(src, span), "Above.\n---");
2880    }
2881
2882    /// #1885: a marker of another length does not close the block, so a
2883    /// `----` opened and only `---` seen after is unclosed at EOF, and the note
2884    /// names the length that would close it.
2885    #[test]
2886    fn a_marker_of_another_length_does_not_close_the_block() {
2887        let err = tokenize("----\nText.\n---\nfn f() -> Int = 1\n").unwrap_err();
2888        assert_eq!(err.category, "bynk.lex.unclosed_doc_block");
2889        let note = err.notes.join(" ");
2890        assert!(note.contains("exactly 4 dashes"), "{note}");
2891        assert!(note.contains("not a divider"), "{note}");
2892        // The other way round: a `---` block does not close on `----`.
2893        let err = tokenize("---\nText.\n----\n").unwrap_err();
2894        assert_eq!(err.category, "bynk.lex.unclosed_doc_block");
2895    }
2896
2897    /// #2807: the recovering lexer reads past every lexical error, reports each
2898    /// in source order and keeps the tokens around them.
2899    #[test]
2900    fn recovering_lex_collects_every_error_and_keeps_the_rest() {
2901        let src = "fn a() $ 1\nlet n = 99999999999999999999\n\"oops\nfn b() ~ 2\n";
2902        let (tokens, errors) = tokenize_recovering(src);
2903        let cats: Vec<_> = errors.iter().map(|e| e.category).collect();
2904        assert_eq!(
2905            cats,
2906            [
2907                "bynk.lex.unexpected_character",
2908                "bynk.lex.integer_overflow",
2909                "bynk.lex.unterminated_string",
2910                "bynk.lex.unexpected_character",
2911            ]
2912        );
2913        assert!(errors.windows(2).all(|w| w[0].span.start < w[1].span.start));
2914        // The text between the errors still lexes.
2915        let fns = tokens.iter().filter(|t| t.kind == TokenKind::Fn).count();
2916        assert_eq!(fns, 2, "{tokens:?}");
2917        // The strict entry point still reports the first error alone.
2918        assert_eq!(tokenize(src).unwrap_err().span.start, errors[0].span.start);
2919    }
2920
2921    /// #2807: recovery always advances, whatever it meets, and never splits a
2922    /// multi-byte character.
2923    #[test]
2924    fn recovering_lex_terminates_on_hostile_input() {
2925        for src in [
2926            "é",
2927            "\"é",
2928            "\"\"\"é",
2929            "$é$",
2930            "///é",
2931            "----\né",
2932            "r\"é",
2933            "\"\\(é",
2934            "9é99999999999999999999é",
2935        ] {
2936            let (_, errors) = tokenize_recovering(src);
2937            assert!(!errors.is_empty(), "{src:?}");
2938        }
2939    }
2940
2941    /// #2807: recovery from a bad escape resumes after the literal's closer,
2942    /// even when a hole holds a string.
2943    #[test]
2944    fn string_recovery_skips_a_hole_holding_a_string() {
2945        let src = "fn f() { show(\"a\\q \\(g(\"x\")) b\", 1) }\nfn k() $";
2946        let (tokens, errors) = tokenize_recovering(src);
2947        let cats: Vec<_> = errors.iter().map(|e| e.category).collect();
2948        assert_eq!(
2949            cats,
2950            ["bynk.lex.bad_escape", "bynk.lex.unexpected_character"]
2951        );
2952        // `, 1) }` after the literal still lexes: nothing of the hole leaked.
2953        assert!(!tokens.iter().any(|t| t.kind == TokenKind::StrLit));
2954        let kinds: Vec<_> = tokens.iter().map(|t| t.kind).collect();
2955        assert_eq!(
2956            &kinds[kinds.len() - 8..kinds.len() - 4],
2957            [
2958                TokenKind::Comma,
2959                TokenKind::IntLit,
2960                TokenKind::RParen,
2961                TokenKind::RBrace
2962            ]
2963        );
2964    }
2965
2966    #[test]
2967    fn adjacent_stray_characters_are_one_error() {
2968        let (_, errors) = tokenize_recovering("fn $$$ f é$");
2969        assert_eq!(errors.len(), 2, "{errors:?}");
2970        assert_eq!(errors[0].span.end - errors[0].span.start, 3);
2971    }
2972
2973    #[test]
2974    fn an_unclosed_doc_block_swallows_the_rest_without_more_errors() {
2975        let (tokens, errors) =
2976            tokenize_recovering("fn a() = 1\n---\nit's prose; don't lex #this\nfn b() = 2\n");
2977        assert_eq!(errors.len(), 1, "{errors:?}");
2978        assert_eq!(errors[0].category, "bynk.lex.unclosed_doc_block");
2979        assert_eq!(tokens.iter().filter(|t| t.kind == TokenKind::Fn).count(), 1);
2980    }
2981
2982    /// #2807: the parse errors a skipped character causes are dropped (also past
2983    /// a comment, and at the end of the file); a real error elsewhere stays.
2984    #[test]
2985    fn lex_echoes_are_dropped() {
2986        let src = "commons d\n\nfn f(a: Int, b: Int) -> Int { a $ b }\n";
2987        let (tokens, lex_errors) = tokenize_recovering(src);
2988        let parse_errors = crate::parser::parse_units_recovering(&tokens, src).errors;
2989        assert!(!parse_errors.is_empty(), "`a b` is a syntax error");
2990        assert!(without_lex_echoes(&lex_errors, &tokens, parse_errors).is_empty());
2991    }
2992
2993    fn surviving(src: &str) -> usize {
2994        let (tokens, lex_errors) = tokenize_recovering(src);
2995        let parse_errors = crate::parser::parse_units_recovering(&tokens, src).errors;
2996        without_lex_echoes(&lex_errors, &tokens, parse_errors).len()
2997    }
2998
2999    #[test]
3000    fn lex_echo_window_skips_comments_and_runs_to_eof() {
3001        assert_eq!(
3002            surviving("commons d\n\nfn f(a: Int, b: Int) -> Int { a $ // why\n b }\n"),
3003            0
3004        );
3005        // An unclosed doc block inside a body: the missing `}` is its echo.
3006        assert_eq!(
3007            surviving("commons d\n\nfn f() -> Int {\n  1\n---\nprose\n"),
3008            0
3009        );
3010        // A genuine syntax error well away from the lex error stays.
3011        assert_eq!(
3012            surviving("commons d\n\nfn f() -> Int { 1 $ }\n\nfn g( -> Int { 1 }\n"),
3013            1
3014        );
3015    }
3016}