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/// The rest of a multi-line literal's opening line, from `body` (just past the
1396/// `"""`): an optional language tag `[a-z][a-z0-9_-]*` (#2958), then — only
1397/// after a tag — spaces or tabs, then `\n` or `\r\n`. Returns the tag's byte
1398/// range (empty when there is none) and the offset where the first body line
1399/// starts. `None` when the line holds anything else.
1400///
1401/// The tag is editor metadata (which language the text is, for embedded
1402/// highlighting): it is not part of the value.
1403fn multiline_opener(bytes: &[u8], body: usize) -> Option<(std::ops::Range<usize>, usize)> {
1404    let mut p = body;
1405    if bytes.get(p).is_some_and(u8::is_ascii_lowercase) {
1406        p += 1;
1407        while bytes.get(p).is_some_and(|&b| {
1408            b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-'
1409        }) {
1410            p += 1;
1411        }
1412    }
1413    let tag = body..p;
1414    if !tag.is_empty() {
1415        while matches!(bytes.get(p), Some(b' ' | b'\t')) {
1416            p += 1;
1417        }
1418    }
1419    match (bytes.get(p), bytes.get(p + 1)) {
1420        (Some(b'\n'), _) => Some((tag, p + 1)),
1421        (Some(b'\r'), Some(b'\n')) => Some((tag, p + 2)),
1422        _ => None,
1423    }
1424}
1425
1426/// Scan and decode a multi-line literal (`"""` / `r"""`) opening at `start`.
1427/// Returns the offset just past the closing `"""`. The closing delimiter sits on
1428/// a line of its own; its indentation is stripped from every body line, and a
1429/// non-blank line indented less is an error. Newlines in the value are always
1430/// `\n`, whatever the file's line endings.
1431fn lex_multiline(
1432    source: &str,
1433    start: usize,
1434    open: StringOpen,
1435    depth: usize,
1436    file: FileId,
1437    segments: &mut Vec<InterpSegment>,
1438    chunk: &mut String,
1439) -> Result<usize, CompileError> {
1440    let bytes = source.as_bytes();
1441    // The opening `"""` is followed by an optional language tag (#2958) and a
1442    // newline (`\n` or `\r\n`).
1443    let Some((_, mut pos)) = multiline_opener(bytes, open.body) else {
1444        return Err(CompileError::new(
1445            "bynk.lex.multiline_open",
1446            Span::new_in(file, start, open.body),
1447            "the opening `\"\"\"` of a multi-line string must be followed by a newline",
1448        )
1449        .with_note(
1450            "the text starts on the next line; write `\"\"\"` then press enter. \
1451             An optional language tag may sit directly after the delimiter \
1452             (`\"\"\"sql`): a lowercase letter, then lowercase letters, digits, `_` or `-`",
1453        ));
1454    };
1455    // Pass 1: split into lines up to the closing line.
1456    let mut lines: Vec<(usize, usize)> = Vec::new(); // (start, end) excluding the `\n` / `\r\n`
1457    let (indent_len, end) = loop {
1458        if pos >= bytes.len() {
1459            return Err(unterminated_string(file, start, bytes.len(), true));
1460        }
1461        let line_end = bytes[pos..]
1462            .iter()
1463            .position(|&b| b == b'\n')
1464            .map_or(bytes.len(), |n| pos + n);
1465        let mut ws = pos;
1466        while ws < line_end && matches!(bytes[ws], b' ' | b'\t') {
1467            ws += 1;
1468        }
1469        // A closing delimiter ends its line's text: `"""quoted"""` is body text
1470        // holding a stray `"""`, which `decode_run` reports.
1471        if bytes[ws..line_end].starts_with(b"\"\"\"")
1472            && !bytes
1473                .get(ws + 3)
1474                .is_some_and(|&b| is_ident_byte(b) || b == b'"')
1475        {
1476            break (ws - pos, ws + 3);
1477        }
1478        let trimmed_end = if line_end > pos && bytes[line_end - 1] == b'\r' {
1479            line_end - 1
1480        } else {
1481            line_end
1482        };
1483        lines.push((pos, trimmed_end));
1484        if line_end >= bytes.len() {
1485            return Err(unterminated_string(file, start, bytes.len(), true));
1486        }
1487        pos = line_end + 1;
1488    };
1489    let closing_line_start = end - 3 - indent_len;
1490    let indent = &bytes[closing_line_start..closing_line_start + indent_len];
1491    // Pass 2: strip the indentation and decode each line.
1492    for (n, &(ls, le)) in lines.iter().enumerate() {
1493        if n > 0 {
1494            chunk.push('\n');
1495        }
1496        let line = &bytes[ls..le];
1497        let blank = line.iter().all(|b| matches!(b, b' ' | b'\t'));
1498        let content = if line.starts_with(indent) {
1499            ls + indent_len
1500        } else if blank {
1501            le // a blank line shorter than the indentation is empty
1502        } else {
1503            return Err(CompileError::new(
1504                "bynk.lex.multiline_indent",
1505                boundary_span(source, file, ls, le),
1506                "a line of this multi-line string is indented less than its closing `\"\"\"`",
1507            )
1508            .with_note(if line.len() >= indent.len() {
1509                "the indentation must match the closing delimiter's character for character: \
1510                 a tab does not match a space, so mixed tabs and spaces fail here"
1511            } else {
1512                "every non-blank line must start with the closing delimiter's indentation, \
1513                 which is stripped from the value"
1514            }));
1515        };
1516        if open.raw {
1517            let text = &source[content..le];
1518            if let Some(at) = text.find("\"\"\"") {
1519                return Err(CompileError::new(
1520                    "bynk.lex.multiline_close_not_own_line",
1521                    Span::new_in(file, content + at, content + at + 3),
1522                    "a `\"\"\"` inside a multi-line string closes it, but it is not on a line of its own",
1523                )
1524                .with_note("put the closing `\"\"\"` on its own line"));
1525            }
1526            chunk.push_str(text);
1527        } else {
1528            decode_run(source, content, Stop::End(le), depth, file, segments, chunk)?;
1529        }
1530    }
1531    Ok(end)
1532}
1533
1534/// Scan an interpolation hole body. `start` points just past the `\(`; returns
1535/// the offset just past the matching `)`. Tracks paren depth and skips nested
1536/// strings (whose own parens must not close the hole), recursing through
1537/// [`lex_string`] so nested interpolation nests correctly. (v0.43.)
1538fn scan_hole(
1539    bytes: &[u8],
1540    source: &str,
1541    start: usize,
1542    nesting: usize,
1543    file: FileId,
1544) -> Result<usize, CompileError> {
1545    if nesting > crate::MAX_NESTING_DEPTH {
1546        // Anchor on the `\(` opener that tipped over the limit; it sits two
1547        // bytes before `start` and is pure ASCII, so the span stays on char
1548        // boundaries (a fuzz invariant).
1549        return Err(too_deeply_nested_interpolation(Span::new_in(
1550            file,
1551            start.saturating_sub(2),
1552            start,
1553        )));
1554    }
1555    let mut i = start;
1556    let mut depth = 1usize;
1557    loop {
1558        if i >= bytes.len() || bytes[i] == b'\n' {
1559            return Err(CompileError::new(
1560                "bynk.lex.unterminated_interpolation",
1561                Span::new_in(file, start.saturating_sub(2), i.min(bytes.len())),
1562                "unterminated interpolation hole",
1563            )
1564            .with_note(
1565                "an interpolation hole `\\(…)` must close with a matching `)` on the same line",
1566            ));
1567        }
1568        match bytes[i] {
1569            b'(' => {
1570                depth += 1;
1571                i += 1;
1572            }
1573            b')' => {
1574                depth -= 1;
1575                i += 1;
1576                if depth == 0 {
1577                    return Ok(i);
1578                }
1579            }
1580            b'"' => i = nested_string(source, i, nesting, file)?,
1581            // A nested raw string: `r"…"` (not the tail of an identifier).
1582            b'r' if (i == start || !is_ident_byte(bytes[i - 1]))
1583                && string_open(bytes, i).is_some() =>
1584            {
1585                i = nested_string(source, i, nesting, file)?
1586            }
1587            _ => i += 1,
1588        }
1589    }
1590}
1591
1592/// Skip a string literal nested in an interpolation hole. A hole closes on the
1593/// line it opens on, so a multi-line string cannot sit inside one (#2619).
1594fn nested_string(
1595    source: &str,
1596    at: usize,
1597    nesting: usize,
1598    file: FileId,
1599) -> Result<usize, CompileError> {
1600    if string_open(source.as_bytes(), at).is_some_and(|o| o.multi) {
1601        return Err(CompileError::new(
1602            "bynk.lex.multiline_in_hole",
1603            Span::new_in(file, at, at + 3),
1604            "a multi-line string cannot appear inside an interpolation hole",
1605        )
1606        .with_note(
1607            "an interpolation hole `\\(…)` closes on the line it opens on; \
1608             bind the multi-line text to a name and interpolate that",
1609        ));
1610    }
1611    Ok(lex_string(source, at, nesting + 1, file)?.0)
1612}
1613
1614/// The bounded-depth diagnostic for interpolation that nests past
1615/// [`crate::MAX_NESTING_DEPTH`]. `\("\("\(…` mutually recurses
1616/// [`lex_string`] ↔ [`scan_hole`], one stack frame per level, so an unbounded
1617/// scanner overflows and aborts `tokenize` (#713). `span` anchors the report on
1618/// the opener that tipped over the limit (the `"` or the `\(`).
1619fn too_deeply_nested_interpolation(span: Span) -> CompileError {
1620    CompileError::new(
1621        "bynk.lex.interpolation_too_deep",
1622        span,
1623        format!(
1624            "string interpolation nests more than {} levels deep",
1625            crate::MAX_NESTING_DEPTH
1626        ),
1627    )
1628    .with_note(
1629        "deeply nested `\\(…)` interpolation is rejected to keep the lexer from \
1630         overflowing its stack and aborting; flatten or split the string",
1631    )
1632}
1633
1634/// One segment of a split interpolated string (v0.43): literal text (escapes
1635/// resolved) or the absolute source span of a hole's expression (the bytes
1636/// between `\(` and its matching `)`). The parser turns the latter into a real
1637/// `Expr`; the lexer owns only the scanning.
1638pub(crate) enum InterpSegment {
1639    Chunk(String),
1640    Hole(Span),
1641}
1642
1643/// Split a string token (its `span` covers the whole lexeme) into chunks and
1644/// hole spans. Escapes in the chunks are resolved by [`lex_string`], the one
1645/// decoder every form shares; holes are returned as spans for the parser to
1646/// re-lex and parse as expressions. (v0.43, #2619.)
1647pub(crate) fn split_interp(source: &str, span: Span) -> Result<Vec<InterpSegment>, CompileError> {
1648    lex_string(source, span.start, 0, span.file).map(|(_, segments)| segments)
1649}
1650
1651/// A highlighted part of a string literal (#2619), for the semantic tokens.
1652#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1653pub enum StringPart {
1654    /// An escape sequence (`\n`, `\u{1F600}`, …).
1655    Escape,
1656    /// A raw literal's delimiter (`r#"` or `"#`), which the TextMate grammar
1657    /// cannot tell from an identifier `r` and a quote.
1658    RawDelimiter,
1659    /// A multi-line literal's language tag (`sql` in `"""sql`, #2958): editor
1660    /// metadata, not part of the value.
1661    Tag,
1662}
1663
1664/// The escape sequences and raw delimiters of every string literal in `source`
1665/// (including those inside interpolation holes), for the editor's semantic
1666/// tokens. Empty when the file does not lex.
1667pub fn string_parts(source: &str) -> Vec<(Span, StringPart)> {
1668    let mut out = Vec::new();
1669    if let Ok(tokens) = tokenize(source) {
1670        for tok in tokens {
1671            string_parts_of(source, tok, &mut out);
1672        }
1673    }
1674    out
1675}
1676
1677/// [`string_parts`] for one token, recursing into the holes of an
1678/// interpolated string (each hole re-lexed and rebased to absolute offsets).
1679fn string_parts_of(source: &str, tok: Token, out: &mut Vec<(Span, StringPart)>) {
1680    if !matches!(tok.kind, TokenKind::StrLit | TokenKind::InterpStr) {
1681        return;
1682    }
1683    let bytes = source.as_bytes();
1684    let file = tok.span.file;
1685    let Some(open) = string_open(bytes, tok.span.start) else {
1686        return;
1687    };
1688    if open.multi
1689        && let Some((tag, _)) = multiline_opener(bytes, open.body)
1690        && !tag.is_empty()
1691    {
1692        out.push((Span::new_in(file, tag.start, tag.end), StringPart::Tag));
1693    }
1694    if open.raw {
1695        // `r#"` and the closing `"#`; a multi-line raw literal's `r"""` /
1696        // `"""` likewise.
1697        let close = if open.multi { 3 } else { 1 + open.hashes };
1698        out.push((
1699            Span::new_in(file, tok.span.start, open.body),
1700            StringPart::RawDelimiter,
1701        ));
1702        out.push((
1703            Span::new_in(file, tok.span.end - close, tok.span.end),
1704            StringPart::RawDelimiter,
1705        ));
1706        return;
1707    }
1708    let Ok(segments) = split_interp(source, tok.span) else {
1709        return;
1710    };
1711    let holes: Vec<Span> = segments
1712        .iter()
1713        .filter_map(|s| match s {
1714            InterpSegment::Hole(h) => Some(*h),
1715            InterpSegment::Chunk(_) => None,
1716        })
1717        .collect();
1718    let mut i = open.body;
1719    let end = tok.span.end - if open.multi { 3 } else { 1 };
1720    while i < end {
1721        if bytes[i] != b'\\' {
1722            i += 1;
1723            continue;
1724        }
1725        if let Some(hole) = holes.iter().find(|h| h.start == i + 2) {
1726            i = hole.end + 1; // past the hole's `)`
1727            continue;
1728        }
1729        let len = if bytes.get(i + 1) == Some(&b'u') {
1730            bytes[i..end]
1731                .iter()
1732                .position(|&b| b == b'}')
1733                .map_or(2, |n| n + 1)
1734        } else {
1735            2
1736        };
1737        out.push((Span::new_in(file, i, i + len), StringPart::Escape));
1738        i += len;
1739    }
1740    for hole in holes {
1741        let Ok(hole_tokens) = tokenize_in(&source[hole.range()], file) else {
1742            continue;
1743        };
1744        for mut t in hole_tokens {
1745            t.span = Span::new_in(file, t.span.start + hole.start, t.span.end + hole.start);
1746            string_parts_of(source, t, out);
1747        }
1748    }
1749}
1750
1751/// The value of a hole-free string lexeme (any form), for callers that hold the
1752/// lexeme rather than the surrounding source. `span` locates the lexeme in the
1753/// file so a (normally unreachable — the lexer already validated it) error
1754/// points at the right place.
1755pub fn decode_string_literal(lexeme: &str, span: Span) -> Result<String, CompileError> {
1756    let (_, segments) =
1757        lex_string(lexeme, 0, 0, span.file).map_err(|e| e.offset_spans(span.start))?;
1758    let mut out = String::with_capacity(lexeme.len());
1759    for segment in segments {
1760        if let InterpSegment::Chunk(text) = segment {
1761            out.push_str(&text);
1762        }
1763    }
1764    Ok(out)
1765}
1766
1767/// If a `---` doc-block marker line starts at or shortly after `pos` (which
1768/// must be at a line boundary), return the byte offset just past the marker
1769/// line (after the terminating newline, or at EOF), and the marker's dash
1770/// count (three or more). The doc-block grammar requires the marker to be
1771/// alone on its line; leading horizontal whitespace is allowed and ignored.
1772fn doc_block_open_at(source: &str, pos: usize) -> Option<(usize, usize)> {
1773    let bytes = source.as_bytes();
1774    if !at_line_start(source, pos) {
1775        return None;
1776    }
1777    // Skip leading horizontal whitespace.
1778    let mut i = pos;
1779    while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
1780        i += 1;
1781    }
1782    if i + 3 > bytes.len() {
1783        return None;
1784    }
1785    if &bytes[i..i + 3] != b"---" {
1786        return None;
1787    }
1788    let dash_start = i;
1789    i += 3;
1790    // The marker may have additional trailing dashes (per spec "three or more
1791    // consecutive hyphens"). Consume them.
1792    while i < bytes.len() && bytes[i] == b'-' {
1793        i += 1;
1794    }
1795    let dashes = i - dash_start;
1796    // After the dashes, allow only horizontal whitespace then newline/EOF.
1797    while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t' || bytes[i] == b'\r') {
1798        i += 1;
1799    }
1800    if i == bytes.len() {
1801        return Some((i, dashes));
1802    }
1803    if bytes[i] == b'\n' {
1804        return Some((i + 1, dashes));
1805    }
1806    None
1807}
1808
1809/// Find the next closing marker line at or after `pos`: a marker of exactly
1810/// `dashes` dashes, the opener's count (#1885), as with a Markdown code fence.
1811/// A marker line of any other length is content, so a longer fence can hold a
1812/// shorter one. Returns `(start_of_line, end_of_line)` (`end_of_line` is just
1813/// past the terminating newline, or at EOF).
1814fn doc_block_close(source: &str, mut pos: usize, dashes: usize) -> Option<(usize, usize)> {
1815    let bytes = source.as_bytes();
1816    while pos < bytes.len() {
1817        // Advance pos to the start of a line.
1818        let line_start = pos;
1819        // Find the end of this line.
1820        let mut line_end = line_start;
1821        while line_end < bytes.len() && bytes[line_end] != b'\n' {
1822            line_end += 1;
1823        }
1824        // Check this line.
1825        if let Some((end, n)) = doc_block_open_at(source, line_start)
1826            && n == dashes
1827        {
1828            return Some((line_start, end));
1829        }
1830        // Move to the next line.
1831        pos = if line_end < bytes.len() {
1832            line_end + 1
1833        } else {
1834            line_end
1835        };
1836    }
1837    None
1838}
1839
1840/// Returns true if byte offset `pos` is at a line start (column 0).
1841/// #1888: true when only horizontal whitespace precedes `pos` on its line.
1842fn only_indent_before(bytes: &[u8], pos: usize) -> bool {
1843    bytes[..pos]
1844        .iter()
1845        .rev()
1846        .take_while(|&&b| b != b'\n')
1847        .all(|&b| matches!(b, b' ' | b'\t' | b'\r'))
1848}
1849
1850/// #2203: a `--- text` one-line doc starts at `pos`: three or more dashes, a
1851/// space or tab, then something other than whitespace on the line. A marker
1852/// with only whitespace after it is a doc-block fence, which
1853/// [`doc_block_open_at`] has already taken.
1854fn doc_line_marker(bytes: &[u8], pos: usize) -> bool {
1855    let dashes = bytes[pos..].iter().take_while(|&&b| b == b'-').count();
1856    if dashes < 3 || !matches!(bytes.get(pos + dashes), Some(b' ' | b'\t')) {
1857        return false;
1858    }
1859    bytes[pos + dashes..]
1860        .iter()
1861        .take_while(|&&b| b != b'\n')
1862        .any(|b| !b.is_ascii_whitespace())
1863}
1864
1865/// Push a [`TokenKind::DocLine`] from `start` through the end of its line,
1866/// newline included, and return the position after it.
1867fn push_doc_line(tokens: &mut Vec<Token>, bytes: &[u8], start: usize, file: FileId) -> usize {
1868    let mut pos = start;
1869    while pos < bytes.len() && bytes[pos] != b'\n' {
1870        pos += 1;
1871    }
1872    if pos < bytes.len() {
1873        pos += 1;
1874    }
1875    tokens.push(Token {
1876        kind: TokenKind::DocLine,
1877        span: Span::new_in(file, start, pos),
1878    });
1879    pos
1880}
1881
1882/// Push a [`TokenKind::Comment`] from `start` up to (not including) the end of
1883/// its line, and return the position after it.
1884fn push_comment(tokens: &mut Vec<Token>, bytes: &[u8], start: usize, file: FileId) -> usize {
1885    let mut pos = start;
1886    while pos < bytes.len() && bytes[pos] != b'\n' {
1887        pos += 1;
1888    }
1889    tokens.push(Token {
1890        kind: TokenKind::Comment,
1891        span: Span::new_in(file, start, pos),
1892    });
1893    pos
1894}
1895
1896/// #2203: the `bynk.lex.slash_doc` error for a `///` at `pos`, with the fix to
1897/// `---` (and a separating space when text follows straight on).
1898fn slash_doc_error(bytes: &[u8], pos: usize, file: FileId) -> CompileError {
1899    let marker = Span::new_in(file, pos, pos + 3);
1900    let fix = match bytes.get(pos + 3) {
1901        None | Some(b' ' | b'\t' | b'\r' | b'\n') => "---",
1902        Some(_) => "--- ",
1903    };
1904    CompileError::new(
1905        "bynk.lex.slash_doc",
1906        marker,
1907        "`///` is not a doc comment in Bynk; a doc is written with dashes",
1908    )
1909    .with_note(
1910        "a one-line doc is `--- text`, and a longer doc is a block between two `---` lines; \
1911         a comment is `//`",
1912    )
1913    .with_suggestion(
1914        "write `---`",
1915        vec![(marker, fix.to_string())],
1916        Applicability::MachineApplicable,
1917    )
1918}
1919
1920/// The text of a [`TokenKind::DocLine`] token: everything after the marker,
1921/// less one separating space or tab, less a closer (#2203: a final run of
1922/// exactly the opener's dashes, preceded by whitespace, with that whitespace),
1923/// and less the line ending. Further indentation is kept, so a doc line can
1924/// indent a Markdown code block. A legacy `--|` line (#1888) has no closer.
1925pub fn doc_line_text(source: &str, span: Span) -> &str {
1926    let slice = source[span.range()].trim_end_matches(['\n', '\r']);
1927    if let Some(rest) = slice.strip_prefix("--|") {
1928        return rest.strip_prefix(' ').unwrap_or(rest);
1929    }
1930    let dashes = slice.bytes().take_while(|&b| b == b'-').count();
1931    let rest = &slice[dashes..];
1932    let rest = rest.strip_prefix([' ', '\t']).unwrap_or(rest);
1933    let body = rest.trim_end_matches([' ', '\t']);
1934    let run = body.len() - body.trim_end_matches('-').len();
1935    if run == dashes {
1936        let before = &body[..body.len() - run];
1937        if before.is_empty() || before.ends_with([' ', '\t']) {
1938            return before.trim_end_matches([' ', '\t']);
1939        }
1940    }
1941    rest
1942}
1943
1944/// #2203: the source spelling of a one-line doc whose text is `text`: `--- `
1945/// and the text, or a `----` marker when the text ends in a whitespace-preceded
1946/// run of three dashes (which a `---` marker would read as its closer). Empty
1947/// text is spelled with a closer, `--- ---`, since a bare `---` opens a block.
1948pub fn one_line_doc(text: &str) -> String {
1949    let body = text.trim_end_matches([' ', '\t']);
1950    if body.trim().is_empty() {
1951        return "--- ---".to_string();
1952    }
1953    let run = body.len() - body.trim_end_matches('-').len();
1954    let before = &body[..body.len() - run];
1955    let closer_shaped = run == 3 && (before.is_empty() || before.ends_with([' ', '\t']));
1956    let marker = if closer_shaped { "----" } else { "---" };
1957    format!("{marker} {text}")
1958}
1959
1960/// #1885: the dash count of the fence a doc block holding `doc` is printed
1961/// with: one more than the longest marker-shaped line in it (a line of three
1962/// or more dashes alone, surrounding whitespace aside), and never fewer than
1963/// three. A shorter fence would close at that line.
1964pub fn doc_fence_len(doc: &str) -> usize {
1965    doc.lines()
1966        .map(str::trim)
1967        .filter(|l| l.len() >= 3 && l.bytes().all(|b| b == b'-'))
1968        .map(|l| l.len() + 1)
1969        .fold(3, usize::max)
1970}
1971
1972/// #2203: whether a [`TokenKind::DocLine`] token is spelled the legacy way,
1973/// `--|` (#1888), rather than `--- text`.
1974pub fn doc_line_is_legacy(source: &str, span: Span) -> bool {
1975    source[span.range()].starts_with("--|")
1976}
1977
1978/// #2203: whether a [`TokenKind::Comment`] token is spelled the legacy way,
1979/// `--`, rather than `//`.
1980pub fn comment_is_legacy(source: &str, span: Span) -> bool {
1981    source[span.range()].starts_with("--")
1982}
1983
1984/// #2203 (DECISION E): the `bynk.lex.legacy_comment` error for the legacy `--`
1985/// comment at `span` (a [`TokenKind::Comment`] token), with the fix to `//`.
1986pub fn legacy_comment_error(span: Span) -> CompileError {
1987    let marker = Span::new_in(span.file, span.start, span.start + 2);
1988    CompileError::new(
1989        "bynk.lex.legacy_comment",
1990        marker,
1991        "`--` no longer starts a comment; a line comment is `//`",
1992    )
1993    .with_note(
1994        "`bynk fmt` rewrites every legacy comment; for subtraction of a negation, write `a - -b`",
1995    )
1996    .with_suggestion(
1997        "write `//`",
1998        vec![(marker, "//".to_string())],
1999        Applicability::MachineApplicable,
2000    )
2001}
2002
2003/// #2203 (DECISION E): the `bynk.lex.legacy_doc_line` error for the legacy
2004/// `--|` doc line at `span` (a [`TokenKind::DocLine`] token), with the fix to
2005/// `--- text`. A `--|` with no text has no one-line spelling (a bare `---`
2006/// opens a block), so it gets no fix.
2007pub fn legacy_doc_line_error(source: &str, span: Span) -> CompileError {
2008    let marker = Span::new_in(span.file, span.start, span.start + 3);
2009    let err = CompileError::new(
2010        "bynk.lex.legacy_doc_line",
2011        marker,
2012        "`--|` no longer starts a doc; a one-line doc is `--- text`",
2013    );
2014    let text = doc_line_text(source, span);
2015    if text.trim().is_empty() {
2016        return err.with_note(
2017            "a one-line doc holds text; a doc of more than one paragraph is a block between \
2018             two `---` lines",
2019        );
2020    }
2021    let line = Span::new_in(span.file, span.start, doc_line_text_range(source, span).end);
2022    err.with_note("`bynk fmt` rewrites every legacy doc line")
2023        .with_suggestion(
2024            "write `--- `",
2025            vec![(line, one_line_doc(text))],
2026            Applicability::MachineApplicable,
2027        )
2028}
2029
2030/// #1888: the byte range of a doc line's text within `source`, as
2031/// [`doc_line_text`] returns it, for callers that map positions in a doc
2032/// back to the source (document links).
2033pub fn doc_line_text_range(source: &str, span: Span) -> std::ops::Range<usize> {
2034    let text = doc_line_text(source, span);
2035    let start = text.as_ptr() as usize - source.as_ptr() as usize;
2036    start..start + text.len()
2037}
2038
2039fn at_line_start(source: &str, pos: usize) -> bool {
2040    if pos == 0 {
2041        return true;
2042    }
2043    let bytes = source.as_bytes();
2044    bytes[pos - 1] == b'\n'
2045}
2046
2047/// The doc-block body as a byte range into `source` — leading/trailing `---`
2048/// marker lines stripped, no further processing (unlike [`doc_block_content`],
2049/// which additionally strips a common per-line indent — not offset-preserving).
2050/// Callers that need to map a position in the body back to `source` (e.g.
2051/// document-link spans) use this instead of re-deriving it from the string
2052/// `doc_block_content` returns.
2053pub fn doc_block_body_range(source: &str, span: Span) -> Option<std::ops::Range<usize>> {
2054    let slice = &source[span.range()];
2055    // Drop the first line (opening marker).
2056    let after_open_rel = slice.find('\n')? + 1;
2057    let after_open = &slice[after_open_rel..];
2058    let bytes = after_open.as_bytes();
2059    // Trim the trailing closing-marker line.
2060    let mut i = bytes.len();
2061    if i > 0 && bytes[i - 1] == b'\n' {
2062        i -= 1;
2063    }
2064    while i > 0 && matches!(bytes[i - 1], b' ' | b'\t' | b'\r') {
2065        i -= 1;
2066    }
2067    while i > 0 && bytes[i - 1] == b'-' {
2068        i -= 1;
2069    }
2070    if i > 0 && bytes[i - 1] == b'\n' {
2071        i -= 1;
2072    }
2073    let start = span.range().start + after_open_rel;
2074    Some(start..start + i)
2075}
2076
2077/// Extract the body content of a doc-block token from its source span.
2078/// Strips the leading and trailing `---` marker lines and returns the body
2079/// verbatim. If every non-empty content line begins with the same horizontal
2080/// whitespace prefix (e.g., because the doc block sits inside a brace-form
2081/// commons body), that common prefix is removed so the body reads naturally
2082/// when emitted as JSDoc.
2083pub fn doc_block_content(source: &str, span: Span) -> String {
2084    let Some(range) = doc_block_body_range(source, span) else {
2085        return String::new();
2086    };
2087    let body = &source[range];
2088
2089    // Compute the common leading-whitespace prefix across all non-empty lines
2090    // and strip it. This lets writers indent the doc block alongside the
2091    // declaration it documents without bleeding the indent into the JSDoc.
2092    let common: Option<usize> = body
2093        .lines()
2094        .filter(|l| !l.trim().is_empty())
2095        .map(|l| l.bytes().take_while(|&b| b == b' ' || b == b'\t').count())
2096        .min();
2097    let strip = common.unwrap_or(0);
2098    if strip == 0 {
2099        return body.to_string();
2100    }
2101    let mut out = String::with_capacity(body.len());
2102    let mut first = true;
2103    for line in body.lines() {
2104        if !first {
2105            out.push('\n');
2106        }
2107        first = false;
2108        if line.trim().is_empty() {
2109            // Preserve blank lines.
2110            continue;
2111        }
2112        let leading: usize = line
2113            .bytes()
2114            .take_while(|&b| b == b' ' || b == b'\t')
2115            .count();
2116        let drop = strip.min(leading);
2117        out.push_str(&line[drop..]);
2118    }
2119    out
2120}
2121
2122/// Extract the body of a `Comment` trivia token: everything after the
2123/// leading `//` marker, preserving its inline whitespace verbatim. Used by
2124/// the parser when attaching comments to declarations.
2125///
2126/// #2203: a legacy `--` comment's body is what follows its `--`, so the
2127/// formatter prints it after `//`. A legacy body that starts with a dash (a
2128/// `--- Helpers ---` divider, read by [`tokenize_legacy`]) keeps all of its
2129/// dashes behind a space instead, as `// --- Helpers ---`, and a legacy
2130/// `--|` after code (#1888) drops its `|`.
2131pub fn comment_body(source: &str, span: Span) -> std::borrow::Cow<'_, str> {
2132    let slice = &source[span.range()];
2133    if let Some(rest) = slice.strip_prefix("//") {
2134        return rest.into();
2135    }
2136    match slice.strip_prefix("--") {
2137        Some(rest) if rest.starts_with('-') => format!(" {slice}").into(),
2138        // A `--|` after code was a comment (#1888); its `|` was not text.
2139        Some(rest) => rest.strip_prefix('|').unwrap_or(rest).into(),
2140        None => slice.into(),
2141    }
2142}
2143
2144/// Returns true if there is a blank line (a line containing only whitespace)
2145/// in `source` strictly between byte offsets `from` (inclusive) and `to`
2146/// (exclusive). Used by the parser to detect orphan doc blocks.
2147///
2148/// A doc-block token's span ends just past the closing-marker line's
2149/// terminating newline. So if the next declaration begins on the immediately
2150/// following line, the substring between contains no newline (only optional
2151/// indentation). Any newline in the substring therefore implies at least one
2152/// entirely-blank line separating the doc from the declaration.
2153pub fn has_blank_line_between(source: &str, from: usize, to: usize) -> bool {
2154    if to <= from {
2155        return false;
2156    }
2157    let bytes = source.as_bytes();
2158    let mut i = from;
2159    while i < to {
2160        if bytes[i] == b'\n' {
2161            return true;
2162        }
2163        if !matches!(bytes[i], b' ' | b'\t' | b'\r') {
2164            return false;
2165        }
2166        i += 1;
2167    }
2168    false
2169}
2170
2171#[cfg(test)]
2172mod tests {
2173    use super::*;
2174
2175    fn kinds(source: &str) -> Vec<TokenKind> {
2176        tokenize(source)
2177            .unwrap()
2178            .into_iter()
2179            .map(|t| t.kind)
2180            .collect()
2181    }
2182
2183    #[test]
2184    fn keywords_and_idents() {
2185        use TokenKind::*;
2186        assert_eq!(
2187            kinds("commons type fn where true false Int String Bool foo bar"),
2188            vec![
2189                Commons, Type, Fn, Where, True, False, Int, String, Bool, Ident, Ident
2190            ],
2191        );
2192        // #548: `and` is no longer a keyword — it lexes as an ordinary identifier.
2193        assert_eq!(kinds("and"), vec![Ident]);
2194    }
2195
2196    #[test]
2197    fn deeply_nested_interpolation_is_bounded_not_overflowed() {
2198        // `"\("\("\(…` mutually recurses lex_string <-> scan_hole, one frame per
2199        // level, and an unbounded scanner overflows `tokenize` and aborts the
2200        // process (#713). Well past the limit it must return a bounded-depth
2201        // diagnostic instead. The holes are left open so the depth guard, not a
2202        // later `)`, stops the scan.
2203        let depth = crate::MAX_NESTING_DEPTH + 8;
2204        let src = format!("\"{}", "\\(\"".repeat(depth));
2205        let err = tokenize(&src).unwrap_err();
2206        assert_eq!(err.category, "bynk.lex.interpolation_too_deep");
2207    }
2208
2209    #[test]
2210    fn integer_and_string_literals() {
2211        use TokenKind::*;
2212        assert_eq!(
2213            kinds(r#"0 42 "hello" "with\nescape""#),
2214            vec![IntLit, IntLit, StrLit, StrLit]
2215        );
2216    }
2217
2218    #[test]
2219    fn operators() {
2220        use TokenKind::*;
2221        assert_eq!(
2222            kinds("-> == != <= >= && || + - * / % ! = < > ( ) { } [ ] , : . @"),
2223            vec![
2224                Arrow, EqEq, BangEq, LtEq, GtEq, AmpAmp, PipePipe, Plus, Minus, Star, Slash,
2225                Percent, Bang, Eq, Lt, Gt, LParen, RParen, LBrace, RBrace, LBracket, RBracket,
2226                Comma, Colon, Dot, At,
2227            ],
2228        );
2229    }
2230
2231    #[test]
2232    fn dot_family_maximal_munch() {
2233        // Events track slice 1 (spine #936): `..` must lex as one `DotDot`
2234        // token, not two `Dot`s — a real token keeps agreement with
2235        // tree-sitter (which declares `".."` as one literal), so a
2236        // whitespace-split `. .` cannot silently parse where a real `..`
2237        // is required. Also confirms `...`/`..`/`.` don't shadow each other
2238        // regardless of declaration order (logos maximal-munch).
2239        use TokenKind::*;
2240        assert_eq!(
2241            kinds("a .. b ... c . d . ."),
2242            vec![Ident, DotDot, Ident, DotDotDot, Ident, Dot, Ident, Dot, Dot,],
2243        );
2244    }
2245
2246    #[test]
2247    fn line_comments_emitted_as_trivia() {
2248        // v1.1: line comments are preserved as Comment tokens so the
2249        // formatter can attach and re-emit them.
2250        use TokenKind::*;
2251        let src = "// a comment\ntype X = Int // trailing\n";
2252        assert_eq!(kinds(src), vec![Comment, Type, Ident, Eq, Int, Comment],);
2253    }
2254
2255    #[test]
2256    fn comment_body_extracts_text_after_marker() {
2257        let toks = tokenize("// hello world\n").unwrap();
2258        assert_eq!(toks.len(), 1);
2259        assert_eq!(toks[0].kind, TokenKind::Comment);
2260        assert_eq!(
2261            comment_body("// hello world\n", toks[0].span),
2262            " hello world"
2263        );
2264    }
2265
2266    #[test]
2267    fn comment_does_not_consume_newline() {
2268        // Two adjacent comment lines should produce two distinct tokens
2269        // — the newline between them is not part of either comment's span.
2270        let toks = tokenize("// one\n// two\n").unwrap();
2271        assert_eq!(toks.len(), 2);
2272        assert!(toks.iter().all(|t| t.kind == TokenKind::Comment));
2273    }
2274
2275    /// #2203: `//` is a comment anywhere, with no whitespace rule, and `--` is
2276    /// two `-` operators unless it is spelled like a legacy comment.
2277    #[test]
2278    fn slash_slash_is_a_comment_and_dash_dash_is_subtraction() {
2279        use TokenKind::*;
2280        assert_eq!(kinds("a//b"), vec![Ident, Comment]);
2281        assert_eq!(kinds("a--b"), vec![Ident, Minus, Minus, Ident]);
2282        assert_eq!(kinds("x--\ny"), vec![Ident, Minus, Minus, Ident]);
2283        assert_eq!(kinds("x--"), vec![Ident, Minus, Minus]);
2284        assert_eq!(kinds("a - -b"), vec![Ident, Minus, Minus, Ident]);
2285        assert_eq!(kinds("a --b"), vec![Ident, Minus, Minus, Ident]);
2286        // A `////` ruler is a comment, as in Rust.
2287        assert_eq!(kinds("////////\n"), vec![Comment]);
2288        assert_eq!(kinds("// -------\n"), vec![Comment]);
2289    }
2290
2291    /// #2203 (DECISION E): a `--` at the start of a line or after whitespace,
2292    /// followed by a space or the end of the line, lexes as a comment still,
2293    /// spelled the legacy way, so it is never read as `- -`.
2294    #[test]
2295    fn a_legacy_dash_dash_comment_still_lexes_as_a_comment() {
2296        use TokenKind::*;
2297        assert_eq!(kinds("a -- c"), vec![Ident, Comment]);
2298        assert_eq!(kinds("-- c"), vec![Comment]);
2299        assert_eq!(kinds("a\n--\nb"), vec![Ident, Comment, Ident]);
2300        assert_eq!(kinds("x --"), vec![Ident, Comment]);
2301        assert_eq!(
2302            kinds("a: Int, --| note"),
2303            vec![Ident, Colon, Int, Comma, Comment]
2304        );
2305        let src = "x = 1 -- note\n";
2306        let toks = tokenize(src).unwrap();
2307        let c = toks.last().unwrap().span;
2308        assert!(comment_is_legacy(src, c));
2309        assert_eq!(comment_body(src, c), " note");
2310        let err = legacy_comment_error(c);
2311        assert_eq!(err.category, "bynk.lex.legacy_comment");
2312        assert_eq!(err.suggestions[0].edits[0].1, "//");
2313        assert_eq!(&src[err.suggestions[0].edits[0].0.range()], "--");
2314    }
2315
2316    /// #2203 (DECISION C): `--- text` first on its line is a one-line doc; its
2317    /// closer is a final run of exactly the opener's dashes after whitespace.
2318    #[test]
2319    fn a_dash_marker_with_text_is_a_doc_line() {
2320        use TokenKind::*;
2321        assert_eq!(kinds("--- doc\ntype"), vec![DocLine, Type]);
2322        assert_eq!(kinds("  ---- doc\n\t--- x"), vec![DocLine, DocLine]);
2323        let text = |src: &str| {
2324            let toks = tokenize(src).unwrap();
2325            assert_eq!(toks[0].kind, DocLine, "{src}");
2326            doc_line_text(src, toks[0].span).to_string()
2327        };
2328        assert_eq!(text("--- The repo.\n"), "The repo.");
2329        assert_eq!(text("--- The repo. ---\n"), "The repo.");
2330        assert_eq!(text("---- The repo. ----\r\n"), "The repo.");
2331        // A trailing run of another length is text.
2332        assert_eq!(text("--- a -- b ----\n"), "a -- b ----");
2333        assert_eq!(text("---- a ---\n"), "a ---");
2334        assert_eq!(text("--- a---\n"), "a---");
2335        // Further indentation is kept.
2336        assert_eq!(text("---   indented\n"), "  indented");
2337        // A marker alone is a block fence, and a `---` with no space after it
2338        // is not a doc.
2339        assert_eq!(kinds("---\nx\n---\n"), vec![DocBlock]);
2340        assert_eq!(kinds("---x\n"), vec![Minus, Minus, Minus, Ident]);
2341        // After code, `---` is operators (the parser rejects it).
2342        assert_eq!(kinds("a --- b"), vec![Ident, Minus, Minus, Minus, Ident]);
2343        let src = "  --- A doc.  \nx";
2344        let toks = tokenize(src).unwrap();
2345        assert_eq!(&src[doc_line_text_range(src, toks[0].span)], "A doc.  ");
2346    }
2347
2348    /// #2203: the one-line spelling of a doc's text never reads its own end as
2349    /// a closer.
2350    #[test]
2351    fn one_line_doc_spelling_round_trips() {
2352        for text in [
2353            "The repo.",
2354            "a ---",
2355            "---",
2356            "----",
2357            "a -- b",
2358            "  indented",
2359            "",
2360        ] {
2361            let src = format!("{}\n", one_line_doc(text));
2362            let toks = tokenize(&src).unwrap();
2363            assert_eq!(toks[0].kind, TokenKind::DocLine, "{src}");
2364            assert_eq!(doc_line_text(&src, toks[0].span), text, "{src}");
2365        }
2366    }
2367
2368    /// #1888's `--|` first on its line is a doc line still, spelled the legacy
2369    /// way; its fix is `--- text`.
2370    #[test]
2371    fn a_legacy_bar_doc_line_still_lexes_as_a_doc_line() {
2372        use TokenKind::{DocLine, Type};
2373        assert_eq!(kinds("--| doc\ntype"), vec![DocLine, Type]);
2374        assert_eq!(kinds("  --| doc\n\t--|\n"), vec![DocLine, DocLine]);
2375        let src = "  --|  indented  \r\n--|bare\n--|";
2376        let toks = tokenize(src).unwrap();
2377        assert!(doc_line_is_legacy(src, toks[0].span));
2378        assert_eq!(&src[toks[0].span.range()], "--|  indented  \r\n");
2379        assert_eq!(doc_line_text(src, toks[0].span), " indented  ");
2380        assert_eq!(doc_line_text(src, toks[1].span), "bare");
2381        assert_eq!(doc_line_text(src, toks[2].span), "");
2382        let fix = |i: usize| {
2383            let err = legacy_doc_line_error(src, toks[i].span);
2384            assert_eq!(err.category, "bynk.lex.legacy_doc_line");
2385            err.suggestions
2386                .first()
2387                .map(|s| (src[s.edits[0].0.range()].to_string(), s.edits[0].1.clone()))
2388        };
2389        assert_eq!(
2390            fix(1),
2391            Some(("--|bare".to_string(), "--- bare".to_string()))
2392        );
2393        assert_eq!(fix(2), None, "an empty `--|` has no one-line spelling");
2394    }
2395
2396    /// #2203: `///` first on its line is `bynk.lex.slash_doc`, fixed to `---`.
2397    #[test]
2398    fn a_slash_doc_is_an_error_with_a_fix() {
2399        for (src, fix) in [
2400            ("/// A doc.\n", "---"),
2401            ("///A doc.\n", "--- "),
2402            ("  ///", "---"),
2403        ] {
2404            let err = tokenize(src).unwrap_err();
2405            assert_eq!(err.category, "bynk.lex.slash_doc");
2406            let (span, text) = &err.suggestions[0].edits[0];
2407            assert_eq!(&src[span.range()], "///");
2408            assert_eq!(text, fix);
2409        }
2410    }
2411
2412    /// #2203: a legacy read takes ADR 0188 D1's rule, so a `--- Helpers ---`
2413    /// divider and a `--note` are comments, as they were before `//`.
2414    #[test]
2415    fn a_legacy_read_keeps_the_old_comment_rule() {
2416        let src = "--- Helpers ---\nx --note\na--b\n--| doc\n// new\n";
2417        let toks = tokenize_legacy(src).unwrap();
2418        let kinds: Vec<_> = toks.iter().map(|t| t.kind).collect();
2419        use TokenKind::*;
2420        assert_eq!(
2421            kinds,
2422            vec![
2423                Comment, Ident, Comment, Ident, Minus, Minus, Ident, DocLine, Comment
2424            ]
2425        );
2426        assert_eq!(comment_body(src, toks[0].span), " --- Helpers ---");
2427        assert_eq!(comment_body(src, toks[2].span), "note");
2428    }
2429
2430    #[test]
2431    fn unterminated_string_is_error() {
2432        let err = tokenize("\"oops\n").unwrap_err();
2433        assert_eq!(err.category, "bynk.lex.unterminated_string");
2434    }
2435
2436    #[test]
2437    fn integer_overflow_is_error() {
2438        let err = tokenize("99999999999999999999").unwrap_err();
2439        assert_eq!(err.category, "bynk.lex.integer_overflow");
2440    }
2441
2442    #[test]
2443    fn an_int_literal_must_be_a_safe_integer() {
2444        // #1657: the `Int` range is ±(2^53 − 1). The largest safe integer lexes;
2445        // one more, and i64-range values above it, do not.
2446        assert!(tokenize("9007199254740991").is_ok());
2447        assert!(tokenize("9_007_199_254_740_991").is_ok());
2448        for over in [
2449            "9007199254740992",
2450            "9007199254740993",
2451            "9223372036854775807",
2452        ] {
2453            let err = tokenize(over).unwrap_err();
2454            assert_eq!(err.category, "bynk.lex.integer_overflow", "{over}");
2455        }
2456    }
2457
2458    #[test]
2459    fn range_bounds_never_lex_as_a_float() {
2460        use TokenKind::*;
2461        // #2699: a float needs a digit on both sides of its `.`, so every range
2462        // spelling is `IntLit DotDot IntLit`, with the sign a separate `Minus`.
2463        assert_eq!(kinds("0..9"), vec![IntLit, DotDot, IntLit]);
2464        assert_eq!(kinds("18.."), vec![IntLit, DotDot]);
2465        assert_eq!(kinds("..-1"), vec![DotDot, Minus, IntLit]);
2466        assert_eq!(kinds("-5..-1"), vec![Minus, IntLit, DotDot, Minus, IntLit]);
2467        assert_eq!(kinds("1_000..2_000"), vec![IntLit, DotDot, IntLit]);
2468    }
2469
2470    #[test]
2471    fn a_decimal_literal_is_one_token_with_a_d_suffix() {
2472        use TokenKind::*;
2473        // #2622: digits, an optional fraction, a trailing `d`.
2474        assert_eq!(kinds("1.20d"), vec![DecimalLit]);
2475        assert_eq!(kinds("5d"), vec![DecimalLit]);
2476        assert_eq!(kinds("1_000.50d"), vec![DecimalLit]);
2477        // No `d` is still an `Int` or a `Float`, and the other numeric forms are
2478        // unchanged (a duration unit, a method on a literal, a range).
2479        assert_eq!(kinds("1.20"), vec![FloatLit]);
2480        assert_eq!(kinds("5.days"), vec![IntLit, Dot, Ident]);
2481        assert_eq!(
2482            kinds("2.5.round()"),
2483            vec![FloatLit, Dot, Ident, LParen, RParen]
2484        );
2485        assert_eq!(kinds("0..9"), vec![IntLit, DotDot, IntLit]);
2486        // The sign stays a separate operator; there is no exponent form, so an
2487        // exponent is a float followed by an identifier.
2488        assert_eq!(kinds("-1.5d"), vec![Minus, DecimalLit]);
2489        assert_eq!(kinds("1e5d"), vec![FloatLit, Ident]);
2490        // `Decimal` is a keyword.
2491        assert_eq!(kinds("Decimal"), vec![Decimal]);
2492    }
2493
2494    #[test]
2495    fn a_decimal_literal_obeys_the_decimal_limits() {
2496        let frac = |n: usize| format!("0.{}d", "1".repeat(n));
2497        assert!(tokenize(&frac(100)).is_ok());
2498        assert!(tokenize(&frac(101)).is_err());
2499        assert!(tokenize(&format!("{}d", "9".repeat(1000))).is_ok());
2500        assert!(tokenize(&format!("{}d", "9".repeat(1001))).is_err());
2501    }
2502
2503    #[test]
2504    fn digit_separators_lex_as_one_number() {
2505        use TokenKind::*;
2506        // v0.142 (ADR 0166): `_` between digit groups keeps the literal a single
2507        // token for both Int and Float.
2508        assert_eq!(kinds("1_048_576"), vec![IntLit]);
2509        assert_eq!(kinds("1_000.500_5"), vec![FloatLit]);
2510        assert_eq!(kinds("1_000e1_0"), vec![FloatLit]);
2511        // A separator-carrying literal that is in range still lexes (the value is
2512        // validated after stripping the separators).
2513        assert!(tokenize("9_007_199_254_740_991").is_ok());
2514        // Overflow is still caught on the separator-free value.
2515        let err = tokenize("9_999_999_999_999_999_999_9").unwrap_err();
2516        assert_eq!(err.category, "bynk.lex.integer_overflow");
2517    }
2518
2519    #[test]
2520    fn strip_digit_separators_removes_underscores() {
2521        assert_eq!(strip_digit_separators("1_048_576"), "1048576");
2522        assert_eq!(strip_digit_separators("42"), "42");
2523    }
2524
2525    #[test]
2526    fn unexpected_character_is_error() {
2527        let err = tokenize("type X = Int $").unwrap_err();
2528        assert_eq!(err.category, "bynk.lex.unexpected_character");
2529    }
2530
2531    #[test]
2532    fn v0_1_keywords() {
2533        use TokenKind::*;
2534        assert_eq!(
2535            kinds("let if else Ok Err Result ValidationError"),
2536            vec![Let, If, Else, Ok, Err, Result, ValidationError],
2537        );
2538    }
2539
2540    #[test]
2541    fn question_token() {
2542        use TokenKind::*;
2543        assert_eq!(kinds("x?"), vec![Ident, Question]);
2544    }
2545
2546    #[test]
2547    fn v0_2_keywords() {
2548        use TokenKind::*;
2549        assert_eq!(
2550            kinds("enum match Option record self Some None is"),
2551            vec![Enum, Match, Option, Record, Self_, Some, None, Is],
2552        );
2553    }
2554
2555    #[test]
2556    fn pipe_and_pipe_pipe_disambiguated() {
2557        use TokenKind::*;
2558        assert_eq!(kinds("| || |"), vec![Pipe, PipePipe, Pipe]);
2559    }
2560
2561    #[test]
2562    fn v0_7_keywords() {
2563        use TokenKind::*;
2564        assert_eq!(kinds("expect suite case"), vec![Expect, Suite, Case],);
2565        // v0.118: `mocks` and `wires` are retired — plain identifiers now.
2566        assert_eq!(kinds("mocks wires"), vec![Ident, Ident]);
2567    }
2568
2569    #[test]
2570    fn fat_arrow_and_underscore() {
2571        use TokenKind::*;
2572        assert_eq!(kinds("_ =>"), vec![Underscore, FatArrow]);
2573    }
2574
2575    // -- v0.43 string interpolation --
2576
2577    #[test]
2578    fn interp_string_is_one_token() {
2579        use TokenKind::*;
2580        assert_eq!(kinds(r#""Hello, \(name)!""#), vec![InterpStr]);
2581        // A plain string (no hole) stays a `StrLit`, via the logos path.
2582        assert_eq!(kinds(r#""Hello, world""#), vec![StrLit]);
2583    }
2584
2585    #[test]
2586    fn interp_balances_nested_parens_and_strings() {
2587        use TokenKind::*;
2588        // The `)` inside `f(x)` must not close the hole early.
2589        assert_eq!(kinds(r#""= \(f(x))""#), vec![InterpStr]);
2590        // A `)` inside a nested string inside the hole is also ignored.
2591        assert_eq!(kinds(r#""= \(label(")"))""#), vec![InterpStr]);
2592        // A nested interpolated string inside a hole.
2593        assert_eq!(kinds(r#""out \("in \(x)")""#), vec![InterpStr]);
2594    }
2595
2596    // Issue #473: hole-expanding tokenisation makes identifiers inside `\(…)`
2597    // visible to the LSP's token-based cursor resolution.
2598    #[test]
2599    fn expanding_holes_exposes_hole_identifiers() {
2600        use TokenKind::*;
2601        let expand = |src: &str| {
2602            tokenize_expanding_holes(src)
2603                .unwrap()
2604                .into_iter()
2605                .map(|t| t.kind)
2606                .collect::<Vec<_>>()
2607        };
2608        // The opaque `InterpStr` is replaced by its hole's tokens; the chunk
2609        // text (`Hello, ` / `!`) carries none.
2610        assert_eq!(expand(r#""Hello, \(name)!""#), vec![Ident]);
2611        // A call hole exposes every token of the call expression.
2612        assert_eq!(expand(r#""= \(f(x))""#), vec![Ident, LParen, Ident, RParen]);
2613        // Nested interpolation recurses to the innermost hole's identifier.
2614        assert_eq!(expand(r#""out \("in \(x)")""#), vec![Ident]);
2615        // A plain (hole-free) string is untouched.
2616        assert_eq!(expand(r#""Hello, world""#), vec![StrLit]);
2617    }
2618
2619    #[test]
2620    fn expanding_holes_rebases_spans_to_absolute() {
2621        let src = r#""Hello, \(name)!""#;
2622        let toks = tokenize_expanding_holes(src).unwrap();
2623        let ident = toks
2624            .iter()
2625            .find(|t| t.kind == TokenKind::Ident)
2626            .expect("the hole identifier is exposed");
2627        // The span points at `name` in the original source, not a hole-local 0.
2628        assert_eq!(&src[ident.span.range()], "name");
2629        assert_eq!(ident.span.start, src.find("name").unwrap());
2630    }
2631
2632    #[test]
2633    fn escaped_open_paren_is_not_a_hole() {
2634        use TokenKind::*;
2635        // `\\(` is a literal backslash followed by `(` — no hole, so the
2636        // string lexes as a plain `StrLit` on the logos path.
2637        assert_eq!(kinds(r#""a \\(b) c""#), vec![StrLit]);
2638    }
2639
2640    #[test]
2641    fn unterminated_hole_is_an_error() {
2642        // The hole runs to end of line without its closing `)`.
2643        let err = tokenize("\"value \\(x + 1\n\"").unwrap_err();
2644        assert_eq!(err.category, "bynk.lex.unterminated_interpolation");
2645    }
2646
2647    #[test]
2648    fn unterminated_interp_string_is_an_error() {
2649        // A hole closes but the string never does (newline before the `"`).
2650        let err = tokenize("\"value \\(x) more\n").unwrap_err();
2651        assert_eq!(err.category, "bynk.lex.unterminated_string");
2652    }
2653
2654    #[test]
2655    fn bad_escape_in_interp_string_is_an_error() {
2656        let err = tokenize(r#""a \q \(x)""#).unwrap_err();
2657        assert_eq!(err.category, "bynk.lex.bad_escape");
2658    }
2659
2660    /// The decoded value of the one string token in `src`.
2661    fn string_value(src: &str) -> String {
2662        let toks = tokenize(src).unwrap();
2663        assert_eq!(toks.len(), 1, "{src:?}");
2664        assert_eq!(toks[0].kind, TokenKind::StrLit, "{src:?}");
2665        decode_string_literal(&src[toks[0].span.range()], toks[0].span).unwrap()
2666    }
2667
2668    /// #2619: `\r`, `\0` and `\u{…}` join the escape set.
2669    #[test]
2670    fn new_escapes_decode() {
2671        assert_eq!(string_value(r#""a\r\nb""#), "a\r\nb");
2672        assert_eq!(string_value(r#""\0""#), "\0");
2673        assert_eq!(string_value(r#""\u{1F600}""#), "\u{1F600}");
2674        assert_eq!(string_value(r#""a\u{00A0}b""#), "a\u{A0}b");
2675        assert_eq!(string_value(r#""\u{41}\u{000041}""#), "AA");
2676        assert_eq!(string_value(r#""\u{10FFFF}""#), "\u{10FFFF}");
2677    }
2678
2679    #[test]
2680    fn bad_unicode_escapes_are_lexical_errors() {
2681        for src in [r#""\u{D800}""#, r#""\u{DFFF}""#, r#""\u{110000}""#] {
2682            assert_eq!(
2683                tokenize(src).unwrap_err().category,
2684                "bynk.lex.bad_unicode_escape",
2685                "{src}"
2686            );
2687        }
2688        for src in [
2689            r#""\u""#,
2690            r#""\u{}""#,
2691            r#""\u{1234567}""#,
2692            r#""\u{12""#,
2693            r#""\u{zz}""#,
2694        ] {
2695            assert_eq!(
2696                tokenize(src).unwrap_err().category,
2697                "bynk.lex.bad_escape",
2698                "{src}"
2699            );
2700        }
2701    }
2702
2703    #[test]
2704    fn raw_strings_keep_backslashes_and_do_not_interpolate() {
2705        assert_eq!(string_value(r#"r"\d+\.\d+""#), "\\d+\\.\\d+");
2706        assert_eq!(string_value(r#"r"a\(b)""#), "a\\(b)");
2707        assert_eq!(string_value(r###"r#"say "hi""#"###), "say \"hi\"");
2708        assert_eq!(string_value(r#"r"""#), "");
2709        // `r` followed by a space is an identifier then a string.
2710        use TokenKind::*;
2711        assert_eq!(kinds(r#"r "x""#), vec![Ident, StrLit]);
2712        assert_eq!(kinds(r#"bar"x""#), vec![Ident, StrLit]);
2713        // A raw string is not terminated by a newline.
2714        assert_eq!(
2715            tokenize("r\"abc\n\"").unwrap_err().category,
2716            "bynk.lex.unterminated_string"
2717        );
2718    }
2719
2720    #[test]
2721    fn multiline_strings_strip_the_closing_indentation() {
2722        let src = "\"\"\"\n    SELECT id\n      FROM t\n\n    \"\"\"";
2723        assert_eq!(string_value(src), "SELECT id\n  FROM t\n");
2724        // No indentation, and escapes still apply.
2725        assert_eq!(string_value("\"\"\"\na\\tb\n\"\"\""), "a\tb");
2726        // An empty body.
2727        assert_eq!(string_value("\"\"\"\n\"\"\""), "");
2728        // Quotes inside are fine; the raw form keeps backslashes.
2729        assert_eq!(
2730            string_value("\"\"\"\n  a \"q\" \"\"b\n  \"\"\""),
2731            "a \"q\" \"\"b"
2732        );
2733        assert_eq!(string_value("r\"\"\"\n  \\d+\n  \"\"\""), "\\d+");
2734    }
2735
2736    /// Decision D: newlines are `\n` whatever the source's line endings.
2737    #[test]
2738    fn multiline_newlines_are_normalised() {
2739        assert_eq!(string_value("\"\"\"\r\n  a\r\n  b\r\n  \"\"\""), "a\nb");
2740    }
2741
2742    #[test]
2743    fn multiline_with_a_hole_is_an_interpolated_string() {
2744        use TokenKind::*;
2745        assert_eq!(kinds("\"\"\"\n  hi \\(name)\n  \"\"\""), vec![InterpStr]);
2746    }
2747
2748    /// Pinned by review of #2619: edge cases of the multi-line rules.
2749    #[test]
2750    fn multiline_edge_cases() {
2751        // A hole at the start of an indented line: the margin is stripped
2752        // first, never inside the hole.
2753        let src = "\"\"\"\n    \\(a)\n      \\(b) x\n    \"\"\"";
2754        let toks = tokenize(src).unwrap();
2755        assert_eq!(toks[0].kind, TokenKind::InterpStr);
2756        let segs = split_interp(src, toks[0].span).unwrap();
2757        let shape: Vec<String> = segs
2758            .iter()
2759            .map(|s| match s {
2760                InterpSegment::Chunk(c) => format!("{c:?}"),
2761                InterpSegment::Hole(h) => src[h.range()].to_string(),
2762            })
2763            .collect();
2764        assert_eq!(shape, ["a", "\"\\n  \"", "b", "\" x\""]);
2765        // A whitespace-only line shallower than the margin is empty.
2766        assert_eq!(
2767            string_value("\"\"\"\n    a\n  \n    b\n    \"\"\""),
2768            "a\n\nb"
2769        );
2770        // Mixed tabs and spaces that do not match the margin exactly.
2771        let err = tokenize("\"\"\"\n\t  a\n    \"\"\"").unwrap_err();
2772        assert_eq!(err.category, "bynk.lex.multiline_indent");
2773        assert!(err.notes[0].contains("character for character"));
2774        // CRLF inside a raw multi-line string; a lone CR is not a line break.
2775        assert_eq!(
2776            string_value("r\"\"\"\r\n  a\\\r\n  b\r\n  \"\"\""),
2777            "a\\\nb"
2778        );
2779        assert_eq!(string_value("\"\"\"\n  a\rb\n  \"\"\""), "a\rb");
2780        // The raw form holds `"` and `""` but not `"""`.
2781        assert_eq!(string_value("r\"\"\"\n  \"a\"\" b\n  \"\"\""), "\"a\"\" b");
2782        // Empty `\u{}` and an eight-digit escape.
2783        assert!(tokenize(r#""\u{}""#).is_err());
2784        assert!(tokenize(r#""\u{00000041}""#).is_err());
2785    }
2786
2787    /// Review of #2619: a multi-line string cannot nest in a hole, and a
2788    /// `"""` followed by text is not a closing delimiter.
2789    #[test]
2790    fn multiline_in_hole_and_stray_delimiters() {
2791        let cat = |src: &str| tokenize(src).unwrap_err().category;
2792        assert_eq!(
2793            cat("\"a \\(f(\"\"\"\n  x\n  \"\"\")) b\""),
2794            "bynk.lex.multiline_in_hole"
2795        );
2796        assert_eq!(
2797            cat("\"\"\"\n  \\(g(\"\"\"\n  x\n  \"\"\"))\n  \"\"\""),
2798            "bynk.lex.multiline_in_hole"
2799        );
2800        // `"""quoted"""` on a body line is a stray delimiter, not a close.
2801        assert_eq!(
2802            cat("\"\"\"\n  \"\"\"quoted\"\"\"\n  \"\"\""),
2803            "bynk.lex.multiline_close_not_own_line"
2804        );
2805        // A closing delimiter may be followed by punctuation.
2806        let toks = tokenize("\"\"\"\n  a\n  \"\"\", x)").unwrap();
2807        assert_eq!(toks.len(), 4);
2808    }
2809
2810    #[test]
2811    fn multiline_errors() {
2812        let cat = |src: &str| tokenize(src).unwrap_err().category;
2813        // Under-indented line.
2814        assert_eq!(
2815            cat("\"\"\"\n    a\n  b\n    \"\"\""),
2816            "bynk.lex.multiline_indent"
2817        );
2818        // Text after the opening delimiter that is not a language tag.
2819        assert_eq!(cat("\"\"\" abc\n\"\"\""), "bynk.lex.multiline_open");
2820        assert_eq!(cat("\"\"\"ABC\n\"\"\""), "bynk.lex.multiline_open");
2821        // Never closed.
2822        assert_eq!(cat("\"\"\"\n  a\n"), "bynk.lex.unterminated_string");
2823        // A closing delimiter that shares a line with text.
2824        assert_eq!(
2825            cat("\"\"\"\n  a \"\"\" b\n  \"\"\""),
2826            "bynk.lex.multiline_close_not_own_line"
2827        );
2828    }
2829
2830    /// #2958: a language tag after the opening `"""` is metadata: the value
2831    /// is that of the untagged literal, in every form.
2832    #[test]
2833    fn multiline_tag_is_value_neutral() {
2834        let body = "\n    SELECT 1\n      FROM t\n    \"\"\"";
2835        let plain = string_value(&format!("\"\"\"{body}"));
2836        for opener in [
2837            "\"\"\"sql",
2838            "\"\"\"sql  ",
2839            "\"\"\"sql\t",
2840            "\"\"\"x",
2841            "\"\"\"my-lang_2",
2842        ] {
2843            assert_eq!(
2844                string_value(&format!("{opener}{body}")),
2845                plain,
2846                "{opener:?}"
2847            );
2848        }
2849        // CRLF after a tag, and the raw form.
2850        assert_eq!(
2851            string_value("\"\"\"sql\r\n  a\r\n  \"\"\""),
2852            string_value("\"\"\"\r\n  a\r\n  \"\"\"")
2853        );
2854        assert_eq!(string_value("r\"\"\"regex\n  ^\\d+$\n  \"\"\""), "^\\d+$");
2855        // An interpolating tagged literal is still an interpolated string.
2856        assert_eq!(
2857            kinds("\"\"\"sql\n  hi \\(name)\n  \"\"\""),
2858            vec![TokenKind::InterpStr]
2859        );
2860    }
2861
2862    /// #2958: what is not a tag stays `bynk.lex.multiline_open`.
2863    #[test]
2864    fn multiline_malformed_tags() {
2865        let cat = |src: &str| tokenize(src).unwrap_err().category;
2866        for opener in [
2867            "\"\"\" sql",  // a space before the tag
2868            "\"\"\"sql x", // text after the tag
2869            "\"\"\"SQL",   // uppercase
2870            "\"\"\"1sql",  // starts with a digit
2871            "\"\"\"-sql",  // starts with punctuation
2872            "\"\"\"sql.x", // a character outside the tag alphabet
2873            "\"\"\"   ",   // trailing spaces with no tag, as before
2874            "r\"\"\" sql",
2875        ] {
2876            assert_eq!(
2877                cat(&format!("{opener}\n  a\n  \"\"\"")),
2878                "bynk.lex.multiline_open",
2879                "{opener:?}"
2880            );
2881        }
2882        // The note names the tag.
2883        let err = tokenize("\"\"\"SQL\n  a\n  \"\"\"").unwrap_err();
2884        assert!(err.notes[0].contains("language tag"));
2885    }
2886
2887    /// #2619: the semantic-token parts of the three string forms.
2888    #[test]
2889    fn string_parts_cover_escapes_and_raw_delimiters() {
2890        let src = "\"a\\n\\u{41}\" r#\"x\\n\"# \"\"\"\n  \\t \\(\"\\r\")\n  \"\"\" \
2891                   \"\"\"sql\n  a\\n\n  \"\"\" r\"\"\"regex\n  \\d\n  \"\"\"";
2892        let texts: Vec<(&str, StringPart)> = string_parts(src)
2893            .into_iter()
2894            .map(|(s, p)| (&src[s.range()], p))
2895            .collect();
2896        use StringPart::*;
2897        assert_eq!(
2898            texts,
2899            vec![
2900                ("\\n", Escape),
2901                ("\\u{41}", Escape),
2902                ("r#\"", RawDelimiter),
2903                ("\"#", RawDelimiter),
2904                ("\\t", Escape),
2905                ("\\r", Escape),
2906                ("sql", Tag),
2907                ("\\n", Escape),
2908                ("regex", Tag),
2909                ("r\"\"\"", RawDelimiter),
2910                ("\"\"\"", RawDelimiter),
2911            ]
2912        );
2913    }
2914
2915    fn doc_block_span(source: &str) -> Span {
2916        tokenize(source)
2917            .unwrap()
2918            .into_iter()
2919            .find(|t| t.kind == TokenKind::DocBlock)
2920            .expect("a DocBlock token")
2921            .span
2922    }
2923
2924    #[test]
2925    fn doc_block_body_range_slices_to_the_same_bytes_doc_block_content_would_strip() {
2926        let src = "---\nHello there.\n---\nfn f() -> Int = 1\n";
2927        let span = doc_block_span(src);
2928        let range = doc_block_body_range(src, span).unwrap();
2929        assert_eq!(&src[range], "Hello there.");
2930    }
2931
2932    #[test]
2933    fn doc_block_body_range_is_offset_preserving_unlike_doc_block_content() {
2934        // A content line indented relative to its (unindented) markers:
2935        // doc_block_content strips the common indent (not offset-preserving),
2936        // doc_block_body_range does not — its slice still contains the raw
2937        // indentation, so span-based callers can map a position in the raw
2938        // body straight back to `src`.
2939        let src = "---\n  See [Foo].\n---\nfn f() -> Int = 1\n";
2940        let span = doc_block_span(src);
2941        let range = doc_block_body_range(src, span).unwrap();
2942        assert_eq!(&src[range.clone()], "  See [Foo].");
2943        assert_eq!(doc_block_content(src, span), "See [Foo].");
2944        // The raw range still locates `[Foo]` correctly within `src`.
2945        let bracket_rel = src[range.clone()].find('[').unwrap();
2946        assert_eq!(
2947            &src[range.start + bracket_rel..range.start + bracket_rel + 5],
2948            "[Foo]"
2949        );
2950    }
2951
2952    #[test]
2953    fn doc_block_content_and_body_range_agree_on_empty_body() {
2954        let src = "---\n---\nfn f() -> Int = 1\n";
2955        let span = doc_block_span(src);
2956        let range = doc_block_body_range(src, span).unwrap();
2957        assert_eq!(&src[range], "");
2958        assert_eq!(doc_block_content(src, span), "");
2959    }
2960
2961    /// #1885 (DECISION A): a `----` block closes only on `----`, so the `---`
2962    /// lines inside it are content, a nested doc example among them.
2963    #[test]
2964    fn a_longer_fence_holds_shorter_marker_lines() {
2965        let src = "----\nIntro.\n---\nInner doc.\n---\nTail.\n----\nfn f() -> Int = 1\n";
2966        let tokens = tokenize(src).unwrap();
2967        let docs: Vec<_> = tokens
2968            .iter()
2969            .filter(|t| t.kind == TokenKind::DocBlock)
2970            .collect();
2971        assert_eq!(docs.len(), 1, "one block, not three");
2972        assert_eq!(
2973            doc_block_content(src, docs[0].span),
2974            "Intro.\n---\nInner doc.\n---\nTail."
2975        );
2976    }
2977
2978    /// The trailing-dash trim in `doc_block_body_range` stops at the closer's
2979    /// line, so a body that ends in a shorter marker line keeps it.
2980    #[test]
2981    fn a_body_ending_in_a_shorter_marker_line_keeps_it() {
2982        let src = "----\nAbove.\n---\n----\nfn f() -> Int = 1\n";
2983        let span = doc_block_span(src);
2984        assert_eq!(doc_block_content(src, span), "Above.\n---");
2985    }
2986
2987    /// #1885: a marker of another length does not close the block, so a
2988    /// `----` opened and only `---` seen after is unclosed at EOF, and the note
2989    /// names the length that would close it.
2990    #[test]
2991    fn a_marker_of_another_length_does_not_close_the_block() {
2992        let err = tokenize("----\nText.\n---\nfn f() -> Int = 1\n").unwrap_err();
2993        assert_eq!(err.category, "bynk.lex.unclosed_doc_block");
2994        let note = err.notes.join(" ");
2995        assert!(note.contains("exactly 4 dashes"), "{note}");
2996        assert!(note.contains("not a divider"), "{note}");
2997        // The other way round: a `---` block does not close on `----`.
2998        let err = tokenize("---\nText.\n----\n").unwrap_err();
2999        assert_eq!(err.category, "bynk.lex.unclosed_doc_block");
3000    }
3001
3002    /// #2807: the recovering lexer reads past every lexical error, reports each
3003    /// in source order and keeps the tokens around them.
3004    #[test]
3005    fn recovering_lex_collects_every_error_and_keeps_the_rest() {
3006        let src = "fn a() $ 1\nlet n = 99999999999999999999\n\"oops\nfn b() ~ 2\n";
3007        let (tokens, errors) = tokenize_recovering(src);
3008        let cats: Vec<_> = errors.iter().map(|e| e.category).collect();
3009        assert_eq!(
3010            cats,
3011            [
3012                "bynk.lex.unexpected_character",
3013                "bynk.lex.integer_overflow",
3014                "bynk.lex.unterminated_string",
3015                "bynk.lex.unexpected_character",
3016            ]
3017        );
3018        assert!(errors.windows(2).all(|w| w[0].span.start < w[1].span.start));
3019        // The text between the errors still lexes.
3020        let fns = tokens.iter().filter(|t| t.kind == TokenKind::Fn).count();
3021        assert_eq!(fns, 2, "{tokens:?}");
3022        // The strict entry point still reports the first error alone.
3023        assert_eq!(tokenize(src).unwrap_err().span.start, errors[0].span.start);
3024    }
3025
3026    /// #2807: recovery always advances, whatever it meets, and never splits a
3027    /// multi-byte character.
3028    #[test]
3029    fn recovering_lex_terminates_on_hostile_input() {
3030        for src in [
3031            "é",
3032            "\"é",
3033            "\"\"\"é",
3034            "$é$",
3035            "///é",
3036            "----\né",
3037            "r\"é",
3038            "\"\\(é",
3039            "9é99999999999999999999é",
3040        ] {
3041            let (_, errors) = tokenize_recovering(src);
3042            assert!(!errors.is_empty(), "{src:?}");
3043        }
3044    }
3045
3046    /// #2807: recovery from a bad escape resumes after the literal's closer,
3047    /// even when a hole holds a string.
3048    #[test]
3049    fn string_recovery_skips_a_hole_holding_a_string() {
3050        let src = "fn f() { show(\"a\\q \\(g(\"x\")) b\", 1) }\nfn k() $";
3051        let (tokens, errors) = tokenize_recovering(src);
3052        let cats: Vec<_> = errors.iter().map(|e| e.category).collect();
3053        assert_eq!(
3054            cats,
3055            ["bynk.lex.bad_escape", "bynk.lex.unexpected_character"]
3056        );
3057        // `, 1) }` after the literal still lexes: nothing of the hole leaked.
3058        assert!(!tokens.iter().any(|t| t.kind == TokenKind::StrLit));
3059        let kinds: Vec<_> = tokens.iter().map(|t| t.kind).collect();
3060        assert_eq!(
3061            &kinds[kinds.len() - 8..kinds.len() - 4],
3062            [
3063                TokenKind::Comma,
3064                TokenKind::IntLit,
3065                TokenKind::RParen,
3066                TokenKind::RBrace
3067            ]
3068        );
3069    }
3070
3071    #[test]
3072    fn adjacent_stray_characters_are_one_error() {
3073        let (_, errors) = tokenize_recovering("fn $$$ f é$");
3074        assert_eq!(errors.len(), 2, "{errors:?}");
3075        assert_eq!(errors[0].span.end - errors[0].span.start, 3);
3076    }
3077
3078    #[test]
3079    fn an_unclosed_doc_block_swallows_the_rest_without_more_errors() {
3080        let (tokens, errors) =
3081            tokenize_recovering("fn a() = 1\n---\nit's prose; don't lex #this\nfn b() = 2\n");
3082        assert_eq!(errors.len(), 1, "{errors:?}");
3083        assert_eq!(errors[0].category, "bynk.lex.unclosed_doc_block");
3084        assert_eq!(tokens.iter().filter(|t| t.kind == TokenKind::Fn).count(), 1);
3085    }
3086
3087    /// #2807: the parse errors a skipped character causes are dropped (also past
3088    /// a comment, and at the end of the file); a real error elsewhere stays.
3089    #[test]
3090    fn lex_echoes_are_dropped() {
3091        let src = "commons d\n\nfn f(a: Int, b: Int) -> Int { a $ b }\n";
3092        let (tokens, lex_errors) = tokenize_recovering(src);
3093        let parse_errors = crate::parser::parse_units_recovering(&tokens, src).errors;
3094        assert!(!parse_errors.is_empty(), "`a b` is a syntax error");
3095        assert!(without_lex_echoes(&lex_errors, &tokens, parse_errors).is_empty());
3096    }
3097
3098    fn surviving(src: &str) -> usize {
3099        let (tokens, lex_errors) = tokenize_recovering(src);
3100        let parse_errors = crate::parser::parse_units_recovering(&tokens, src).errors;
3101        without_lex_echoes(&lex_errors, &tokens, parse_errors).len()
3102    }
3103
3104    #[test]
3105    fn lex_echo_window_skips_comments_and_runs_to_eof() {
3106        assert_eq!(
3107            surviving("commons d\n\nfn f(a: Int, b: Int) -> Int { a $ // why\n b }\n"),
3108            0
3109        );
3110        // An unclosed doc block inside a body: the missing `}` is its echo.
3111        assert_eq!(
3112            surviving("commons d\n\nfn f() -> Int {\n  1\n---\nprose\n"),
3113            0
3114        );
3115        // A genuine syntax error well away from the lex error stays.
3116        assert_eq!(
3117            surviving("commons d\n\nfn f() -> Int { 1 $ }\n\nfn g( -> Int { 1 }\n"),
3118            1
3119        );
3120    }
3121}