use std::num::{ParseFloatError, ParseIntError};
use logos::{Lexer, Logos};
use thiserror::Error;
use crate::span::Span;
#[derive(Debug, Clone, PartialEq, gen_platform::IsVariant)]
pub enum TokenKind {
Shebang(String),
LParen,
RParen,
LBrace,
RBrace,
LBracket,
RBracket,
Quote,
Quasiquote,
Unquote,
UnquoteSplice,
Str(String),
Int(i64),
Float(f64),
Bool(bool),
Nil,
Symbol(String),
Keyword(String),
LineComment(String),
Newlines(u32),
Whitespace,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Token {
pub kind: TokenKind,
pub span: Span,
}
#[derive(Debug, Default, Error, PartialEq, Eq, Clone)]
pub enum LexError {
#[default]
#[error("unrecognized token")]
Unrecognized,
#[error("unterminated string at offset {0}")]
UnterminatedString(u32),
#[error("invalid escape sequence \\{1} at offset {0}")]
BadEscape(u32, char),
#[error("invalid number literal at offset {0}: {1}")]
BadInt(u32, String),
#[error("invalid float literal at offset {0}: {1}")]
BadFloat(u32, String),
#[error("unexpected character {1:?} at offset {0}")]
UnexpectedChar(u32, char),
}
impl From<(u32, ParseIntError)> for LexError {
fn from(v: (u32, ParseIntError)) -> Self {
Self::BadInt(v.0, v.1.to_string())
}
}
impl From<(u32, ParseFloatError)> for LexError {
fn from(v: (u32, ParseFloatError)) -> Self {
Self::BadFloat(v.0, v.1.to_string())
}
}
#[derive(Logos, Debug, PartialEq)]
#[logos(error = LexError)]
enum LogosKind {
#[token("(")]
LParen,
#[token(")")]
RParen,
#[token("{")]
LBrace,
#[token("}")]
RBrace,
#[token("[")]
LBracket,
#[token("]")]
RBracket,
#[token("'")]
Quote,
#[token("`")]
Quasiquote,
#[token(",@")]
UnquoteSplice,
#[token(",")]
Unquote,
#[token("#t", |_| true)]
#[token("#f", |_| false)]
Bool(bool),
#[regex(r#""(?:[^"\\]|\\.)*""#, lex_string_body)]
Str(String),
#[regex(r"[+-]?[0-9]+", priority = 3, callback = parse_int)]
Int(i64),
#[regex(
r"[+-]?(?:[0-9]+\.[0-9]*|\.[0-9]+|[0-9]+[eE][+-]?[0-9]+|[0-9]+\.[0-9]*[eE][+-]?[0-9]+|\.[0-9]+[eE][+-]?[0-9]+)",
priority = 3,
callback = parse_float
)]
Float(f64),
#[regex(":[^\\s()'`,\";\\{\\}\\[\\]]+", |lex| lex.slice()[1..].to_string())]
Keyword(String),
#[regex(r";[^\n]*", |lex| {
let s = lex.slice();
// strip the leading ';'
s[1..].to_string()
})]
LineComment(String),
#[regex(r"[\n][ \t\r\n]*", count_newlines)]
Newlines(u32),
#[regex(r"[ \t\r]+")]
Whitespace,
#[regex(
"[^\\s()'`,\";#\\{\\}\\[\\]][^\\s()'`,\";#\\{\\}\\[\\]]*",
|lex| lex.slice().to_string()
)]
Symbol(String),
}
fn lex_string_body(lex: &mut Lexer<LogosKind>) -> Result<String, LexError> {
let raw = lex.slice();
debug_assert!(raw.starts_with('"') && raw.ends_with('"'));
let inner = &raw[1..raw.len() - 1];
let span_start = u32::try_from(lex.span().start).unwrap_or(u32::MAX);
let mut out = String::with_capacity(inner.len());
let mut chars = inner.char_indices();
while let Some((i, c)) = chars.next() {
if c == '\\' {
match chars.next() {
Some((_, 'n')) => out.push('\n'),
Some((_, 't')) => out.push('\t'),
Some((_, 'r')) => out.push('\r'),
Some((_, '"')) => out.push('"'),
Some((_, '\\')) => out.push('\\'),
Some((_, other)) => out.push(other),
None => {
return Err(LexError::BadEscape(
span_start + 1 + u32::try_from(i).unwrap_or(0),
'\\',
));
}
}
} else {
out.push(c);
}
}
Ok(out)
}
fn parse_int(lex: &mut Lexer<LogosKind>) -> Result<i64, LexError> {
let span_start = u32::try_from(lex.span().start).unwrap_or(u32::MAX);
lex.slice()
.parse::<i64>()
.map_err(|e| LexError::BadInt(span_start, e.to_string()))
}
fn parse_float(lex: &mut Lexer<LogosKind>) -> Result<f64, LexError> {
let span_start = u32::try_from(lex.span().start).unwrap_or(u32::MAX);
lex.slice()
.parse::<f64>()
.map_err(|e| LexError::BadFloat(span_start, e.to_string()))
}
fn count_newlines(lex: &mut Lexer<LogosKind>) -> u32 {
let s = lex.slice();
let n = s.bytes().filter(|&b| b == b'\n').count();
u32::try_from(n).unwrap_or(u32::MAX)
}
pub fn tokenize(src: &str) -> Result<Vec<Token>, LexError> {
let mut out = Vec::new();
let body_start = if src.starts_with("#!") {
let end = src.find('\n').unwrap_or(src.len());
out.push(Token {
kind: TokenKind::Shebang(src[..end].to_string()),
span: Span::new(0, u32::try_from(end).unwrap_or(u32::MAX)),
});
end
} else {
0
};
let mut lex = LogosKind::lexer(&src[body_start..]);
while let Some(result) = lex.next() {
let span = lex.span();
let span_start = u32::try_from(span.start + body_start).unwrap_or(u32::MAX);
let span_end = u32::try_from(span.end + body_start).unwrap_or(u32::MAX);
let span = Span::new(span_start, span_end);
match result {
Ok(kind) => {
let public = match kind {
LogosKind::LParen => TokenKind::LParen,
LogosKind::RParen => TokenKind::RParen,
LogosKind::LBrace => TokenKind::LBrace,
LogosKind::RBrace => TokenKind::RBrace,
LogosKind::LBracket => TokenKind::LBracket,
LogosKind::RBracket => TokenKind::RBracket,
LogosKind::Quote => TokenKind::Quote,
LogosKind::Quasiquote => TokenKind::Quasiquote,
LogosKind::Unquote => TokenKind::Unquote,
LogosKind::UnquoteSplice => TokenKind::UnquoteSplice,
LogosKind::Bool(b) => TokenKind::Bool(b),
LogosKind::Str(s) => TokenKind::Str(s),
LogosKind::Int(i) => TokenKind::Int(i),
LogosKind::Float(f) => TokenKind::Float(f),
LogosKind::Keyword(s) => TokenKind::Keyword(s),
LogosKind::LineComment(s) => TokenKind::LineComment(s),
LogosKind::Newlines(n) => TokenKind::Newlines(n),
LogosKind::Whitespace => TokenKind::Whitespace,
LogosKind::Symbol(s) => {
if s == "nil" {
TokenKind::Nil
} else {
TokenKind::Symbol(s)
}
}
};
out.push(Token { kind: public, span });
}
Err(_) => {
let slice = lex.slice();
if slice.starts_with('"') {
return Err(LexError::UnterminatedString(span_start));
}
let ch = slice.chars().next().unwrap_or(' ');
return Err(LexError::UnexpectedChar(span_start, ch));
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn kinds(src: &str) -> Vec<TokenKind> {
tokenize(src)
.unwrap()
.into_iter()
.map(|t| t.kind)
.filter(|k| !k.is_whitespace() && !k.is_newlines())
.collect()
}
#[allow(
clippy::approx_constant,
reason = "float-literal lex fixture, not a PI approximation"
)]
#[test]
fn basic_atoms() {
assert_eq!(kinds("42"), vec![TokenKind::Int(42)]);
assert_eq!(kinds("3.14"), vec![TokenKind::Float(3.14)]);
assert_eq!(kinds("-7"), vec![TokenKind::Int(-7)]);
assert_eq!(kinds("#t"), vec![TokenKind::Bool(true)]);
assert_eq!(kinds("#f"), vec![TokenKind::Bool(false)]);
assert_eq!(kinds("nil"), vec![TokenKind::Nil]);
assert_eq!(kinds("\"hi\\n\""), vec![TokenKind::Str("hi\n".into())]);
assert_eq!(
kinds(":key-word"),
vec![TokenKind::Keyword("key-word".into())]
);
assert_eq!(kinds("my-sym"), vec![TokenKind::Symbol("my-sym".into())]);
}
#[test]
fn lists_and_readers() {
assert_eq!(
kinds("(a b)"),
vec![
TokenKind::LParen,
TokenKind::Symbol("a".into()),
TokenKind::Symbol("b".into()),
TokenKind::RParen,
]
);
assert_eq!(
kinds("'x"),
vec![TokenKind::Quote, TokenKind::Symbol("x".into())]
);
assert_eq!(
kinds(",@xs"),
vec![TokenKind::UnquoteSplice, TokenKind::Symbol("xs".into())]
);
}
#[test]
fn line_comment() {
let toks = tokenize("; hello\nworld").unwrap();
assert!(matches!(toks[0].kind, TokenKind::LineComment(ref s) if s == " hello"));
assert!(matches!(toks[1].kind, TokenKind::Newlines(_)));
assert!(matches!(toks[2].kind, TokenKind::Symbol(ref s) if s == "world"));
}
#[test]
fn unterminated_string_errors() {
assert!(matches!(
tokenize(r#""oops"#),
Err(LexError::UnterminatedString(_))
));
}
#[test]
fn utf8_in_string_round_trip() {
let src = r#""π — émoji 🎉""#;
let toks = tokenize(src).unwrap();
match &toks[0].kind {
TokenKind::Str(s) => assert_eq!(s, "π — émoji 🎉"),
other => panic!("{other:?}"),
}
}
#[test]
fn newline_run_preserves_count() {
let toks = tokenize("a\n\n\nb").unwrap();
assert!(matches!(toks[0].kind, TokenKind::Symbol(ref s) if s == "a"));
match toks[1].kind {
TokenKind::Newlines(n) => assert_eq!(n, 3),
ref other => panic!("{other:?}"),
}
assert!(matches!(toks[2].kind, TokenKind::Symbol(ref s) if s == "b"));
}
#[test]
fn float_with_exponent() {
assert_eq!(kinds("1.5e10"), vec![TokenKind::Float(1.5e10)]);
assert_eq!(kinds("1e-3"), vec![TokenKind::Float(1e-3)]);
assert_eq!(kinds("-2.5E2"), vec![TokenKind::Float(-2.5e2)]);
}
#[test]
fn bool_keyword_clash_handled() {
assert_eq!(
kinds("#t#f"),
vec![TokenKind::Bool(true), TokenKind::Bool(false)]
);
}
}
#[cfg(test)]
mod is_variant_tests {
use super::*;
fn all_variants() -> Vec<(TokenKind, &'static str)> {
vec![
(TokenKind::Shebang("#!/env t".into()), "Shebang"),
(TokenKind::LParen, "LParen"),
(TokenKind::RParen, "RParen"),
(TokenKind::LBrace, "LBrace"),
(TokenKind::RBrace, "RBrace"),
(TokenKind::LBracket, "LBracket"),
(TokenKind::RBracket, "RBracket"),
(TokenKind::Quote, "Quote"),
(TokenKind::Quasiquote, "Quasiquote"),
(TokenKind::Unquote, "Unquote"),
(TokenKind::UnquoteSplice, "UnquoteSplice"),
(TokenKind::Str("s".into()), "Str"),
(TokenKind::Int(0), "Int"),
(TokenKind::Float(0.0), "Float"),
(TokenKind::Bool(false), "Bool"),
(TokenKind::Nil, "Nil"),
(TokenKind::Symbol("x".into()), "Symbol"),
(TokenKind::Keyword("k".into()), "Keyword"),
(TokenKind::LineComment(" c".into()), "LineComment"),
(TokenKind::Newlines(1), "Newlines"),
(TokenKind::Whitespace, "Whitespace"),
]
}
fn predicate_row(k: &TokenKind) -> [bool; 21] {
[
k.is_shebang(),
k.is_l_paren(),
k.is_r_paren(),
k.is_l_brace(),
k.is_r_brace(),
k.is_l_bracket(),
k.is_r_bracket(),
k.is_quote(),
k.is_quasiquote(),
k.is_unquote(),
k.is_unquote_splice(),
k.is_str(),
k.is_int(),
k.is_float(),
k.is_bool(),
k.is_nil(),
k.is_symbol(),
k.is_keyword(),
k.is_line_comment(),
k.is_newlines(),
k.is_whitespace(),
]
}
#[test]
fn token_kind_is_variant_predicates_partition_the_arm_set() {
let variants = all_variants();
for (idx, (variant, name)) in variants.iter().enumerate() {
let observed = predicate_row(variant);
let mut expected = [false; 21];
expected[idx] = true;
assert_eq!(
observed, expected,
"TokenKind::{name} at declaration-order slot {idx} must \
satisfy exactly one is_* predicate (its own); observed \
row must equal the one-hot expected row"
);
}
}
#[test]
fn token_kind_is_whitespace_and_is_newlines_byte_equal_pre_lift_matches_shape() {
for (variant, name) in all_variants() {
let via_matches_ws = matches!(variant, TokenKind::Whitespace);
let via_predicate_ws = variant.is_whitespace();
assert_eq!(
via_predicate_ws, via_matches_ws,
"TokenKind::{name}.is_whitespace() must byte-equal \
matches!(_, TokenKind::Whitespace) — otherwise the \
converged trivia-filter in `kinds` would silently \
disagree with its pre-lift shape"
);
let via_matches_nl = matches!(variant, TokenKind::Newlines(_));
let via_predicate_nl = variant.is_newlines();
assert_eq!(
via_predicate_nl, via_matches_nl,
"TokenKind::{name}.is_newlines() must byte-equal \
matches!(_, TokenKind::Newlines(_)) — otherwise the \
converged trivia-filter in `kinds` would silently \
disagree with its pre-lift shape"
);
}
}
}