1use chumsky::span::SimpleSpan;
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
29pub enum Token<'a> {
30 Word(&'a str),
32 Number(&'a str),
34 Punct(char),
36 Space,
41}
42
43impl std::fmt::Display for Token<'_> {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 match self {
46 Token::Word(w) => write!(f, "{w}"),
47 Token::Number(n) => write!(f, "{n}"),
48 Token::Punct(c) => write!(f, "{c}"),
49 Token::Space => write!(f, "whitespace"),
50 }
51 }
52}
53
54#[must_use]
59pub fn lex(input: &str) -> Vec<(Token<'_>, SimpleSpan)> {
60 let mut tokens = Vec::new();
61 let mut chars = input.char_indices().peekable();
62
63 while let Some(&(start, c)) = chars.peek() {
64 let kind = if c.is_whitespace() {
65 CharKind::Space
66 } else if c.is_alphabetic() {
67 CharKind::Word
68 } else if c.is_ascii_digit() {
69 CharKind::Number
70 } else {
71 CharKind::Punct
72 };
73
74 if kind == CharKind::Punct {
75 chars.next();
76 let end = start + c.len_utf8();
77 tokens.push((Token::Punct(c), SimpleSpan::from(start..end)));
78 continue;
79 }
80
81 let mut end = start;
83 while let Some(&(offset, next)) = chars.peek() {
84 let next_kind = if next.is_whitespace() {
85 CharKind::Space
86 } else if next.is_alphabetic() {
87 CharKind::Word
88 } else if next.is_ascii_digit() {
89 CharKind::Number
90 } else {
91 CharKind::Punct
92 };
93 if next_kind != kind {
94 break;
95 }
96 end = offset + next.len_utf8();
97 chars.next();
98 }
99
100 let slice = &input[start..end];
101 let token = match kind {
102 CharKind::Word => Token::Word(slice),
103 CharKind::Number => Token::Number(slice),
104 CharKind::Space => Token::Space,
105 CharKind::Punct => unreachable!("punctuation is handled above"),
106 };
107 tokens.push((token, SimpleSpan::from(start..end)));
108 }
109
110 tokens
111}
112
113#[derive(Clone, Copy, PartialEq, Eq)]
114enum CharKind {
115 Word,
116 Number,
117 Punct,
118 Space,
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 fn kinds(input: &str) -> Vec<Token<'_>> {
126 lex(input).into_iter().map(|(t, _)| t).collect()
127 }
128
129 #[test]
130 fn words_are_consumed_maximally() {
131 assert_eq!(kinds("days"), vec![Token::Word("days")]);
133 assert_eq!(kinds("min"), vec![Token::Word("min")]);
134 }
135
136 #[test]
137 fn numbers_keep_their_width() {
138 assert_eq!(kinds("01"), vec![Token::Number("01")]);
139 assert_eq!(kinds("2024"), vec![Token::Number("2024")]);
140 }
141
142 #[test]
143 fn whitespace_is_preserved_as_a_token() {
144 assert_eq!(
145 kinds("5 min"),
146 vec![Token::Number("5"), Token::Space, Token::Word("min")]
147 );
148 assert_eq!(kinds("5min"), vec![Token::Number("5"), Token::Word("min")]);
149 }
150
151 #[test]
152 fn non_ascii_words_stay_whole() {
153 assert_eq!(kinds("übermorgen"), vec![Token::Word("übermorgen")]);
154 assert_eq!(kinds("nächsten"), vec![Token::Word("nächsten")]);
155 }
156
157 #[test]
158 fn spans_are_byte_offsets_into_the_source() {
159 let input = "in fünf Tagen";
160 let tokens = lex(input);
161 let (last, span) = tokens.last().copied().expect("non-empty");
162 assert_eq!(last, Token::Word("Tagen"));
163 assert_eq!(&input[span.start..span.end], "Tagen");
164 }
165
166 #[test]
167 fn iso_datetimes_split_into_fields() {
168 assert_eq!(
169 kinds("2024-01-15T14:30"),
170 vec![
171 Token::Number("2024"),
172 Token::Punct('-'),
173 Token::Number("01"),
174 Token::Punct('-'),
175 Token::Number("15"),
176 Token::Word("T"),
177 Token::Number("14"),
178 Token::Punct(':'),
179 Token::Number("30"),
180 ]
181 );
182 }
183}