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