use chumsky::span::SimpleSpan;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Token<'a> {
Word(&'a str),
Number(&'a str),
Punct(char),
Space,
}
impl std::fmt::Display for Token<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Token::Word(w) => write!(f, "{w}"),
Token::Number(n) => write!(f, "{n}"),
Token::Punct(c) => write!(f, "{c}"),
Token::Space => write!(f, "whitespace"),
}
}
}
#[must_use]
pub fn lex(input: &str) -> Vec<(Token<'_>, SimpleSpan)> {
let mut tokens = Vec::new();
let mut chars = input.char_indices().peekable();
while let Some(&(start, c)) = chars.peek() {
let kind = if c.is_whitespace() {
CharKind::Space
} else if c.is_alphabetic() {
CharKind::Word
} else if c.is_ascii_digit() {
CharKind::Number
} else {
CharKind::Punct
};
if kind == CharKind::Punct {
chars.next();
let end = start + c.len_utf8();
tokens.push((Token::Punct(c), SimpleSpan::from(start..end)));
continue;
}
let mut end = start;
while let Some(&(offset, next)) = chars.peek() {
let next_kind = if next.is_whitespace() {
CharKind::Space
} else if next.is_alphabetic() {
CharKind::Word
} else if next.is_ascii_digit() {
CharKind::Number
} else {
CharKind::Punct
};
if next_kind != kind {
break;
}
end = offset + next.len_utf8();
chars.next();
}
let slice = &input[start..end];
let token = match kind {
CharKind::Word => Token::Word(slice),
CharKind::Number => Token::Number(slice),
CharKind::Space => Token::Space,
CharKind::Punct => unreachable!("punctuation is handled above"),
};
tokens.push((token, SimpleSpan::from(start..end)));
}
tokens
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum CharKind {
Word,
Number,
Punct,
Space,
}
#[cfg(test)]
mod tests {
use super::*;
fn kinds(input: &str) -> Vec<Token<'_>> {
lex(input).into_iter().map(|(t, _)| t).collect()
}
#[test]
fn words_are_consumed_maximally() {
assert_eq!(kinds("days"), vec![Token::Word("days")]);
assert_eq!(kinds("min"), vec![Token::Word("min")]);
}
#[test]
fn numbers_keep_their_width() {
assert_eq!(kinds("01"), vec![Token::Number("01")]);
assert_eq!(kinds("2024"), vec![Token::Number("2024")]);
}
#[test]
fn whitespace_is_preserved_as_a_token() {
assert_eq!(
kinds("5 min"),
vec![Token::Number("5"), Token::Space, Token::Word("min")]
);
assert_eq!(kinds("5min"), vec![Token::Number("5"), Token::Word("min")]);
}
#[test]
fn non_ascii_words_stay_whole() {
assert_eq!(kinds("übermorgen"), vec![Token::Word("übermorgen")]);
assert_eq!(kinds("nächsten"), vec![Token::Word("nächsten")]);
}
#[test]
fn spans_are_byte_offsets_into_the_source() {
let input = "in fünf Tagen";
let tokens = lex(input);
let (last, span) = tokens.last().copied().expect("non-empty");
assert_eq!(last, Token::Word("Tagen"));
assert_eq!(&input[span.start..span.end], "Tagen");
}
#[test]
fn iso_datetimes_split_into_fields() {
assert_eq!(
kinds("2024-01-15T14:30"),
vec![
Token::Number("2024"),
Token::Punct('-'),
Token::Number("01"),
Token::Punct('-'),
Token::Number("15"),
Token::Word("T"),
Token::Number("14"),
Token::Punct(':'),
Token::Number("30"),
]
);
}
}