Skip to main content

temps_core/
lexer.rs

1//! Tokenizer for natural-language time expressions.
2//!
3//! The parsers used to run directly over `&str`, fusing lexing and parsing into
4//! a single character-level pass — a "larser". That design makes keyword
5//! matching prefix-based: `"day"` matches inside `"days"`, `"m"` inside
6//! `"min"`, so every alternation has to be hand-ordered longest-first, a
7//! convention that fails silently when broken.
8//!
9//! Splitting the lexer out removes that class of bug structurally. A word is
10//! consumed maximally and then compared as a whole, so a keyword can never
11//! match part of a longer word regardless of the order alternatives appear in.
12//!
13//! Tokenising does not by itself fix the *phrase*-level version of the same
14//! hazard — `choice` still commits to the first alternative that succeeds, so a
15//! bare `tomorrow` could shadow `tomorrow morning`. That one is handled in the
16//! grammar rather than here, by left-factoring the shared prefix and making the
17//! remainder optional (`day_reference().then(part_of_day().or_not())`), and
18//! inside keyword tables by [`phrases_ci`](crate::common::phrases_ci), which
19//! sorts its entries so the table's source order stays irrelevant.
20
21use chumsky::span::SimpleSpan;
22
23/// A single lexical unit of a time expression.
24///
25/// [`Token::Word`] and [`Token::Number`] carry their source slice rather than a
26/// parsed value: callers need the original text to compare keywords
27/// case-insensitively and to tell `01` from `1` when validating field widths.
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
29pub enum Token<'a> {
30    /// A maximal run of alphabetic characters, e.g. `tomorrow`, `übermorgen`.
31    Word(&'a str),
32    /// A maximal run of ASCII digits, kept as text to preserve width.
33    Number(&'a str),
34    /// A single non-alphanumeric, non-whitespace character.
35    Punct(char),
36    /// A run of whitespace.
37    ///
38    /// Whitespace is significant here: `5 minutes` is a time expression while
39    /// `5minutes` is not, so the tokens have to record where the gaps were.
40    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/// Split `input` into tokens, each paired with its byte span in the source.
55///
56/// Spans are byte offsets into `input` so that diagnostics can point back at
57/// the original text.
58#[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        // Consume the whole run so a word is never matched piecemeal.
82        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        // The whole point: "day" cannot be seen inside "days".
132        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}