swan_core/lexer.rs
1//! The Swan lexer: turns source text into [`Token`]s.
2
3
4use std::iter::Peekable;
5use std::str::Chars;
6use crate::token::Token;
7
8
9/// A zero-copy lexer for Swan source text.
10///
11/// `Lexer` converts a source `&str` into a sequence of [`Token`]s. Each
12/// token that carries a string payload (`Identifier`, `Text`, `Comment`,
13/// `Undefined`) borrows a slice directly from the original source, so no heap
14/// allocations are needed during tokenisation.
15///
16/// # Usage
17///
18/// Call [`next_token`](Lexer::next_token) in a loop to pull tokens one at a
19/// time, or use the [`Iterator`] implementation to drive the lexer with
20/// standard iterator adapters. The iterator stops (returns `None`) at
21/// [`Token::EndOfExpression`]; `next_token` returns that sentinel instead.
22///
23/// ```
24/// use swan_core::lexer::Lexer;
25/// use swan_core::token::Token;
26///
27/// let tokens: Vec<_> = Lexer::new("x = 42").collect();
28/// assert_eq!(tokens[0], (0, Token::Identifier("x")));
29/// assert_eq!(tokens[1], (2, Token::AssignmentOp));
30/// assert_eq!(tokens[2], (4, Token::Number(42.0)));
31/// ```
32///
33/// # Position tracking
34///
35/// Every call to `next_token` returns a `(usize, Token)` pair where the
36/// `usize` is the **byte offset** of the token's first character inside the
37/// source string. Positions are byte-based (not char-based) and are safe to
38/// use as indices into the original `&str`.
39
40pub struct Lexer<'a> {
41 /// The full source string — never modified; sliced into by token variants.
42 source: &'a str,
43 /// Character iterator with one-character lookahead.
44 chars: Peekable<Chars<'a>>,
45 /// Byte offset of the *next* character to be consumed by `next_char`.
46 /// Starts at 0. Incremented by `ch.len_utf8()` with every consumed char,
47 /// keeping it valid as a UTF-8 byte index into `source`.
48 position: usize,
49}
50
51impl<'a> Lexer<'a> {
52
53 /// Creates a new `Lexer` positioned at the start of `source`.
54 pub fn new(source: &'a str) -> Self {
55 Self {
56 source,
57 chars: source.chars().peekable(),
58 position: 0,
59 }
60 }
61
62 // -------------------------------------------------------------------------
63 // Low-level character helpers
64 // -------------------------------------------------------------------------
65
66 /// Consumes and returns the next character, advancing `position` by the
67 /// character's UTF-8 byte length. Returns `None` at end-of-input.
68 fn next_char(&mut self) -> Option<char> {
69 let ch = self.chars.next()?;
70 self.position += ch.len_utf8();
71 Some(ch)
72 }
73
74 /// Returns the next character without consuming it.
75 fn peek_char(&mut self) -> Option<char> {
76 self.chars.peek().copied()
77 }
78
79 /// Returns the second upcoming character without consuming any characters.
80 fn peek_second_char(&self) -> Option<char> {
81 let mut it = self.chars.clone();
82 it.next(); // Skip the first peeked character
83 it.next() // Return the second character
84 }
85
86 /// Returns `true` if the next (unconsumed) character equals `ch`.
87 fn next_char_is(&mut self, ch: char) -> bool {
88 self.peek_char() == Some(ch)
89 }
90
91 /// Skips all leading whitespace characters (space, tab, newline, …).
92 fn skip_whitespaces(&mut self) {
93 while let Some(ch) = self.peek_char() {
94 if ch.is_whitespace() {
95 self.next_char();
96 } else {
97 break;
98 }
99 }
100 }
101
102 // -------------------------------------------------------------------------
103 // Token-specific scanners
104 // -------------------------------------------------------------------------
105
106 /// Scans the rest of an identifier, given that its first character has
107 /// already been consumed and `start_pos` is its byte offset.
108 ///
109 /// Identifier continuation characters are letters, digits, and `_`.
110 /// The first character (already consumed by the caller) is either a letter
111 /// or `_`, so this method only extends the span rightward.
112 ///
113 /// Returns a [`Token::Identifier`] borrowing the scanned slice from
114 /// `source`.
115 fn next_identifier(&mut self, start_pos: usize) -> Token<'a> {
116 while let Some(ch) = self.peek_char() {
117 if ch == '_' || ch.is_alphanumeric() {
118 self.next_char();
119 } else {
120 break;
121 }
122 }
123 let literal = &self.source[start_pos..self.position];
124 Token::Identifier(literal)
125 }
126
127 /// Consumes a run of ASCII decimal digits without returning anything.
128 /// Used as a building block inside [`next_numeric_literal`](Self::next_numeric_literal).
129 fn consume_digits(&mut self) {
130 while let Some(ch) = self.peek_char() {
131 if ch.is_ascii_digit() {
132 self.next_char();
133 } else {
134 break;
135 }
136 }
137 }
138
139 /// Scans a numeric literal, given that its first digit has already been
140 /// consumed and `start_pos` is its byte offset.
141 ///
142 /// Accepts the following forms (matching the JS reference grammar):
143 ///
144 /// ```text
145 /// integer: 123
146 /// decimal: 12.3
147 /// scientific: 12.3e2 12.3E+2 12.3e-2
148 /// ```
149 ///
150 /// Returns [`Token::Number`] on success, or [`Token::Undefined`] when the
151 /// literal is malformed: an exponent marker without digits (`1e+`), or a
152 /// number immediately followed by identifier characters (`123abc`).
153 ///
154 /// A dot that is not followed by a digit is not part of the number, so
155 /// `12.` lexes as `Number(12)` followed by `SubcontextingOp`.
156 fn next_numeric_literal(&mut self, start_pos: usize) -> Token<'a> {
157
158 // Consume the integer part (the first digit was already consumed by the
159 // caller, but consume_digits will handle any remaining leading digits).
160 self.consume_digits();
161
162 // Optional fractional part: `.` followed by one or more digits.
163 if self.peek_char() == Some('.') && matches!(self.peek_second_char(), Some('0'..='9')) {
164 self.next_char(); // consume '.'
165 self.consume_digits();
166 }
167
168 // Optional exponent part: `e` or `E`, optional sign, one or more digits.
169 if matches!(self.peek_char(), Some('e') | Some('E')) {
170 self.next_char(); // consume 'e'/'E'
171 if matches!(self.peek_char(), Some('+') | Some('-')) {
172 self.next_char(); // consume optional sign
173 }
174 if matches!(self.peek_char(), Some('0'..='9')) {
175 self.consume_digits();
176 } else {
177 // Exponent marker not followed by digits → malformed literal.
178 return Token::Undefined(&self.source[start_pos..self.position]);
179 }
180 }
181
182 // Trailing word-boundary check: numbers cannot be directly adjacent to identifier chars
183 if matches!(self.peek_char(), Some('_') | Some('a'..='z' | 'A'..='Z')) {
184 while let Some(ch) = self.peek_char() {
185 if ch == '_' || ch.is_alphanumeric() {
186 self.next_char();
187 } else {
188 break;
189 }
190 }
191 return Token::Undefined(&self.source[start_pos..self.position]);
192 }
193
194 // The accumulated substring is a well-formed f64 string; Rust's
195 // standard parser handles all three forms above.
196 let number_str = &self.source[start_pos..self.position];
197 let value: f64 = number_str
198 .parse()
199 .expect("BUG: lexer produced a number string that f64::parse() rejected");
200 Token::Number(value)
201 }
202
203 /// Scans a double-quoted string literal, given that the opening `"` has
204 /// already been consumed and `start_pos` is the byte offset of the first
205 /// content character (i.e. `self.position` right after consuming `"`).
206 ///
207 /// The returned [`Token::Text`] slice contains only the content between
208 /// the quotes; the delimiters are not included.
209 ///
210 /// If the input ends before a closing `"` is found, the slice extends to
211 /// the end of the source rather than returning an error.
212 fn next_string_literal(&mut self, start_pos: usize) -> Token<'a> {
213 let end_pos: usize = loop {
214 let ch = self.peek_char();
215 if ch == Some('"') {
216 // Found the closing quote. Record the end position *before*
217 // consuming it so the quote is excluded from the slice.
218 let end_pos = self.position;
219 self.next_char(); // consume closing '"'
220 break end_pos;
221 } else if ch.is_none() {
222 // End of input without a closing quote — emit the content so
223 // far rather than erroring (lenient, matching the JS behaviour).
224 break self.position;
225 } else {
226 self.next_char();
227 }
228 };
229 Token::Text(&self.source[start_pos..end_pos])
230 }
231
232 /// Scans a `#`-prefixed line comment, given that the `#` has already been
233 /// consumed and `start_pos` is the byte offset of the first content
234 /// character (i.e. `self.position` right after consuming `#`).
235 ///
236 /// The comment extends to (but does not include) the next `\n`, or to the
237 /// end of the source if there is no newline. The `\n` itself is consumed
238 /// and discarded.
239 ///
240 /// The returned [`Token::Comment`] slice contains only the text after `#`
241 /// and before the terminating newline.
242 fn next_comment(&mut self, start_pos: usize) -> Token<'a> {
243 let end_pos: usize = loop {
244 let ch = self.peek_char();
245 if ch == Some('\n') {
246 // Newline terminates the comment; exclude it from the slice.
247 let end_pos = self.position;
248 self.next_char(); // consume '\n'
249 break end_pos;
250 } else if ch.is_none() {
251 // End of input — comment runs to the end of the source.
252 break self.position;
253 } else {
254 self.next_char();
255 }
256 };
257 Token::Comment(&self.source[start_pos..end_pos])
258 }
259
260 // -------------------------------------------------------------------------
261 // Main entry point
262 // -------------------------------------------------------------------------
263
264 /// Returns the next token and its byte position in the source.
265 ///
266 /// Leading whitespace is skipped before each token. When the source is
267 /// exhausted this method returns `(pos, Token::EndOfExpression)` and
268 /// continues to do so on every subsequent call — the position will be
269 /// pinned at the end of the source.
270 ///
271 /// Two-character operators (`==`, `!=`, `<=`, `>=`, `->`, `<<`, `>>`,
272 /// `=>`, `~>`) are recognised greedily: the first character is consumed by the
273 /// main dispatch, then if the following character completes the operator it
274 /// is consumed too before the token is returned.
275 pub fn next_token(&mut self) -> (usize, Token<'a>) {
276 self.skip_whitespaces();
277
278 // Snapshot the byte offset of the token we are about to produce.
279 let ch_pos = self.position;
280
281 // Consume the first character; return EndOfExpression at end-of-input.
282 let ch = match self.next_char() {
283 Some(ch) => ch,
284 None => return (ch_pos, Token::EndOfExpression),
285 };
286
287 // --- Identifiers -------------------------------------------------------
288 // Start character: letter or `_`. The rest of the identifier is
289 // consumed by next_identifier.
290 if ch == '_' || ch.is_alphabetic() {
291 (ch_pos, self.next_identifier(ch_pos))
292 }
293
294 // --- Numeric literals --------------------------------------------------
295 // Start character: ASCII digit.
296 else if ch.is_ascii_digit() {
297 (ch_pos, self.next_numeric_literal(ch_pos))
298 }
299
300 // --- String literals ---------------------------------------------------
301 // Opening `"`. self.position now points past the quote.
302 else if ch == '"' {
303 (ch_pos, self.next_string_literal(self.position))
304 }
305
306 // --- Line comments -----------------------------------------------------
307 // Opening `#`. self.position now points past the `#`.
308 else if ch == '#' {
309 (ch_pos, self.next_comment(self.position))
310 }
311
312 // --- Two-character operators -------------------------------------------
313 // Each branch peeks at the next character before committing.
314
315 // `==`
316 else if ch == '=' && self.next_char_is('=') {
317 self.next_char(); (ch_pos, Token::EqualToOp)
318 }
319 // `!=`
320 else if ch == '!' && self.next_char_is('=') {
321 self.next_char(); (ch_pos, Token::NotEqualToOp)
322 }
323 // `<=`
324 else if ch == '<' && self.next_char_is('=') {
325 self.next_char(); (ch_pos, Token::LessThanOrEqualToOp)
326 }
327 // `>=`
328 else if ch == '>' && self.next_char_is('=') {
329 self.next_char(); (ch_pos, Token::GreaterThanOrEqualToOp)
330 }
331 // `->`
332 else if ch == '-' && self.next_char_is('>') {
333 self.next_char(); (ch_pos, Token::FunctionDefinitionOp)
334 }
335 // `<<`
336 else if ch == '<' && self.next_char_is('<') {
337 self.next_char(); (ch_pos, Token::CompositionOp)
338 }
339 // `>>`
340 else if ch == '>' && self.next_char_is('>') {
341 self.next_char(); (ch_pos, Token::ReverseCompositionOp)
342 }
343 // `=>`
344 else if ch == '=' && self.next_char_is('>') {
345 self.next_char(); (ch_pos, Token::MappingOp)
346 }
347 else if ch == '~' && self.next_char_is('>') {
348 self.next_char();
349 (ch_pos, Token::ThenOp)
350 }
351
352 // --- Single-character tokens -------------------------------------------
353 else {
354 match ch {
355 '(' => (ch_pos, Token::LeftRoundBracket),
356 ')' => (ch_pos, Token::RightRoundBracket),
357
358 '[' => (ch_pos, Token::LeftSquareBracket),
359 ']' => (ch_pos, Token::RightSquareBracket),
360
361 '{' => (ch_pos, Token::LeftCurlyBracket),
362 '}' => (ch_pos, Token::RightCurlyBracket),
363
364 '.' => (ch_pos, Token::SubcontextingOp),
365
366 '^' => (ch_pos, Token::PowerOp),
367 '*' => (ch_pos, Token::ProductOp),
368 '/' => (ch_pos, Token::DivisionOp),
369 '%' => (ch_pos, Token::ModuloOp),
370 '+' => (ch_pos, Token::PlusOp),
371 '-' => (ch_pos, Token::MinusOp),
372
373 '<' => (ch_pos, Token::LessThanOp),
374 '>' => (ch_pos, Token::GreaterThanOp),
375
376 '&' => (ch_pos, Token::AndOp),
377 '|' => (ch_pos, Token::OrOp),
378
379 '?' => (ch_pos, Token::ConditionalOp),
380 ';' => (ch_pos, Token::AlternativeOp),
381
382 '=' => (ch_pos, Token::AssignmentOp),
383 ':' => (ch_pos, Token::LabellingOp),
384
385 ',' => (ch_pos, Token::PairingOp),
386
387 '!' => (ch_pos, Token::NotOp),
388 '$' => (ch_pos, Token::LiftingOp),
389
390 // Anything else is returned as an Undefined token containing
391 // exactly the one unrecognised character.
392 _ => (ch_pos, Token::Undefined(&self.source[ch_pos..self.position])),
393 }
394 }
395 }
396}
397
398
399// -----------------------------------------------------------------------------
400// Iterator implementation
401// -----------------------------------------------------------------------------
402
403/// Drives the lexer as a standard Rust iterator.
404///
405/// Each call to `next` delegates to [`next_token`](Lexer::next_token) and maps
406/// `Token::EndOfExpression` to `None`, stopping iteration. All other tokens
407/// (including [`Token::Comment`]) are yielded.
408///
409/// > **Note**: Comment tokens are *not* silently discarded here. The parser
410/// > is responsible for skipping them during expression parsing.
411impl<'a> Iterator for Lexer<'a> {
412 type Item = (usize, Token<'a>);
413
414 fn next(&mut self) -> Option<Self::Item> {
415 let (pos, token) = self.next_token();
416 if token == Token::EndOfExpression {
417 None
418 } else {
419 Some((pos, token))
420 }
421 }
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 #[test]
429 fn test_lexer_next_token() {
430
431 macro_rules! assert_token {
432 ($input:expr, $token:expr, $pos:expr) => {
433 assert_eq!(
434 Lexer::new($input).next_token(),
435 ($pos, $token))
436 }
437 }
438
439 // Identifier
440 assert_token!("Abc1", Token::Identifier("Abc1"), 0);
441 assert_token!("abc1", Token::Identifier("abc1"), 0);
442 assert_token!("_ab1", Token::Identifier("_ab1"), 0);
443
444 // Numeric Literal
445 assert_token!("123", Token::Number(123.0), 0);
446 assert_token!("12.3", Token::Number(12.3), 0);
447 assert_token!("12.3e2", Token::Number(1230.0), 0);
448 assert_token!("12.3E+2", Token::Number(1230.0), 0);
449 assert_token!("12.3e-2", Token::Number(0.123), 0);
450 assert_token!("12.", Token::Number(12.0), 0);
451 assert_token!("12.3e", Token::Undefined("12.3e"), 0);
452 assert_token!("12.3e+", Token::Undefined("12.3e+"), 0);
453 assert_token!("12.3E-", Token::Undefined("12.3E-"), 0);
454 assert_token!("12.3abc", Token::Undefined("12.3abc"), 0);
455
456 // Text Literal
457 assert_token!("\"abc\"", Token::Text("abc"), 0);
458 assert_token!("\"abc", Token::Text("abc"), 0);
459
460 // Brackets
461 assert_token!("(", Token::LeftRoundBracket, 0);
462 assert_token!(")", Token::RightRoundBracket, 0);
463 assert_token!("[", Token::LeftSquareBracket, 0);
464 assert_token!("]", Token::RightSquareBracket, 0);
465 assert_token!("{", Token::LeftCurlyBracket, 0);
466 assert_token!("}", Token::RightCurlyBracket, 0);
467
468 // Binary operators
469 assert_token!(".", Token::SubcontextingOp, 0);
470
471 assert_token!("^", Token::PowerOp, 0);
472 assert_token!("*", Token::ProductOp, 0);
473 assert_token!("/", Token::DivisionOp, 0);
474 assert_token!("%", Token::ModuloOp, 0);
475 assert_token!("+", Token::PlusOp, 0);
476 assert_token!("-", Token::MinusOp, 0);
477
478 assert_token!("==", Token::EqualToOp, 0);
479 assert_token!("!=", Token::NotEqualToOp, 0);
480 assert_token!("<", Token::LessThanOp, 0);
481 assert_token!("<=", Token::LessThanOrEqualToOp, 0);
482 assert_token!(">", Token::GreaterThanOp, 0);
483 assert_token!(">=", Token::GreaterThanOrEqualToOp, 0);
484
485 assert_token!("&", Token::AndOp, 0);
486 assert_token!("|", Token::OrOp, 0);
487
488 assert_token!("?", Token::ConditionalOp, 0);
489 assert_token!(";", Token::AlternativeOp, 0);
490
491 assert_token!("->", Token::FunctionDefinitionOp, 0);
492
493 assert_token!("<<", Token::CompositionOp, 0);
494 assert_token!(">>", Token::ReverseCompositionOp, 0);
495
496 assert_token!("=>", Token::MappingOp, 0);
497
498 assert_token!("=", Token::AssignmentOp, 0);
499 assert_token!(":", Token::LabellingOp, 0);
500
501 assert_token!(",", Token::PairingOp, 0);
502
503 // Special tokens
504 assert_token!("@", Token::Undefined("@"), 0);
505
506 assert_token!("# this is a comment \n\n", Token::Comment(" this is a comment "), 0);
507 assert_token!("# this is a comment ", Token::Comment(" this is a comment "), 0);
508
509 assert_token!("", Token::EndOfExpression, 0);
510
511 // Spaces skipping
512 assert_token!(" *", Token::ProductOp, 2);
513 }
514
515 #[test]
516 fn test_lexer_iterator() {
517 let input = "msg = \"hello\"";
518 let lexer = Lexer::new(input);
519
520 let tokens: Vec<(usize, Token)> = lexer.collect();
521 assert_eq!(
522 tokens,
523 vec![
524 (0, Token::Identifier("msg")),
525 (4, Token::AssignmentOp),
526 (6, Token::Text("hello")),
527 ]
528 );
529 }
530}
531
532