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("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("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#""([^"\\\n]|\\[nt"\\])*""#)]
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("!")]
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`",
Fn => "`fn`",
Where => "`where`",
True => "`true`",
False => "`false`",
Int => "`Int`",
String => "`String`",
Bool => "`Bool`",
Float => "`Float`",
Duration => "`Duration`",
Instant => "`Instant`",
Bytes => "`Bytes`",
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",
StrLit => "string literal",
InterpStr => "interpolated string",
Arrow => "`->`",
EqEq => "`==`",
BangEq => "`!=`",
LtEq => "`<=`",
GtEq => "`>=`",
AmpAmp => "`&&`",
PipePipe => "`||`",
Plus => "`+`",
Minus => "`-`",
Star => "`*`",
Slash => "`/`",
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 mut tokens = 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 => {
return Err(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 `// ---------`"
)));
}
}
}
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'/') {
return Err(slash_doc_error(bytes, pos, file));
}
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 bytes[pos] == b'"' && has_interp_hole(bytes, pos) {
let end = scan_str(bytes, source, pos, 0, file)?;
tokens.push(Token {
kind: TokenKind::InterpStr,
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());
return Err(CompileError::new(
"bynk.lex.unexpected_character",
span,
format!("unexpected character `{ch}`"),
));
};
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)
{
return Err(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",
));
}
}
if kind == TokenKind::FloatLit {
let slice = &source[span.range()];
match strip_digit_separators(slice).parse::<f64>() {
Ok(v) if v.is_finite() => {}
_ => {
return Err(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",
));
}
}
}
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}`"),
)
};
return Err(err);
}
}
}
Ok(tokens)
}
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); }
}
}
fn has_interp_hole(bytes: &[u8], start: usize) -> bool {
let mut i = start + 1;
while i < bytes.len() {
match bytes[i] {
b'\n' | b'"' => return false,
b'\\' => {
if bytes.get(i + 1) == Some(&b'(') {
return true;
}
i += 2;
}
_ => i += 1,
}
}
false
}
fn scan_str(
bytes: &[u8],
source: &str,
start: usize,
depth: usize,
file: FileId,
) -> Result<usize, CompileError> {
debug_assert_eq!(bytes[start], b'"');
if depth > crate::MAX_NESTING_DEPTH {
return Err(too_deeply_nested_interpolation(Span::new_in(
file,
start,
start + 1,
)));
}
let mut i = start + 1;
loop {
if i >= bytes.len() || bytes[i] == b'\n' {
return Err(CompileError::new(
"bynk.lex.unterminated_string",
Span::new_in(file, start, i.min(bytes.len())),
"unterminated string literal",
)
.with_note(
"string literals must close with `\"` on the same line; \
supported escapes are `\\n`, `\\t`, `\\\"`, `\\\\`, and `\\(…)` interpolation",
));
}
match bytes[i] {
b'"' => return Ok(i + 1),
b'\\' => match bytes.get(i + 1) {
Some(b'n' | b't' | b'"' | b'\\') => i += 2,
Some(b'(') => i = scan_hole(bytes, source, i + 2, depth + 1, file)?,
other => {
let shown = other.map(|b| (*b as char).to_string()).unwrap_or_default();
let mut end = (i + 2).min(bytes.len());
while end < source.len() && !source.is_char_boundary(end) {
end += 1;
}
return Err(CompileError::new(
"bynk.lex.bad_escape",
Span::new_in(file, i, end),
format!("invalid escape sequence `\\{shown}` in string literal"),
)
.with_note("supported escapes: \\n \\t \\\" \\\\ \\(…)"));
}
},
_ => i += 1,
}
}
}
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 = scan_str(bytes, source, i, nesting + 1, file)?,
_ => i += 1,
}
}
}
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> {
let bytes = source.as_bytes();
let inner_end = span.end - 1; let mut segments = Vec::new();
let mut chunk = String::new();
let mut i = span.start + 1; while i < inner_end {
match bytes[i] {
b'\\' => match bytes[i + 1] {
b'n' => {
chunk.push('\n');
i += 2;
}
b't' => {
chunk.push('\t');
i += 2;
}
b'"' => {
chunk.push('"');
i += 2;
}
b'\\' => {
chunk.push('\\');
i += 2;
}
b'(' => {
if !chunk.is_empty() {
segments.push(InterpSegment::Chunk(std::mem::take(&mut chunk)));
}
let hole_start = i + 2;
let after = scan_hole(bytes, source, hole_start, 0, span.file)?;
segments.push(InterpSegment::Hole(Span::new_in(
span.file,
hole_start,
after - 1,
)));
i = after;
}
other => unreachable!("unvalidated escape `\\{}` in InterpStr", other as char),
},
_ => {
let ch = source[i..].chars().next().unwrap();
chunk.push(ch);
i += ch.len_utf8();
}
}
}
if !chunk.is_empty() {
segments.push(InterpSegment::Chunk(chunk));
}
Ok(segments)
}
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, 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 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 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");
}
}