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