use logos::Logos;
use crate::error::{Applicability, CompileError};
use crate::span::{FileId, Span};
pub const MAX_SAFE_INT: i64 = 9_007_199_254_740_991;
pub(crate) fn strip_digit_separators(lexeme: &str) -> std::borrow::Cow<'_, str> {
if lexeme.as_bytes().contains(&b'_') {
std::borrow::Cow::Owned(lexeme.replace('_', ""))
} else {
std::borrow::Cow::Borrowed(lexeme)
}
}
#[derive(Logos, Debug, Clone, Copy, PartialEq, Eq)]
#[logos(skip r"[ \t\r\n]+")]
pub enum TokenKind {
#[token("commons")]
Commons,
#[token("type")]
Type,
#[token("event")]
Event,
#[token("asset")]
Asset,
#[token("embed")]
Embed,
#[token("const")]
Const,
#[token("fn")]
Fn,
#[token("where")]
Where,
#[token("true")]
True,
#[token("false")]
False,
#[token("Int")]
Int,
#[token("String")]
String,
#[token("Bool")]
Bool,
#[token("Float")]
Float,
#[token("Duration")]
Duration,
#[token("Instant")]
Instant,
#[token("Bytes")]
Bytes,
#[token("Decimal")]
Decimal,
#[token("let")]
Let,
#[token("if")]
If,
#[token("else")]
Else,
#[token("Ok")]
Ok,
#[token("Err")]
Err,
#[token("Result")]
Result,
#[token("ValidationError")]
ValidationError,
#[token("JsonError")]
JsonError,
#[token("enum")]
Enum,
#[token("match")]
Match,
#[token("Option")]
Option,
#[token("record")]
Record,
#[token("self")]
Self_,
#[token("Some")]
Some,
#[token("None")]
None,
#[token("is")]
Is,
#[token("opaque")]
Opaque,
#[token("uses")]
Uses,
#[token("context")]
Context,
#[token("consumes")]
Consumes,
#[token("exports")]
Exports,
#[token("transparent")]
Transparent,
#[token("as")]
As,
#[token("expect")]
Expect,
#[token("suite")]
Suite,
#[token("case")]
Case,
#[token("stub")]
Stub,
#[token("property")]
Property,
#[token("adapter")]
Adapter,
#[token("binding")]
Binding,
#[token("agent")]
Agent,
#[token("capability")]
Capability,
#[token("Effect")]
Effect,
#[token("do")]
Do,
#[token("given")]
Given,
#[token("on")]
On,
#[token("http")]
Http,
#[token("cron")]
Cron,
#[token("queue")]
Queue,
#[token("from")]
From,
#[token("protocol")]
Protocol,
#[token("provides")]
Provides,
#[token("service")]
Service,
#[token("actor")]
Actor,
#[token("by")]
By,
#[token("invariant")]
Invariant,
#[token("implies")]
Implies,
#[token("requires")]
Requires,
#[token("ensures")]
Ensures,
#[token("transition")]
Transition,
#[token("messages")]
Messages,
#[token("...")]
DotDotDot,
#[token("..")]
DotDot,
#[token("<-")]
LArrow,
#[token("~>")]
TildeArrow,
#[token(":=")]
ColonEq,
DocBlock,
DocLine,
Comment,
#[regex(r"[A-Za-z][A-Za-z0-9_]*")]
Ident,
#[regex(r"[0-9]+(_[0-9]+)*")]
IntLit,
#[regex(
r"[0-9]+(_[0-9]+)*\.[0-9]+(_[0-9]+)*([eE][+-]?[0-9]+(_[0-9]+)*)?|[0-9]+(_[0-9]+)*[eE][+-]?[0-9]+(_[0-9]+)*"
)]
FloatLit,
#[regex(r"[0-9]+(_[0-9]+)*(\.[0-9]+(_[0-9]+)*)?d")]
DecimalLit,
StrLit,
InterpStr,
#[token("->")]
Arrow,
#[token("==")]
EqEq,
#[token("!=")]
BangEq,
#[token("<=")]
LtEq,
#[token(">=")]
GtEq,
#[token("&&")]
AmpAmp,
#[token("||")]
PipePipe,
#[token("+")]
Plus,
#[token("-")]
Minus,
#[token("*")]
Star,
#[token("/")]
Slash,
#[token("%")]
Percent,
#[token("!")]
Bang,
#[token("=")]
Eq,
#[token("<")]
Lt,
#[token(">")]
Gt,
#[token("?")]
Question,
#[token("=>")]
FatArrow,
#[token("_")]
Underscore,
#[token("|")]
Pipe,
#[token("@")]
At,
#[token("(")]
LParen,
#[token(")")]
RParen,
#[token("{")]
LBrace,
#[token("}")]
RBrace,
#[token("[")]
LBracket,
#[token("]")]
RBracket,
#[token(",")]
Comma,
#[token(":")]
Colon,
#[token(".")]
Dot,
}
impl TokenKind {
pub fn describe(self) -> &'static str {
use TokenKind::*;
match self {
Commons => "`commons`",
Type => "`type`",
Event => "`event`",
Asset => "`asset`",
Embed => "`embed`",
Const => "`const`",
Fn => "`fn`",
Where => "`where`",
True => "`true`",
False => "`false`",
Int => "`Int`",
String => "`String`",
Bool => "`Bool`",
Float => "`Float`",
Duration => "`Duration`",
Instant => "`Instant`",
Bytes => "`Bytes`",
Decimal => "`Decimal`",
Let => "`let`",
If => "`if`",
Else => "`else`",
Ok => "`Ok`",
Err => "`Err`",
Result => "`Result`",
ValidationError => "`ValidationError`",
JsonError => "`JsonError`",
Enum => "`enum`",
Match => "`match`",
Option => "`Option`",
Record => "`record`",
Self_ => "`self`",
Some => "`Some`",
None => "`None`",
Is => "`is`",
Opaque => "`opaque`",
Uses => "`uses`",
Context => "`context`",
Consumes => "`consumes`",
Exports => "`exports`",
Transparent => "`transparent`",
As => "`as`",
Expect => "`expect`",
Suite => "`suite`",
Case => "`case`",
Property => "`property`",
Adapter => "`adapter`",
Binding => "`binding`",
Agent => "`agent`",
Capability => "`capability`",
Effect => "`Effect`",
Do => "`do`",
Given => "`given`",
On => "`on`",
Http => "`http`",
Cron => "`cron`",
Queue => "`queue`",
From => "`from`",
Protocol => "`protocol`",
Provides => "`provides`",
Stub => "`stub`",
Service => "`service`",
Actor => "`actor`",
By => "`by`",
Invariant => "`invariant`",
Implies => "`implies`",
Requires => "`requires`",
Ensures => "`ensures`",
Transition => "`transition`",
Messages => "`messages`",
ColonEq => "`:=`",
DotDotDot => "`...`",
DotDot => "`..`",
LArrow => "`<-`",
TildeArrow => "`~>`",
DocBlock => "documentation block",
DocLine => "one-line documentation (`--- text`)",
Comment => "line comment",
Ident => "identifier",
IntLit => "integer literal",
FloatLit => "float literal",
DecimalLit => "decimal literal",
StrLit => "string literal",
InterpStr => "interpolated string",
Arrow => "`->`",
EqEq => "`==`",
BangEq => "`!=`",
LtEq => "`<=`",
GtEq => "`>=`",
AmpAmp => "`&&`",
PipePipe => "`||`",
Plus => "`+`",
Minus => "`-`",
Star => "`*`",
Slash => "`/`",
Percent => "`%`",
Bang => "`!`",
Eq => "`=`",
Lt => "`<`",
Gt => "`>`",
Question => "`?`",
FatArrow => "`=>`",
Underscore => "`_`",
Pipe => "`|`",
At => "`@`",
LParen => "`(`",
RParen => "`)`",
LBrace => "`{`",
RBrace => "`}`",
LBracket => "`[`",
RBracket => "`]`",
Comma => "`,`",
Colon => "`:`",
Dot => "`.`",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Token {
pub kind: TokenKind,
pub span: Span,
}
pub fn tokenize(source: &str) -> Result<Vec<Token>, CompileError> {
tokenize_in(source, FileId::UNKNOWN)
}
pub fn tokenize_in(source: &str, file: FileId) -> Result<Vec<Token>, CompileError> {
tokenize_mode(source, file, false)
}
pub fn tokenize_legacy(source: &str) -> Result<Vec<Token>, CompileError> {
tokenize_mode(source, FileId::UNKNOWN, true)
}
fn tokenize_mode(source: &str, file: FileId, legacy: bool) -> Result<Vec<Token>, CompileError> {
let (tokens, errors) = tokenize_mode_recovering(source, file, legacy);
match errors.into_iter().next() {
Some(first) => Err(first),
None => Ok(tokens),
}
}
pub fn tokenize_recovering_in(source: &str, file: FileId) -> (Vec<Token>, Vec<CompileError>) {
tokenize_mode_recovering(source, file, false)
}
pub fn tokenize_recovering(source: &str) -> (Vec<Token>, Vec<CompileError>) {
tokenize_recovering_in(source, FileId::UNKNOWN)
}
fn skip_line(bytes: &[u8], from: usize) -> usize {
match bytes[from.min(bytes.len())..]
.iter()
.position(|&b| b == b'\n')
{
Some(i) => from + i + 1,
None => bytes.len(),
}
}
fn string_error_resume(bytes: &[u8], pos: usize, err: &CompileError) -> usize {
let fallback = skip_line(bytes, err.span.end.max(pos + 1));
if err.category == "bynk.lex.unterminated_string" {
return fallback;
}
let Some(open) = string_open(bytes, pos) else {
return fallback;
};
let mut p = open.body;
while p < bytes.len() {
match bytes[p] {
b'\\' if !open.raw && bytes.get(p + 1) == Some(&b'(') => {
match hole_end(bytes, p + 2) {
Some(end) => p = end,
None => return fallback,
}
}
b'\\' if !open.raw => p += 2,
b'\n' if !open.multi => return fallback,
b'"' if open.multi => {
if bytes[p..].starts_with(b"\"\"\"") {
return p + 3;
}
p += 1;
}
b'"' => {
let hashes = &bytes[p + 1..];
if hashes.len() >= open.hashes && hashes[..open.hashes].iter().all(|&b| b == b'#') {
return p + 1 + open.hashes;
}
p += 1;
}
_ => p += 1,
}
}
fallback
}
fn hole_end(bytes: &[u8], from: usize) -> Option<usize> {
let (mut p, mut depth) = (from, 1usize);
while p < bytes.len() {
match bytes[p] {
b'\n' => return None,
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
return Some(p + 1);
}
}
b'"' => {
p += 1;
while p < bytes.len() && bytes[p] != b'"' {
match bytes[p] {
b'\n' => return None,
b'\\' => p += 1,
_ => {}
}
p += 1;
}
}
_ => {}
}
p += 1;
}
None
}
fn push_lex_error(errors: &mut Vec<CompileError>, err: CompileError) {
const UNEXPECTED: &str = "bynk.lex.unexpected_character";
if err.category == UNEXPECTED
&& let Some(prev) = errors.last_mut()
&& prev.category == UNEXPECTED
&& prev.span.end == err.span.start
{
prev.span.end = err.span.end;
prev.message = "unexpected characters".to_string();
return;
}
errors.push(err);
}
fn tokenize_mode_recovering(
source: &str,
file: FileId,
legacy: bool,
) -> (Vec<Token>, Vec<CompileError>) {
let mut tokens = Vec::new();
let mut errors: Vec<CompileError> = Vec::new();
let bytes = source.as_bytes();
let mut pos = 0;
while pos < bytes.len() {
if let Some((open_end, dashes)) = doc_block_open_at(source, pos) {
match doc_block_close(source, open_end, dashes) {
Some((close_start, close_end)) => {
let span = Span::new_in(file, pos, close_end);
tokens.push(Token {
kind: TokenKind::DocBlock,
span,
});
let _ = close_start;
pos = close_end;
continue;
}
None => {
errors.push(CompileError::new(
"bynk.lex.unclosed_doc_block",
Span::new_in(file, pos, open_end),
"documentation block opened but never closed",
)
.with_note(format!(
"a doc block is closed by a line of exactly {dashes} dashes on its own; \
a line of three or more dashes is a doc-block marker, not a divider, so \
for a divider use a line comment, such as `// ---------`"
)));
pos = bytes.len();
continue;
}
}
}
let line_first = only_indent_before(bytes, pos);
if line_first && doc_line_marker(bytes, pos) {
if legacy {
pos = push_comment(&mut tokens, bytes, pos, file);
} else {
pos = push_doc_line(&mut tokens, bytes, pos, file);
}
continue;
}
if line_first && bytes[pos..].starts_with(b"///") && bytes.get(pos + 3) != Some(&b'/') {
errors.push(slash_doc_error(bytes, pos, file));
pos = skip_line(bytes, pos);
continue;
}
if line_first && bytes[pos..].starts_with(b"--|") {
pos = push_doc_line(&mut tokens, bytes, pos, file);
continue;
}
if bytes[pos..].starts_with(b"//") {
pos = push_comment(&mut tokens, bytes, pos, file);
continue;
}
let after_space = pos == 0 || matches!(bytes[pos - 1], b' ' | b'\t' | b'\r' | b'\n');
if after_space
&& bytes[pos..].starts_with(b"--")
&& (legacy
|| matches!(
bytes.get(pos + 2),
None | Some(b' ' | b'\t' | b'\r' | b'\n' | b'|')
))
{
pos = push_comment(&mut tokens, bytes, pos, file);
continue;
}
if matches!(bytes[pos], b' ' | b'\t' | b'\r' | b'\n') {
pos += 1;
continue;
}
if string_open(bytes, pos).is_some() {
let (end, segments) = match lex_string(source, pos, 0, file) {
Ok(lexed) => lexed,
Err(e) => {
pos = string_error_resume(bytes, pos, &e);
errors.push(e);
continue;
}
};
let kind = if segments.iter().any(|s| matches!(s, InterpSegment::Hole(_))) {
TokenKind::InterpStr
} else {
TokenKind::StrLit
};
tokens.push(Token {
kind,
span: Span::new_in(file, pos, end),
});
pos = end;
continue;
}
let mut lex = TokenKind::lexer(&source[pos..]);
let Some(result) = lex.next() else {
let ch = source[pos..].chars().next().unwrap_or('\0');
let span = Span::new_in(file, pos, pos + ch.len_utf8());
push_lex_error(
&mut errors,
CompileError::new(
"bynk.lex.unexpected_character",
span,
format!("unexpected character `{ch}`"),
),
);
pos += ch.len_utf8();
continue;
};
let local = lex.span();
let span: Span = Span::new_in(file, pos + local.start, pos + local.end);
match result {
Ok(kind) => {
if kind == TokenKind::IntLit {
let slice = &source[span.range()];
if !strip_digit_separators(slice)
.parse::<i64>()
.is_ok_and(|n| n <= MAX_SAFE_INT)
{
errors.push(
CompileError::new(
"bynk.lex.integer_overflow",
span,
format!("integer literal `{slice}` is out of range for an `Int`"),
)
.with_note(
"an `Int` is a safe integer, -(2^53 - 1) to 2^53 - 1 \
(±9007199254740991); use a `Float` or a `String` for a larger value",
),
);
pos = span.end;
continue;
}
}
if kind == TokenKind::DecimalLit {
let slice = &source[span.range()];
let body = strip_digit_separators(&slice[..slice.len() - 1]).into_owned();
let frac = body.split_once('.').map_or(0, |(_, f)| f.len());
if frac > 100 || body.len() > 1000 {
errors.push(
CompileError::new(
"bynk.lex.decimal_literal_too_large",
span,
"decimal literal is too large",
)
.with_note(
"a `Decimal` has at most 100 fraction digits and 1000 characters",
),
);
pos = span.end;
continue;
}
}
if kind == TokenKind::FloatLit {
let slice = &source[span.range()];
match strip_digit_separators(slice).parse::<f64>() {
Ok(v) if v.is_finite() => {}
_ => {
errors.push(
CompileError::new(
"bynk.lex.float_literal_overflow",
span,
format!(
"float literal `{slice}` is out of range for a 64-bit float"
),
)
.with_note(
"the literal does not fit a finite IEEE 754 double; \
the largest finite value is ~1.8e308",
),
);
pos = span.end;
continue;
}
}
}
tokens.push(Token { kind, span });
pos = span.end;
}
Err(()) => {
let slice = &source[span.range()];
let ch = slice.chars().next().unwrap_or('\0');
let err = if ch == '"' {
CompileError::new(
"bynk.lex.unterminated_string",
span,
"unterminated string literal",
)
.with_note(
"string literals must close with `\"` on the same line; \
supported escapes are `\\n`, `\\t`, `\\\"`, `\\\\`",
)
} else {
CompileError::new(
"bynk.lex.unexpected_character",
span,
format!("unexpected character `{ch}`"),
)
};
push_lex_error(&mut errors, err);
pos = if ch == '"' {
skip_line(bytes, span.end.max(pos + 1))
} else {
span.end.max(pos + ch.len_utf8())
};
}
}
}
(tokens, errors)
}
pub fn without_lex_echoes(
lex_errors: &[CompileError],
tokens: &[Token],
parse_errors: Vec<CompileError>,
) -> Vec<CompileError> {
let echo_ranges: Vec<(usize, usize)> = lex_errors
.iter()
.map(|le| {
let first = tokens.partition_point(|t| t.span.start < le.span.end);
let next_end = tokens[first..]
.iter()
.find(|t| !matches!(t.kind, TokenKind::Comment | TokenKind::DocLine))
.map_or(usize::MAX, |t| t.span.end);
(le.span.start, next_end)
})
.collect();
parse_errors
.into_iter()
.filter(|e| {
!echo_ranges
.iter()
.any(|&(lo, hi)| e.span.start >= lo && e.span.start <= hi)
})
.collect()
}
pub fn tokenize_expanding_holes(source: &str) -> Result<Vec<Token>, CompileError> {
tokenize_expanding_holes_in(source, FileId::UNKNOWN)
}
pub fn tokenize_expanding_holes_in(source: &str, file: FileId) -> Result<Vec<Token>, CompileError> {
let mut out = Vec::new();
for tok in tokenize_in(source, file)? {
expand_hole_token(source, file, tok, &mut out);
}
Ok(out)
}
fn expand_hole_token(source: &str, file: FileId, tok: Token, out: &mut Vec<Token>) {
if tok.kind != TokenKind::InterpStr {
out.push(tok);
return;
}
let Ok(segments) = split_interp(source, tok.span) else {
out.push(tok); return;
};
for segment in segments {
let InterpSegment::Hole(hole) = segment else {
continue; };
let Ok(hole_tokens) = tokenize_in(&source[hole.range()], file) else {
continue;
};
for mut t in hole_tokens {
t.span = Span::new_in(file, t.span.start + hole.start, t.span.end + hole.start);
expand_hole_token(source, file, t, out); }
}
}
#[derive(Clone, Copy)]
struct StringOpen {
raw: bool,
multi: bool,
hashes: usize,
body: usize,
}
fn is_ident_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn string_open(bytes: &[u8], start: usize) -> Option<StringOpen> {
let raw = bytes.get(start) == Some(&b'r');
let mut p = start + usize::from(raw);
let mut hashes = 0;
if raw {
while bytes.get(p) == Some(&b'#') {
hashes += 1;
p += 1;
}
}
if bytes.get(p) != Some(&b'"') {
return None;
}
let multi = hashes == 0 && bytes[p..].starts_with(b"\"\"\"");
Some(StringOpen {
raw,
multi,
hashes,
body: if multi { p + 3 } else { p + 1 },
})
}
fn unterminated_string(file: FileId, start: usize, end: usize, multi: bool) -> CompileError {
let note = if multi {
"a multi-line string closes with `\"\"\"` on a line of its own"
} else {
"string literals must close with `\"` on the same line (use `\"\"\"` for a multi-line string); \
supported escapes: \\n \\r \\t \\0 \\\" \\\\ \\u{…} and `\\(…)` interpolation"
};
CompileError::new(
"bynk.lex.unterminated_string",
Span::new_in(file, start, end),
"unterminated string literal",
)
.with_note(note)
}
fn boundary_span(source: &str, file: FileId, from: usize, to: usize) -> Span {
let mut end = to.min(source.len());
while end < source.len() && !source.is_char_boundary(end) {
end += 1;
}
Span::new_in(file, from, end)
}
const ESCAPE_NOTE: &str = "supported escapes: \\n \\r \\t \\0 \\\" \\\\ \\u{…} \\(…)";
fn lex_string(
source: &str,
start: usize,
depth: usize,
file: FileId,
) -> Result<(usize, Vec<InterpSegment>), CompileError> {
let bytes = source.as_bytes();
let open = string_open(bytes, start).expect("caller checked string_open");
if depth > crate::MAX_NESTING_DEPTH {
return Err(too_deeply_nested_interpolation(Span::new_in(
file,
start,
start + 1,
)));
}
let mut segments = Vec::new();
let mut chunk = String::new();
if open.multi {
let end = lex_multiline(source, start, open, depth, file, &mut segments, &mut chunk)?;
if !chunk.is_empty() {
segments.push(InterpSegment::Chunk(chunk));
}
return Ok((end, segments));
}
if open.raw {
let mut i = open.body;
loop {
if i >= bytes.len() || bytes[i] == b'\n' {
return Err(unterminated_string(file, start, i.min(bytes.len()), false));
}
if bytes[i] == b'"' && (1..=open.hashes).all(|k| bytes.get(i + k) == Some(&b'#')) {
break;
}
i += 1;
}
chunk.push_str(&source[open.body..i]);
if !chunk.is_empty() {
segments.push(InterpSegment::Chunk(chunk));
}
return Ok((i + 1 + open.hashes, segments));
}
let end = decode_run(
source,
open.body,
Stop::Quote(start),
depth,
file,
&mut segments,
&mut chunk,
)?;
if !chunk.is_empty() {
segments.push(InterpSegment::Chunk(chunk));
}
Ok((end + 1, segments))
}
#[derive(Clone, Copy)]
enum Stop {
Quote(usize),
End(usize),
}
fn decode_run(
source: &str,
from: usize,
stop: Stop,
depth: usize,
file: FileId,
segments: &mut Vec<InterpSegment>,
chunk: &mut String,
) -> Result<usize, CompileError> {
let bytes = source.as_bytes();
let limit = match stop {
Stop::Quote(_) => bytes.len(),
Stop::End(e) => e,
};
let mut i = from;
loop {
if i >= limit {
match stop {
Stop::End(_) => return Ok(i),
Stop::Quote(start) => {
return Err(unterminated_string(file, start, i.min(bytes.len()), false));
}
}
}
match bytes[i] {
b'\n' if matches!(stop, Stop::Quote(_)) => {
let Stop::Quote(start) = stop else {
unreachable!()
};
return Err(unterminated_string(file, start, i, false));
}
b'"' => match stop {
Stop::Quote(_) => return Ok(i),
Stop::End(e) => {
if bytes[i..e].starts_with(b"\"\"\"") {
return Err(CompileError::new(
"bynk.lex.multiline_close_not_own_line",
Span::new_in(file, i, i + 3),
"a `\"\"\"` inside a multi-line string closes it, but it is not on a line of its own",
)
.with_note(
"put the closing `\"\"\"` on its own line, or escape a quote as `\\\"`",
));
}
chunk.push('"');
i += 1;
}
},
b'\\' => {
let next = if i + 1 < limit {
Some(bytes[i + 1])
} else {
None
};
match next {
Some(b'n') => {
chunk.push('\n');
i += 2;
}
Some(b't') => {
chunk.push('\t');
i += 2;
}
Some(b'r') => {
chunk.push('\r');
i += 2;
}
Some(b'0') => {
chunk.push('\0');
i += 2;
}
Some(b'"') => {
chunk.push('"');
i += 2;
}
Some(b'\\') => {
chunk.push('\\');
i += 2;
}
Some(b'u') => {
let (ch, after) = decode_unicode_escape(source, i, limit, file)?;
chunk.push(ch);
i = after;
}
Some(b'(') => {
if !chunk.is_empty() {
segments.push(InterpSegment::Chunk(std::mem::take(chunk)));
}
let hole_start = i + 2;
let after = scan_hole(bytes, source, hole_start, depth + 1, file)?;
segments.push(InterpSegment::Hole(Span::new_in(
file,
hole_start,
after - 1,
)));
i = after;
}
other => {
let shown = other.map(|b| (b as char).to_string()).unwrap_or_default();
return Err(CompileError::new(
"bynk.lex.bad_escape",
boundary_span(source, file, i, i + 2),
format!("invalid escape sequence `\\{shown}` in string literal"),
)
.with_note(ESCAPE_NOTE));
}
}
}
_ => {
let ch = source[i..].chars().next().unwrap();
chunk.push(ch);
i += ch.len_utf8();
}
}
}
}
fn decode_unicode_escape(
source: &str,
i: usize,
limit: usize,
file: FileId,
) -> Result<(char, usize), CompileError> {
let bytes = source.as_bytes();
let malformed = |end: usize| {
CompileError::new(
"bynk.lex.bad_escape",
Span::new_in(file, i, end),
"invalid `\\u{…}` escape in string literal",
)
.with_note(
"a Unicode escape is `\\u{` followed by 1 to 6 hex digits and `}`, e.g. `\\u{1F600}`",
)
};
let mut p = i + 2; if p >= limit || bytes[p] != b'{' {
return Err(malformed(p));
}
p += 1;
let digits_start = p;
while p < limit && bytes[p].is_ascii_hexdigit() {
p += 1;
}
let digits = p - digits_start;
if digits == 0 || digits > 6 || p >= limit || bytes[p] != b'}' {
return Err(malformed(p.min(limit)));
}
let value = u32::from_str_radix(&source[digits_start..p], 16).expect("hex digits");
p += 1; match char::from_u32(value) {
Some(ch) => Ok((ch, p)),
None => Err(CompileError::new(
"bynk.lex.bad_unicode_escape",
Span::new_in(file, i, p),
format!("`\\u{{{value:X}}}` is not a Unicode scalar value"),
)
.with_note(
"surrogate code points (D800 to DFFF) and values above 10FFFF cannot appear in a string",
)),
}
}
fn multiline_opener(bytes: &[u8], body: usize) -> Option<(std::ops::Range<usize>, usize)> {
let mut p = body;
if bytes.get(p).is_some_and(u8::is_ascii_lowercase) {
p += 1;
while bytes.get(p).is_some_and(|&b| {
b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-'
}) {
p += 1;
}
}
let tag = body..p;
if !tag.is_empty() {
while matches!(bytes.get(p), Some(b' ' | b'\t')) {
p += 1;
}
}
match (bytes.get(p), bytes.get(p + 1)) {
(Some(b'\n'), _) => Some((tag, p + 1)),
(Some(b'\r'), Some(b'\n')) => Some((tag, p + 2)),
_ => None,
}
}
fn lex_multiline(
source: &str,
start: usize,
open: StringOpen,
depth: usize,
file: FileId,
segments: &mut Vec<InterpSegment>,
chunk: &mut String,
) -> Result<usize, CompileError> {
let bytes = source.as_bytes();
let Some((_, mut pos)) = multiline_opener(bytes, open.body) else {
return Err(CompileError::new(
"bynk.lex.multiline_open",
Span::new_in(file, start, open.body),
"the opening `\"\"\"` of a multi-line string must be followed by a newline",
)
.with_note(
"the text starts on the next line; write `\"\"\"` then press enter. \
An optional language tag may sit directly after the delimiter \
(`\"\"\"sql`): a lowercase letter, then lowercase letters, digits, `_` or `-`",
));
};
let mut lines: Vec<(usize, usize)> = Vec::new(); let (indent_len, end) = loop {
if pos >= bytes.len() {
return Err(unterminated_string(file, start, bytes.len(), true));
}
let line_end = bytes[pos..]
.iter()
.position(|&b| b == b'\n')
.map_or(bytes.len(), |n| pos + n);
let mut ws = pos;
while ws < line_end && matches!(bytes[ws], b' ' | b'\t') {
ws += 1;
}
if bytes[ws..line_end].starts_with(b"\"\"\"")
&& !bytes
.get(ws + 3)
.is_some_and(|&b| is_ident_byte(b) || b == b'"')
{
break (ws - pos, ws + 3);
}
let trimmed_end = if line_end > pos && bytes[line_end - 1] == b'\r' {
line_end - 1
} else {
line_end
};
lines.push((pos, trimmed_end));
if line_end >= bytes.len() {
return Err(unterminated_string(file, start, bytes.len(), true));
}
pos = line_end + 1;
};
let closing_line_start = end - 3 - indent_len;
let indent = &bytes[closing_line_start..closing_line_start + indent_len];
for (n, &(ls, le)) in lines.iter().enumerate() {
if n > 0 {
chunk.push('\n');
}
let line = &bytes[ls..le];
let blank = line.iter().all(|b| matches!(b, b' ' | b'\t'));
let content = if line.starts_with(indent) {
ls + indent_len
} else if blank {
le } else {
return Err(CompileError::new(
"bynk.lex.multiline_indent",
boundary_span(source, file, ls, le),
"a line of this multi-line string is indented less than its closing `\"\"\"`",
)
.with_note(if line.len() >= indent.len() {
"the indentation must match the closing delimiter's character for character: \
a tab does not match a space, so mixed tabs and spaces fail here"
} else {
"every non-blank line must start with the closing delimiter's indentation, \
which is stripped from the value"
}));
};
if open.raw {
let text = &source[content..le];
if let Some(at) = text.find("\"\"\"") {
return Err(CompileError::new(
"bynk.lex.multiline_close_not_own_line",
Span::new_in(file, content + at, content + at + 3),
"a `\"\"\"` inside a multi-line string closes it, but it is not on a line of its own",
)
.with_note("put the closing `\"\"\"` on its own line"));
}
chunk.push_str(text);
} else {
decode_run(source, content, Stop::End(le), depth, file, segments, chunk)?;
}
}
Ok(end)
}
fn scan_hole(
bytes: &[u8],
source: &str,
start: usize,
nesting: usize,
file: FileId,
) -> Result<usize, CompileError> {
if nesting > crate::MAX_NESTING_DEPTH {
return Err(too_deeply_nested_interpolation(Span::new_in(
file,
start.saturating_sub(2),
start,
)));
}
let mut i = start;
let mut depth = 1usize;
loop {
if i >= bytes.len() || bytes[i] == b'\n' {
return Err(CompileError::new(
"bynk.lex.unterminated_interpolation",
Span::new_in(file, start.saturating_sub(2), i.min(bytes.len())),
"unterminated interpolation hole",
)
.with_note(
"an interpolation hole `\\(…)` must close with a matching `)` on the same line",
));
}
match bytes[i] {
b'(' => {
depth += 1;
i += 1;
}
b')' => {
depth -= 1;
i += 1;
if depth == 0 {
return Ok(i);
}
}
b'"' => i = nested_string(source, i, nesting, file)?,
b'r' if (i == start || !is_ident_byte(bytes[i - 1]))
&& string_open(bytes, i).is_some() =>
{
i = nested_string(source, i, nesting, file)?
}
_ => i += 1,
}
}
}
fn nested_string(
source: &str,
at: usize,
nesting: usize,
file: FileId,
) -> Result<usize, CompileError> {
if string_open(source.as_bytes(), at).is_some_and(|o| o.multi) {
return Err(CompileError::new(
"bynk.lex.multiline_in_hole",
Span::new_in(file, at, at + 3),
"a multi-line string cannot appear inside an interpolation hole",
)
.with_note(
"an interpolation hole `\\(…)` closes on the line it opens on; \
bind the multi-line text to a name and interpolate that",
));
}
Ok(lex_string(source, at, nesting + 1, file)?.0)
}
fn too_deeply_nested_interpolation(span: Span) -> CompileError {
CompileError::new(
"bynk.lex.interpolation_too_deep",
span,
format!(
"string interpolation nests more than {} levels deep",
crate::MAX_NESTING_DEPTH
),
)
.with_note(
"deeply nested `\\(…)` interpolation is rejected to keep the lexer from \
overflowing its stack and aborting; flatten or split the string",
)
}
pub(crate) enum InterpSegment {
Chunk(String),
Hole(Span),
}
pub(crate) fn split_interp(source: &str, span: Span) -> Result<Vec<InterpSegment>, CompileError> {
lex_string(source, span.start, 0, span.file).map(|(_, segments)| segments)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StringPart {
Escape,
RawDelimiter,
Tag,
}
pub fn string_parts(source: &str) -> Vec<(Span, StringPart)> {
let mut out = Vec::new();
if let Ok(tokens) = tokenize(source) {
for tok in tokens {
string_parts_of(source, tok, &mut out);
}
}
out
}
fn string_parts_of(source: &str, tok: Token, out: &mut Vec<(Span, StringPart)>) {
if !matches!(tok.kind, TokenKind::StrLit | TokenKind::InterpStr) {
return;
}
let bytes = source.as_bytes();
let file = tok.span.file;
let Some(open) = string_open(bytes, tok.span.start) else {
return;
};
if open.multi
&& let Some((tag, _)) = multiline_opener(bytes, open.body)
&& !tag.is_empty()
{
out.push((Span::new_in(file, tag.start, tag.end), StringPart::Tag));
}
if open.raw {
let close = if open.multi { 3 } else { 1 + open.hashes };
out.push((
Span::new_in(file, tok.span.start, open.body),
StringPart::RawDelimiter,
));
out.push((
Span::new_in(file, tok.span.end - close, tok.span.end),
StringPart::RawDelimiter,
));
return;
}
let Ok(segments) = split_interp(source, tok.span) else {
return;
};
let holes: Vec<Span> = segments
.iter()
.filter_map(|s| match s {
InterpSegment::Hole(h) => Some(*h),
InterpSegment::Chunk(_) => None,
})
.collect();
let mut i = open.body;
let end = tok.span.end - if open.multi { 3 } else { 1 };
while i < end {
if bytes[i] != b'\\' {
i += 1;
continue;
}
if let Some(hole) = holes.iter().find(|h| h.start == i + 2) {
i = hole.end + 1; continue;
}
let len = if bytes.get(i + 1) == Some(&b'u') {
bytes[i..end]
.iter()
.position(|&b| b == b'}')
.map_or(2, |n| n + 1)
} else {
2
};
out.push((Span::new_in(file, i, i + len), StringPart::Escape));
i += len;
}
for hole in holes {
let Ok(hole_tokens) = tokenize_in(&source[hole.range()], file) else {
continue;
};
for mut t in hole_tokens {
t.span = Span::new_in(file, t.span.start + hole.start, t.span.end + hole.start);
string_parts_of(source, t, out);
}
}
}
pub fn decode_string_literal(lexeme: &str, span: Span) -> Result<String, CompileError> {
let (_, segments) =
lex_string(lexeme, 0, 0, span.file).map_err(|e| e.offset_spans(span.start))?;
let mut out = String::with_capacity(lexeme.len());
for segment in segments {
if let InterpSegment::Chunk(text) = segment {
out.push_str(&text);
}
}
Ok(out)
}
fn doc_block_open_at(source: &str, pos: usize) -> Option<(usize, usize)> {
let bytes = source.as_bytes();
if !at_line_start(source, pos) {
return None;
}
let mut i = pos;
while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
i += 1;
}
if i + 3 > bytes.len() {
return None;
}
if &bytes[i..i + 3] != b"---" {
return None;
}
let dash_start = i;
i += 3;
while i < bytes.len() && bytes[i] == b'-' {
i += 1;
}
let dashes = i - dash_start;
while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t' || bytes[i] == b'\r') {
i += 1;
}
if i == bytes.len() {
return Some((i, dashes));
}
if bytes[i] == b'\n' {
return Some((i + 1, dashes));
}
None
}
fn doc_block_close(source: &str, mut pos: usize, dashes: usize) -> Option<(usize, usize)> {
let bytes = source.as_bytes();
while pos < bytes.len() {
let line_start = pos;
let mut line_end = line_start;
while line_end < bytes.len() && bytes[line_end] != b'\n' {
line_end += 1;
}
if let Some((end, n)) = doc_block_open_at(source, line_start)
&& n == dashes
{
return Some((line_start, end));
}
pos = if line_end < bytes.len() {
line_end + 1
} else {
line_end
};
}
None
}
fn only_indent_before(bytes: &[u8], pos: usize) -> bool {
bytes[..pos]
.iter()
.rev()
.take_while(|&&b| b != b'\n')
.all(|&b| matches!(b, b' ' | b'\t' | b'\r'))
}
fn doc_line_marker(bytes: &[u8], pos: usize) -> bool {
let dashes = bytes[pos..].iter().take_while(|&&b| b == b'-').count();
if dashes < 3 || !matches!(bytes.get(pos + dashes), Some(b' ' | b'\t')) {
return false;
}
bytes[pos + dashes..]
.iter()
.take_while(|&&b| b != b'\n')
.any(|b| !b.is_ascii_whitespace())
}
fn push_doc_line(tokens: &mut Vec<Token>, bytes: &[u8], start: usize, file: FileId) -> usize {
let mut pos = start;
while pos < bytes.len() && bytes[pos] != b'\n' {
pos += 1;
}
if pos < bytes.len() {
pos += 1;
}
tokens.push(Token {
kind: TokenKind::DocLine,
span: Span::new_in(file, start, pos),
});
pos
}
fn push_comment(tokens: &mut Vec<Token>, bytes: &[u8], start: usize, file: FileId) -> usize {
let mut pos = start;
while pos < bytes.len() && bytes[pos] != b'\n' {
pos += 1;
}
tokens.push(Token {
kind: TokenKind::Comment,
span: Span::new_in(file, start, pos),
});
pos
}
fn slash_doc_error(bytes: &[u8], pos: usize, file: FileId) -> CompileError {
let marker = Span::new_in(file, pos, pos + 3);
let fix = match bytes.get(pos + 3) {
None | Some(b' ' | b'\t' | b'\r' | b'\n') => "---",
Some(_) => "--- ",
};
CompileError::new(
"bynk.lex.slash_doc",
marker,
"`///` is not a doc comment in Bynk; a doc is written with dashes",
)
.with_note(
"a one-line doc is `--- text`, and a longer doc is a block between two `---` lines; \
a comment is `//`",
)
.with_suggestion(
"write `---`",
vec![(marker, fix.to_string())],
Applicability::MachineApplicable,
)
}
pub fn doc_line_text(source: &str, span: Span) -> &str {
let slice = source[span.range()].trim_end_matches(['\n', '\r']);
if let Some(rest) = slice.strip_prefix("--|") {
return rest.strip_prefix(' ').unwrap_or(rest);
}
let dashes = slice.bytes().take_while(|&b| b == b'-').count();
let rest = &slice[dashes..];
let rest = rest.strip_prefix([' ', '\t']).unwrap_or(rest);
let body = rest.trim_end_matches([' ', '\t']);
let run = body.len() - body.trim_end_matches('-').len();
if run == dashes {
let before = &body[..body.len() - run];
if before.is_empty() || before.ends_with([' ', '\t']) {
return before.trim_end_matches([' ', '\t']);
}
}
rest
}
pub fn one_line_doc(text: &str) -> String {
let body = text.trim_end_matches([' ', '\t']);
if body.trim().is_empty() {
return "--- ---".to_string();
}
let run = body.len() - body.trim_end_matches('-').len();
let before = &body[..body.len() - run];
let closer_shaped = run == 3 && (before.is_empty() || before.ends_with([' ', '\t']));
let marker = if closer_shaped { "----" } else { "---" };
format!("{marker} {text}")
}
pub fn doc_fence_len(doc: &str) -> usize {
doc.lines()
.map(str::trim)
.filter(|l| l.len() >= 3 && l.bytes().all(|b| b == b'-'))
.map(|l| l.len() + 1)
.fold(3, usize::max)
}
pub fn doc_line_is_legacy(source: &str, span: Span) -> bool {
source[span.range()].starts_with("--|")
}
pub fn comment_is_legacy(source: &str, span: Span) -> bool {
source[span.range()].starts_with("--")
}
pub fn legacy_comment_error(span: Span) -> CompileError {
let marker = Span::new_in(span.file, span.start, span.start + 2);
CompileError::new(
"bynk.lex.legacy_comment",
marker,
"`--` no longer starts a comment; a line comment is `//`",
)
.with_note(
"`bynk fmt` rewrites every legacy comment; for subtraction of a negation, write `a - -b`",
)
.with_suggestion(
"write `//`",
vec![(marker, "//".to_string())],
Applicability::MachineApplicable,
)
}
pub fn legacy_doc_line_error(source: &str, span: Span) -> CompileError {
let marker = Span::new_in(span.file, span.start, span.start + 3);
let err = CompileError::new(
"bynk.lex.legacy_doc_line",
marker,
"`--|` no longer starts a doc; a one-line doc is `--- text`",
);
let text = doc_line_text(source, span);
if text.trim().is_empty() {
return err.with_note(
"a one-line doc holds text; a doc of more than one paragraph is a block between \
two `---` lines",
);
}
let line = Span::new_in(span.file, span.start, doc_line_text_range(source, span).end);
err.with_note("`bynk fmt` rewrites every legacy doc line")
.with_suggestion(
"write `--- `",
vec![(line, one_line_doc(text))],
Applicability::MachineApplicable,
)
}
pub fn doc_line_text_range(source: &str, span: Span) -> std::ops::Range<usize> {
let text = doc_line_text(source, span);
let start = text.as_ptr() as usize - source.as_ptr() as usize;
start..start + text.len()
}
fn at_line_start(source: &str, pos: usize) -> bool {
if pos == 0 {
return true;
}
let bytes = source.as_bytes();
bytes[pos - 1] == b'\n'
}
pub fn doc_block_body_range(source: &str, span: Span) -> Option<std::ops::Range<usize>> {
let slice = &source[span.range()];
let after_open_rel = slice.find('\n')? + 1;
let after_open = &slice[after_open_rel..];
let bytes = after_open.as_bytes();
let mut i = bytes.len();
if i > 0 && bytes[i - 1] == b'\n' {
i -= 1;
}
while i > 0 && matches!(bytes[i - 1], b' ' | b'\t' | b'\r') {
i -= 1;
}
while i > 0 && bytes[i - 1] == b'-' {
i -= 1;
}
if i > 0 && bytes[i - 1] == b'\n' {
i -= 1;
}
let start = span.range().start + after_open_rel;
Some(start..start + i)
}
pub fn doc_block_content(source: &str, span: Span) -> String {
let Some(range) = doc_block_body_range(source, span) else {
return String::new();
};
let body = &source[range];
let common: Option<usize> = body
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| l.bytes().take_while(|&b| b == b' ' || b == b'\t').count())
.min();
let strip = common.unwrap_or(0);
if strip == 0 {
return body.to_string();
}
let mut out = String::with_capacity(body.len());
let mut first = true;
for line in body.lines() {
if !first {
out.push('\n');
}
first = false;
if line.trim().is_empty() {
continue;
}
let leading: usize = line
.bytes()
.take_while(|&b| b == b' ' || b == b'\t')
.count();
let drop = strip.min(leading);
out.push_str(&line[drop..]);
}
out
}
pub fn comment_body(source: &str, span: Span) -> std::borrow::Cow<'_, str> {
let slice = &source[span.range()];
if let Some(rest) = slice.strip_prefix("//") {
return rest.into();
}
match slice.strip_prefix("--") {
Some(rest) if rest.starts_with('-') => format!(" {slice}").into(),
Some(rest) => rest.strip_prefix('|').unwrap_or(rest).into(),
None => slice.into(),
}
}
pub fn has_blank_line_between(source: &str, from: usize, to: usize) -> bool {
if to <= from {
return false;
}
let bytes = source.as_bytes();
let mut i = from;
while i < to {
if bytes[i] == b'\n' {
return true;
}
if !matches!(bytes[i], b' ' | b'\t' | b'\r') {
return false;
}
i += 1;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn kinds(source: &str) -> Vec<TokenKind> {
tokenize(source)
.unwrap()
.into_iter()
.map(|t| t.kind)
.collect()
}
#[test]
fn keywords_and_idents() {
use TokenKind::*;
assert_eq!(
kinds("commons type fn where true false Int String Bool foo bar"),
vec![
Commons, Type, Fn, Where, True, False, Int, String, Bool, Ident, Ident
],
);
assert_eq!(kinds("and"), vec![Ident]);
}
#[test]
fn deeply_nested_interpolation_is_bounded_not_overflowed() {
let depth = crate::MAX_NESTING_DEPTH + 8;
let src = format!("\"{}", "\\(\"".repeat(depth));
let err = tokenize(&src).unwrap_err();
assert_eq!(err.category, "bynk.lex.interpolation_too_deep");
}
#[test]
fn integer_and_string_literals() {
use TokenKind::*;
assert_eq!(
kinds(r#"0 42 "hello" "with\nescape""#),
vec![IntLit, IntLit, StrLit, StrLit]
);
}
#[test]
fn operators() {
use TokenKind::*;
assert_eq!(
kinds("-> == != <= >= && || + - * / % ! = < > ( ) { } [ ] , : . @"),
vec![
Arrow, EqEq, BangEq, LtEq, GtEq, AmpAmp, PipePipe, Plus, Minus, Star, Slash,
Percent, Bang, Eq, Lt, Gt, LParen, RParen, LBrace, RBrace, LBracket, RBracket,
Comma, Colon, Dot, At,
],
);
}
#[test]
fn dot_family_maximal_munch() {
use TokenKind::*;
assert_eq!(
kinds("a .. b ... c . d . ."),
vec![Ident, DotDot, Ident, DotDotDot, Ident, Dot, Ident, Dot, Dot,],
);
}
#[test]
fn line_comments_emitted_as_trivia() {
use TokenKind::*;
let src = "// a comment\ntype X = Int // trailing\n";
assert_eq!(kinds(src), vec![Comment, Type, Ident, Eq, Int, Comment],);
}
#[test]
fn comment_body_extracts_text_after_marker() {
let toks = tokenize("// hello world\n").unwrap();
assert_eq!(toks.len(), 1);
assert_eq!(toks[0].kind, TokenKind::Comment);
assert_eq!(
comment_body("// hello world\n", toks[0].span),
" hello world"
);
}
#[test]
fn comment_does_not_consume_newline() {
let toks = tokenize("// one\n// two\n").unwrap();
assert_eq!(toks.len(), 2);
assert!(toks.iter().all(|t| t.kind == TokenKind::Comment));
}
#[test]
fn slash_slash_is_a_comment_and_dash_dash_is_subtraction() {
use TokenKind::*;
assert_eq!(kinds("a//b"), vec![Ident, Comment]);
assert_eq!(kinds("a--b"), vec![Ident, Minus, Minus, Ident]);
assert_eq!(kinds("x--\ny"), vec![Ident, Minus, Minus, Ident]);
assert_eq!(kinds("x--"), vec![Ident, Minus, Minus]);
assert_eq!(kinds("a - -b"), vec![Ident, Minus, Minus, Ident]);
assert_eq!(kinds("a --b"), vec![Ident, Minus, Minus, Ident]);
assert_eq!(kinds("////////\n"), vec![Comment]);
assert_eq!(kinds("// -------\n"), vec![Comment]);
}
#[test]
fn a_legacy_dash_dash_comment_still_lexes_as_a_comment() {
use TokenKind::*;
assert_eq!(kinds("a -- c"), vec![Ident, Comment]);
assert_eq!(kinds("-- c"), vec![Comment]);
assert_eq!(kinds("a\n--\nb"), vec![Ident, Comment, Ident]);
assert_eq!(kinds("x --"), vec![Ident, Comment]);
assert_eq!(
kinds("a: Int, --| note"),
vec![Ident, Colon, Int, Comma, Comment]
);
let src = "x = 1 -- note\n";
let toks = tokenize(src).unwrap();
let c = toks.last().unwrap().span;
assert!(comment_is_legacy(src, c));
assert_eq!(comment_body(src, c), " note");
let err = legacy_comment_error(c);
assert_eq!(err.category, "bynk.lex.legacy_comment");
assert_eq!(err.suggestions[0].edits[0].1, "//");
assert_eq!(&src[err.suggestions[0].edits[0].0.range()], "--");
}
#[test]
fn a_dash_marker_with_text_is_a_doc_line() {
use TokenKind::*;
assert_eq!(kinds("--- doc\ntype"), vec![DocLine, Type]);
assert_eq!(kinds(" ---- doc\n\t--- x"), vec![DocLine, DocLine]);
let text = |src: &str| {
let toks = tokenize(src).unwrap();
assert_eq!(toks[0].kind, DocLine, "{src}");
doc_line_text(src, toks[0].span).to_string()
};
assert_eq!(text("--- The repo.\n"), "The repo.");
assert_eq!(text("--- The repo. ---\n"), "The repo.");
assert_eq!(text("---- The repo. ----\r\n"), "The repo.");
assert_eq!(text("--- a -- b ----\n"), "a -- b ----");
assert_eq!(text("---- a ---\n"), "a ---");
assert_eq!(text("--- a---\n"), "a---");
assert_eq!(text("--- indented\n"), " indented");
assert_eq!(kinds("---\nx\n---\n"), vec![DocBlock]);
assert_eq!(kinds("---x\n"), vec![Minus, Minus, Minus, Ident]);
assert_eq!(kinds("a --- b"), vec![Ident, Minus, Minus, Minus, Ident]);
let src = " --- A doc. \nx";
let toks = tokenize(src).unwrap();
assert_eq!(&src[doc_line_text_range(src, toks[0].span)], "A doc. ");
}
#[test]
fn one_line_doc_spelling_round_trips() {
for text in [
"The repo.",
"a ---",
"---",
"----",
"a -- b",
" indented",
"",
] {
let src = format!("{}\n", one_line_doc(text));
let toks = tokenize(&src).unwrap();
assert_eq!(toks[0].kind, TokenKind::DocLine, "{src}");
assert_eq!(doc_line_text(&src, toks[0].span), text, "{src}");
}
}
#[test]
fn a_legacy_bar_doc_line_still_lexes_as_a_doc_line() {
use TokenKind::{DocLine, Type};
assert_eq!(kinds("--| doc\ntype"), vec![DocLine, Type]);
assert_eq!(kinds(" --| doc\n\t--|\n"), vec![DocLine, DocLine]);
let src = " --| indented \r\n--|bare\n--|";
let toks = tokenize(src).unwrap();
assert!(doc_line_is_legacy(src, toks[0].span));
assert_eq!(&src[toks[0].span.range()], "--| indented \r\n");
assert_eq!(doc_line_text(src, toks[0].span), " indented ");
assert_eq!(doc_line_text(src, toks[1].span), "bare");
assert_eq!(doc_line_text(src, toks[2].span), "");
let fix = |i: usize| {
let err = legacy_doc_line_error(src, toks[i].span);
assert_eq!(err.category, "bynk.lex.legacy_doc_line");
err.suggestions
.first()
.map(|s| (src[s.edits[0].0.range()].to_string(), s.edits[0].1.clone()))
};
assert_eq!(
fix(1),
Some(("--|bare".to_string(), "--- bare".to_string()))
);
assert_eq!(fix(2), None, "an empty `--|` has no one-line spelling");
}
#[test]
fn a_slash_doc_is_an_error_with_a_fix() {
for (src, fix) in [
("/// A doc.\n", "---"),
("///A doc.\n", "--- "),
(" ///", "---"),
] {
let err = tokenize(src).unwrap_err();
assert_eq!(err.category, "bynk.lex.slash_doc");
let (span, text) = &err.suggestions[0].edits[0];
assert_eq!(&src[span.range()], "///");
assert_eq!(text, fix);
}
}
#[test]
fn a_legacy_read_keeps_the_old_comment_rule() {
let src = "--- Helpers ---\nx --note\na--b\n--| doc\n// new\n";
let toks = tokenize_legacy(src).unwrap();
let kinds: Vec<_> = toks.iter().map(|t| t.kind).collect();
use TokenKind::*;
assert_eq!(
kinds,
vec![
Comment, Ident, Comment, Ident, Minus, Minus, Ident, DocLine, Comment
]
);
assert_eq!(comment_body(src, toks[0].span), " --- Helpers ---");
assert_eq!(comment_body(src, toks[2].span), "note");
}
#[test]
fn unterminated_string_is_error() {
let err = tokenize("\"oops\n").unwrap_err();
assert_eq!(err.category, "bynk.lex.unterminated_string");
}
#[test]
fn integer_overflow_is_error() {
let err = tokenize("99999999999999999999").unwrap_err();
assert_eq!(err.category, "bynk.lex.integer_overflow");
}
#[test]
fn an_int_literal_must_be_a_safe_integer() {
assert!(tokenize("9007199254740991").is_ok());
assert!(tokenize("9_007_199_254_740_991").is_ok());
for over in [
"9007199254740992",
"9007199254740993",
"9223372036854775807",
] {
let err = tokenize(over).unwrap_err();
assert_eq!(err.category, "bynk.lex.integer_overflow", "{over}");
}
}
#[test]
fn range_bounds_never_lex_as_a_float() {
use TokenKind::*;
assert_eq!(kinds("0..9"), vec![IntLit, DotDot, IntLit]);
assert_eq!(kinds("18.."), vec![IntLit, DotDot]);
assert_eq!(kinds("..-1"), vec![DotDot, Minus, IntLit]);
assert_eq!(kinds("-5..-1"), vec![Minus, IntLit, DotDot, Minus, IntLit]);
assert_eq!(kinds("1_000..2_000"), vec![IntLit, DotDot, IntLit]);
}
#[test]
fn a_decimal_literal_is_one_token_with_a_d_suffix() {
use TokenKind::*;
assert_eq!(kinds("1.20d"), vec![DecimalLit]);
assert_eq!(kinds("5d"), vec![DecimalLit]);
assert_eq!(kinds("1_000.50d"), vec![DecimalLit]);
assert_eq!(kinds("1.20"), vec![FloatLit]);
assert_eq!(kinds("5.days"), vec![IntLit, Dot, Ident]);
assert_eq!(
kinds("2.5.round()"),
vec![FloatLit, Dot, Ident, LParen, RParen]
);
assert_eq!(kinds("0..9"), vec![IntLit, DotDot, IntLit]);
assert_eq!(kinds("-1.5d"), vec![Minus, DecimalLit]);
assert_eq!(kinds("1e5d"), vec![FloatLit, Ident]);
assert_eq!(kinds("Decimal"), vec![Decimal]);
}
#[test]
fn a_decimal_literal_obeys_the_decimal_limits() {
let frac = |n: usize| format!("0.{}d", "1".repeat(n));
assert!(tokenize(&frac(100)).is_ok());
assert!(tokenize(&frac(101)).is_err());
assert!(tokenize(&format!("{}d", "9".repeat(1000))).is_ok());
assert!(tokenize(&format!("{}d", "9".repeat(1001))).is_err());
}
#[test]
fn digit_separators_lex_as_one_number() {
use TokenKind::*;
assert_eq!(kinds("1_048_576"), vec![IntLit]);
assert_eq!(kinds("1_000.500_5"), vec![FloatLit]);
assert_eq!(kinds("1_000e1_0"), vec![FloatLit]);
assert!(tokenize("9_007_199_254_740_991").is_ok());
let err = tokenize("9_999_999_999_999_999_999_9").unwrap_err();
assert_eq!(err.category, "bynk.lex.integer_overflow");
}
#[test]
fn strip_digit_separators_removes_underscores() {
assert_eq!(strip_digit_separators("1_048_576"), "1048576");
assert_eq!(strip_digit_separators("42"), "42");
}
#[test]
fn unexpected_character_is_error() {
let err = tokenize("type X = Int $").unwrap_err();
assert_eq!(err.category, "bynk.lex.unexpected_character");
}
#[test]
fn v0_1_keywords() {
use TokenKind::*;
assert_eq!(
kinds("let if else Ok Err Result ValidationError"),
vec![Let, If, Else, Ok, Err, Result, ValidationError],
);
}
#[test]
fn question_token() {
use TokenKind::*;
assert_eq!(kinds("x?"), vec![Ident, Question]);
}
#[test]
fn v0_2_keywords() {
use TokenKind::*;
assert_eq!(
kinds("enum match Option record self Some None is"),
vec![Enum, Match, Option, Record, Self_, Some, None, Is],
);
}
#[test]
fn pipe_and_pipe_pipe_disambiguated() {
use TokenKind::*;
assert_eq!(kinds("| || |"), vec![Pipe, PipePipe, Pipe]);
}
#[test]
fn v0_7_keywords() {
use TokenKind::*;
assert_eq!(kinds("expect suite case"), vec![Expect, Suite, Case],);
assert_eq!(kinds("mocks wires"), vec![Ident, Ident]);
}
#[test]
fn fat_arrow_and_underscore() {
use TokenKind::*;
assert_eq!(kinds("_ =>"), vec![Underscore, FatArrow]);
}
#[test]
fn interp_string_is_one_token() {
use TokenKind::*;
assert_eq!(kinds(r#""Hello, \(name)!""#), vec![InterpStr]);
assert_eq!(kinds(r#""Hello, world""#), vec![StrLit]);
}
#[test]
fn interp_balances_nested_parens_and_strings() {
use TokenKind::*;
assert_eq!(kinds(r#""= \(f(x))""#), vec![InterpStr]);
assert_eq!(kinds(r#""= \(label(")"))""#), vec![InterpStr]);
assert_eq!(kinds(r#""out \("in \(x)")""#), vec![InterpStr]);
}
#[test]
fn expanding_holes_exposes_hole_identifiers() {
use TokenKind::*;
let expand = |src: &str| {
tokenize_expanding_holes(src)
.unwrap()
.into_iter()
.map(|t| t.kind)
.collect::<Vec<_>>()
};
assert_eq!(expand(r#""Hello, \(name)!""#), vec![Ident]);
assert_eq!(expand(r#""= \(f(x))""#), vec![Ident, LParen, Ident, RParen]);
assert_eq!(expand(r#""out \("in \(x)")""#), vec![Ident]);
assert_eq!(expand(r#""Hello, world""#), vec![StrLit]);
}
#[test]
fn expanding_holes_rebases_spans_to_absolute() {
let src = r#""Hello, \(name)!""#;
let toks = tokenize_expanding_holes(src).unwrap();
let ident = toks
.iter()
.find(|t| t.kind == TokenKind::Ident)
.expect("the hole identifier is exposed");
assert_eq!(&src[ident.span.range()], "name");
assert_eq!(ident.span.start, src.find("name").unwrap());
}
#[test]
fn escaped_open_paren_is_not_a_hole() {
use TokenKind::*;
assert_eq!(kinds(r#""a \\(b) c""#), vec![StrLit]);
}
#[test]
fn unterminated_hole_is_an_error() {
let err = tokenize("\"value \\(x + 1\n\"").unwrap_err();
assert_eq!(err.category, "bynk.lex.unterminated_interpolation");
}
#[test]
fn unterminated_interp_string_is_an_error() {
let err = tokenize("\"value \\(x) more\n").unwrap_err();
assert_eq!(err.category, "bynk.lex.unterminated_string");
}
#[test]
fn bad_escape_in_interp_string_is_an_error() {
let err = tokenize(r#""a \q \(x)""#).unwrap_err();
assert_eq!(err.category, "bynk.lex.bad_escape");
}
fn string_value(src: &str) -> String {
let toks = tokenize(src).unwrap();
assert_eq!(toks.len(), 1, "{src:?}");
assert_eq!(toks[0].kind, TokenKind::StrLit, "{src:?}");
decode_string_literal(&src[toks[0].span.range()], toks[0].span).unwrap()
}
#[test]
fn new_escapes_decode() {
assert_eq!(string_value(r#""a\r\nb""#), "a\r\nb");
assert_eq!(string_value(r#""\0""#), "\0");
assert_eq!(string_value(r#""\u{1F600}""#), "\u{1F600}");
assert_eq!(string_value(r#""a\u{00A0}b""#), "a\u{A0}b");
assert_eq!(string_value(r#""\u{41}\u{000041}""#), "AA");
assert_eq!(string_value(r#""\u{10FFFF}""#), "\u{10FFFF}");
}
#[test]
fn bad_unicode_escapes_are_lexical_errors() {
for src in [r#""\u{D800}""#, r#""\u{DFFF}""#, r#""\u{110000}""#] {
assert_eq!(
tokenize(src).unwrap_err().category,
"bynk.lex.bad_unicode_escape",
"{src}"
);
}
for src in [
r#""\u""#,
r#""\u{}""#,
r#""\u{1234567}""#,
r#""\u{12""#,
r#""\u{zz}""#,
] {
assert_eq!(
tokenize(src).unwrap_err().category,
"bynk.lex.bad_escape",
"{src}"
);
}
}
#[test]
fn raw_strings_keep_backslashes_and_do_not_interpolate() {
assert_eq!(string_value(r#"r"\d+\.\d+""#), "\\d+\\.\\d+");
assert_eq!(string_value(r#"r"a\(b)""#), "a\\(b)");
assert_eq!(string_value(r###"r#"say "hi""#"###), "say \"hi\"");
assert_eq!(string_value(r#"r"""#), "");
use TokenKind::*;
assert_eq!(kinds(r#"r "x""#), vec![Ident, StrLit]);
assert_eq!(kinds(r#"bar"x""#), vec![Ident, StrLit]);
assert_eq!(
tokenize("r\"abc\n\"").unwrap_err().category,
"bynk.lex.unterminated_string"
);
}
#[test]
fn multiline_strings_strip_the_closing_indentation() {
let src = "\"\"\"\n SELECT id\n FROM t\n\n \"\"\"";
assert_eq!(string_value(src), "SELECT id\n FROM t\n");
assert_eq!(string_value("\"\"\"\na\\tb\n\"\"\""), "a\tb");
assert_eq!(string_value("\"\"\"\n\"\"\""), "");
assert_eq!(
string_value("\"\"\"\n a \"q\" \"\"b\n \"\"\""),
"a \"q\" \"\"b"
);
assert_eq!(string_value("r\"\"\"\n \\d+\n \"\"\""), "\\d+");
}
#[test]
fn multiline_newlines_are_normalised() {
assert_eq!(string_value("\"\"\"\r\n a\r\n b\r\n \"\"\""), "a\nb");
}
#[test]
fn multiline_with_a_hole_is_an_interpolated_string() {
use TokenKind::*;
assert_eq!(kinds("\"\"\"\n hi \\(name)\n \"\"\""), vec![InterpStr]);
}
#[test]
fn multiline_edge_cases() {
let src = "\"\"\"\n \\(a)\n \\(b) x\n \"\"\"";
let toks = tokenize(src).unwrap();
assert_eq!(toks[0].kind, TokenKind::InterpStr);
let segs = split_interp(src, toks[0].span).unwrap();
let shape: Vec<String> = segs
.iter()
.map(|s| match s {
InterpSegment::Chunk(c) => format!("{c:?}"),
InterpSegment::Hole(h) => src[h.range()].to_string(),
})
.collect();
assert_eq!(shape, ["a", "\"\\n \"", "b", "\" x\""]);
assert_eq!(
string_value("\"\"\"\n a\n \n b\n \"\"\""),
"a\n\nb"
);
let err = tokenize("\"\"\"\n\t a\n \"\"\"").unwrap_err();
assert_eq!(err.category, "bynk.lex.multiline_indent");
assert!(err.notes[0].contains("character for character"));
assert_eq!(
string_value("r\"\"\"\r\n a\\\r\n b\r\n \"\"\""),
"a\\\nb"
);
assert_eq!(string_value("\"\"\"\n a\rb\n \"\"\""), "a\rb");
assert_eq!(string_value("r\"\"\"\n \"a\"\" b\n \"\"\""), "\"a\"\" b");
assert!(tokenize(r#""\u{}""#).is_err());
assert!(tokenize(r#""\u{00000041}""#).is_err());
}
#[test]
fn multiline_in_hole_and_stray_delimiters() {
let cat = |src: &str| tokenize(src).unwrap_err().category;
assert_eq!(
cat("\"a \\(f(\"\"\"\n x\n \"\"\")) b\""),
"bynk.lex.multiline_in_hole"
);
assert_eq!(
cat("\"\"\"\n \\(g(\"\"\"\n x\n \"\"\"))\n \"\"\""),
"bynk.lex.multiline_in_hole"
);
assert_eq!(
cat("\"\"\"\n \"\"\"quoted\"\"\"\n \"\"\""),
"bynk.lex.multiline_close_not_own_line"
);
let toks = tokenize("\"\"\"\n a\n \"\"\", x)").unwrap();
assert_eq!(toks.len(), 4);
}
#[test]
fn multiline_errors() {
let cat = |src: &str| tokenize(src).unwrap_err().category;
assert_eq!(
cat("\"\"\"\n a\n b\n \"\"\""),
"bynk.lex.multiline_indent"
);
assert_eq!(cat("\"\"\" abc\n\"\"\""), "bynk.lex.multiline_open");
assert_eq!(cat("\"\"\"ABC\n\"\"\""), "bynk.lex.multiline_open");
assert_eq!(cat("\"\"\"\n a\n"), "bynk.lex.unterminated_string");
assert_eq!(
cat("\"\"\"\n a \"\"\" b\n \"\"\""),
"bynk.lex.multiline_close_not_own_line"
);
}
#[test]
fn multiline_tag_is_value_neutral() {
let body = "\n SELECT 1\n FROM t\n \"\"\"";
let plain = string_value(&format!("\"\"\"{body}"));
for opener in [
"\"\"\"sql",
"\"\"\"sql ",
"\"\"\"sql\t",
"\"\"\"x",
"\"\"\"my-lang_2",
] {
assert_eq!(
string_value(&format!("{opener}{body}")),
plain,
"{opener:?}"
);
}
assert_eq!(
string_value("\"\"\"sql\r\n a\r\n \"\"\""),
string_value("\"\"\"\r\n a\r\n \"\"\"")
);
assert_eq!(string_value("r\"\"\"regex\n ^\\d+$\n \"\"\""), "^\\d+$");
assert_eq!(
kinds("\"\"\"sql\n hi \\(name)\n \"\"\""),
vec![TokenKind::InterpStr]
);
}
#[test]
fn multiline_malformed_tags() {
let cat = |src: &str| tokenize(src).unwrap_err().category;
for opener in [
"\"\"\" sql", "\"\"\"sql x", "\"\"\"SQL", "\"\"\"1sql", "\"\"\"-sql", "\"\"\"sql.x", "\"\"\" ", "r\"\"\" sql",
] {
assert_eq!(
cat(&format!("{opener}\n a\n \"\"\"")),
"bynk.lex.multiline_open",
"{opener:?}"
);
}
let err = tokenize("\"\"\"SQL\n a\n \"\"\"").unwrap_err();
assert!(err.notes[0].contains("language tag"));
}
#[test]
fn string_parts_cover_escapes_and_raw_delimiters() {
let src = "\"a\\n\\u{41}\" r#\"x\\n\"# \"\"\"\n \\t \\(\"\\r\")\n \"\"\" \
\"\"\"sql\n a\\n\n \"\"\" r\"\"\"regex\n \\d\n \"\"\"";
let texts: Vec<(&str, StringPart)> = string_parts(src)
.into_iter()
.map(|(s, p)| (&src[s.range()], p))
.collect();
use StringPart::*;
assert_eq!(
texts,
vec![
("\\n", Escape),
("\\u{41}", Escape),
("r#\"", RawDelimiter),
("\"#", RawDelimiter),
("\\t", Escape),
("\\r", Escape),
("sql", Tag),
("\\n", Escape),
("regex", Tag),
("r\"\"\"", RawDelimiter),
("\"\"\"", RawDelimiter),
]
);
}
fn doc_block_span(source: &str) -> Span {
tokenize(source)
.unwrap()
.into_iter()
.find(|t| t.kind == TokenKind::DocBlock)
.expect("a DocBlock token")
.span
}
#[test]
fn doc_block_body_range_slices_to_the_same_bytes_doc_block_content_would_strip() {
let src = "---\nHello there.\n---\nfn f() -> Int = 1\n";
let span = doc_block_span(src);
let range = doc_block_body_range(src, span).unwrap();
assert_eq!(&src[range], "Hello there.");
}
#[test]
fn doc_block_body_range_is_offset_preserving_unlike_doc_block_content() {
let src = "---\n See [Foo].\n---\nfn f() -> Int = 1\n";
let span = doc_block_span(src);
let range = doc_block_body_range(src, span).unwrap();
assert_eq!(&src[range.clone()], " See [Foo].");
assert_eq!(doc_block_content(src, span), "See [Foo].");
let bracket_rel = src[range.clone()].find('[').unwrap();
assert_eq!(
&src[range.start + bracket_rel..range.start + bracket_rel + 5],
"[Foo]"
);
}
#[test]
fn doc_block_content_and_body_range_agree_on_empty_body() {
let src = "---\n---\nfn f() -> Int = 1\n";
let span = doc_block_span(src);
let range = doc_block_body_range(src, span).unwrap();
assert_eq!(&src[range], "");
assert_eq!(doc_block_content(src, span), "");
}
#[test]
fn a_longer_fence_holds_shorter_marker_lines() {
let src = "----\nIntro.\n---\nInner doc.\n---\nTail.\n----\nfn f() -> Int = 1\n";
let tokens = tokenize(src).unwrap();
let docs: Vec<_> = tokens
.iter()
.filter(|t| t.kind == TokenKind::DocBlock)
.collect();
assert_eq!(docs.len(), 1, "one block, not three");
assert_eq!(
doc_block_content(src, docs[0].span),
"Intro.\n---\nInner doc.\n---\nTail."
);
}
#[test]
fn a_body_ending_in_a_shorter_marker_line_keeps_it() {
let src = "----\nAbove.\n---\n----\nfn f() -> Int = 1\n";
let span = doc_block_span(src);
assert_eq!(doc_block_content(src, span), "Above.\n---");
}
#[test]
fn a_marker_of_another_length_does_not_close_the_block() {
let err = tokenize("----\nText.\n---\nfn f() -> Int = 1\n").unwrap_err();
assert_eq!(err.category, "bynk.lex.unclosed_doc_block");
let note = err.notes.join(" ");
assert!(note.contains("exactly 4 dashes"), "{note}");
assert!(note.contains("not a divider"), "{note}");
let err = tokenize("---\nText.\n----\n").unwrap_err();
assert_eq!(err.category, "bynk.lex.unclosed_doc_block");
}
#[test]
fn recovering_lex_collects_every_error_and_keeps_the_rest() {
let src = "fn a() $ 1\nlet n = 99999999999999999999\n\"oops\nfn b() ~ 2\n";
let (tokens, errors) = tokenize_recovering(src);
let cats: Vec<_> = errors.iter().map(|e| e.category).collect();
assert_eq!(
cats,
[
"bynk.lex.unexpected_character",
"bynk.lex.integer_overflow",
"bynk.lex.unterminated_string",
"bynk.lex.unexpected_character",
]
);
assert!(errors.windows(2).all(|w| w[0].span.start < w[1].span.start));
let fns = tokens.iter().filter(|t| t.kind == TokenKind::Fn).count();
assert_eq!(fns, 2, "{tokens:?}");
assert_eq!(tokenize(src).unwrap_err().span.start, errors[0].span.start);
}
#[test]
fn recovering_lex_terminates_on_hostile_input() {
for src in [
"é",
"\"é",
"\"\"\"é",
"$é$",
"///é",
"----\né",
"r\"é",
"\"\\(é",
"9é99999999999999999999é",
] {
let (_, errors) = tokenize_recovering(src);
assert!(!errors.is_empty(), "{src:?}");
}
}
#[test]
fn string_recovery_skips_a_hole_holding_a_string() {
let src = "fn f() { show(\"a\\q \\(g(\"x\")) b\", 1) }\nfn k() $";
let (tokens, errors) = tokenize_recovering(src);
let cats: Vec<_> = errors.iter().map(|e| e.category).collect();
assert_eq!(
cats,
["bynk.lex.bad_escape", "bynk.lex.unexpected_character"]
);
assert!(!tokens.iter().any(|t| t.kind == TokenKind::StrLit));
let kinds: Vec<_> = tokens.iter().map(|t| t.kind).collect();
assert_eq!(
&kinds[kinds.len() - 8..kinds.len() - 4],
[
TokenKind::Comma,
TokenKind::IntLit,
TokenKind::RParen,
TokenKind::RBrace
]
);
}
#[test]
fn adjacent_stray_characters_are_one_error() {
let (_, errors) = tokenize_recovering("fn $$$ f é$");
assert_eq!(errors.len(), 2, "{errors:?}");
assert_eq!(errors[0].span.end - errors[0].span.start, 3);
}
#[test]
fn an_unclosed_doc_block_swallows_the_rest_without_more_errors() {
let (tokens, errors) =
tokenize_recovering("fn a() = 1\n---\nit's prose; don't lex #this\nfn b() = 2\n");
assert_eq!(errors.len(), 1, "{errors:?}");
assert_eq!(errors[0].category, "bynk.lex.unclosed_doc_block");
assert_eq!(tokens.iter().filter(|t| t.kind == TokenKind::Fn).count(), 1);
}
#[test]
fn lex_echoes_are_dropped() {
let src = "commons d\n\nfn f(a: Int, b: Int) -> Int { a $ b }\n";
let (tokens, lex_errors) = tokenize_recovering(src);
let parse_errors = crate::parser::parse_units_recovering(&tokens, src).errors;
assert!(!parse_errors.is_empty(), "`a b` is a syntax error");
assert!(without_lex_echoes(&lex_errors, &tokens, parse_errors).is_empty());
}
fn surviving(src: &str) -> usize {
let (tokens, lex_errors) = tokenize_recovering(src);
let parse_errors = crate::parser::parse_units_recovering(&tokens, src).errors;
without_lex_echoes(&lex_errors, &tokens, parse_errors).len()
}
#[test]
fn lex_echo_window_skips_comments_and_runs_to_eof() {
assert_eq!(
surviving("commons d\n\nfn f(a: Int, b: Int) -> Int { a $ // why\n b }\n"),
0
);
assert_eq!(
surviving("commons d\n\nfn f() -> Int {\n 1\n---\nprose\n"),
0
);
assert_eq!(
surviving("commons d\n\nfn f() -> Int { 1 $ }\n\nfn g( -> Int { 1 }\n"),
1
);
}
}