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