//! Lexer for form (A). //! //! The lexical layer has three rules: //! //! 1. Whitespace (` `, `\t`, `\n`, `\r`) and parens (`(`, `)`) are the //! only delimiters. Any maximal non-whitespace, non-paren run is a //! single token. //! 2. `;` introduces a comment to end-of-line. Comments produce no //! tokens. //! 3. After tokenisation, classify by first character: //! - `"` ⇒ string literal (consume until closing `"`; `\"` and //! `\n` escapes supported). //! - digit, or `-` followed by digit ⇒ integer or float literal //! (float if the token contains `.`, `e`, or `E`; integer //! otherwise). //! - otherwise ⇒ ident. //! //! Operators (`+`, `==`, `<=`, `**`), qualified names like //! `io/print_int`, and dotted cross-module references like //! `std_list.map` are all single-token idents — there is no special //! lexical rule for any of them. `(`, `)`, and whitespace are the only //! reserved tokens. Bool literals (`true`, `false`) and unit //! (`(lit-unit)`) are disambiguated by the parser via context. use thiserror::Error; /// Source byte offset (start, end] into the original input. `end` is /// one past the last byte. Used for error reporting only. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Span { pub start: usize, pub end: usize, } /// One lexical token. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Tok { /// `(` LParen, /// `)` RParen, /// Integer literal. Original textual form preserved for error messages; /// parsed `i64` value carried for direct use. Int(i64), /// IEEE-754 binary64 literal. The /// payload is the bit pattern produced by `f64::to_bits`. The /// surface forms recognised by the lexer are /// `.(e[+-]?)?` and /// `e[+-]?` (per spec A2). Hex floats and bare /// leading/trailing dots are not recognised. Float(u64), /// String literal contents (after escape processing). Str(String), /// Identifier. Anything that isn't a paren, integer, float, or string. Ident(String), } /// A token plus its span. #[derive(Debug, Clone)] pub struct Token { pub tok: Tok, pub span: Span, } /// Lexer error. #[derive(Debug, Error)] pub enum LexError { #[error("unterminated string literal at byte {start}")] UnterminatedString { start: usize }, #[error("invalid integer literal {literal:?} at byte {start}")] InvalidInteger { literal: String, start: usize }, #[error("invalid float literal {literal:?} at byte {start}")] InvalidFloat { literal: String, start: usize }, #[error("invalid escape sequence \\{ch} in string literal at byte {pos}")] InvalidEscape { ch: char, pos: usize }, } /// Tokenise an input string into a flat token stream. /// /// Whitespace and comments are dropped. Returns a [`LexError`] on /// unterminated string literals or numeric tokens that fail to parse. pub fn tokenize(input: &str) -> Result, LexError> { let bytes = input.as_bytes(); let mut out = Vec::new(); let mut i = 0usize; while i < bytes.len() { let b = bytes[i]; // Whitespace. if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' { i += 1; continue; } // Comment to end-of-line. if b == b';' { while i < bytes.len() && bytes[i] != b'\n' { i += 1; } continue; } // Parens. if b == b'(' { out.push(Token { tok: Tok::LParen, span: Span { start: i, end: i + 1 } }); i += 1; continue; } if b == b')' { out.push(Token { tok: Tok::RParen, span: Span { start: i, end: i + 1 } }); i += 1; continue; } // String literal. Iterate over UTF-8 chars (not raw bytes) so // multi-byte glyphs in the source survive round-trip. if b == b'"' { let start = i; i += 1; let mut value = String::new(); // Walk via char_indices on the unread tail to keep i a byte offset. loop { if i >= bytes.len() { return Err(LexError::UnterminatedString { start }); } // Quick byte-level checks for ASCII control bytes that // can never start a multi-byte UTF-8 sequence. let c0 = bytes[i]; if c0 == b'"' { i += 1; break; } if c0 == b'\\' { if i + 1 >= bytes.len() { return Err(LexError::UnterminatedString { start }); } let esc = bytes[i + 1]; match esc { b'"' => value.push('"'), b'\\' => value.push('\\'), b'n' => value.push('\n'), b't' => value.push('\t'), b'r' => value.push('\r'), other => { return Err(LexError::InvalidEscape { ch: other as char, pos: i, }); } } i += 2; continue; } // Decode one full UTF-8 character starting at i. The // input is guaranteed to be valid UTF-8 by Rust's // `&str` invariant, so a single `chars()` step on the // tail gives us the next scalar. if let Some(ch) = input[i..].chars().next() { value.push(ch); i += ch.len_utf8(); } else { return Err(LexError::UnterminatedString { start }); } } out.push(Token { tok: Tok::Str(value), span: Span { start, end: i }, }); continue; } // Otherwise: maximal non-paren / non-whitespace / non-comment run. let start = i; while i < bytes.len() { let c = bytes[i]; if c == b' ' || c == b'\t' || c == b'\n' || c == b'\r' || c == b'(' || c == b')' || c == b';' { break; } i += 1; } let raw = &input[start..i]; // Classify. let first = raw.as_bytes()[0]; let is_int = first.is_ascii_digit() || (first == b'-' && raw.len() > 1 && raw.as_bytes()[1].is_ascii_digit()); if is_int { let has_dot = raw.contains('.'); let has_exp = raw.contains('e') || raw.contains('E'); if has_dot || has_exp { if !looks_like_float(raw) { return Err(LexError::InvalidFloat { literal: raw.to_string(), start, }); } match raw.parse::() { Ok(f) => out.push(Token { tok: Tok::Float(f.to_bits()), span: Span { start, end: i }, }), Err(_) => { return Err(LexError::InvalidFloat { literal: raw.to_string(), start, }); } } } else { // Pure integer: the existing path. match raw.parse::() { Ok(v) => out.push(Token { tok: Tok::Int(v), span: Span { start, end: i }, }), Err(_) => { return Err(LexError::InvalidInteger { literal: raw.to_string(), start, }); } } } } else { out.push(Token { tok: Tok::Ident(raw.to_string()), span: Span { start, end: i }, }); } } Ok(out) } /// Spec A2 grammar validator for a digit-lead token that already /// contains `.` or `e`/`E`. Returns `true` iff `raw` matches one of /// - `[-]?.` /// - `[-]?.(e|E)[+-]?` /// - `[-]?(e|E)[+-]?` /// and contains at least one of `.`, `e`, `E` (otherwise the caller /// would dispatch to the integer path). Rejects bare leading/trailing /// dots (`5.`, `5.e10`), missing exponent digits (`1.5e`), and any /// trailing junk. /// /// Hex floats (`0x1.8p4`) are naturally rejected because the /// digit-lead caller arm only runs for tokens whose first digit /// character is decimal; an `x` mid-token would fail validation here. fn looks_like_float(raw: &str) -> bool { let body = raw.strip_prefix('-').unwrap_or(raw); let bytes = body.as_bytes(); if bytes.is_empty() || !bytes[0].is_ascii_digit() { return false; } let mut i = 0; while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; } let mut saw_dot = false; if i < bytes.len() && bytes[i] == b'.' { saw_dot = true; i += 1; let frac_start = i; while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; } if i == frac_start { return false; // `5.` or `5.e10` — trailing dot } } let mut saw_exp = false; if i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') { saw_exp = true; i += 1; if i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') { i += 1; } let exp_start = i; while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; } if i == exp_start { return false; // `1.5e` or `1.5e+` } } i == bytes.len() && (saw_dot || saw_exp) } #[cfg(test)] mod tests { use super::*; #[test] fn parens_and_idents() { let toks = tokenize("(foo bar)").unwrap(); assert_eq!(toks.len(), 4); assert!(matches!(toks[0].tok, Tok::LParen)); assert!(matches!(&toks[1].tok, Tok::Ident(s) if s == "foo")); assert!(matches!(&toks[2].tok, Tok::Ident(s) if s == "bar")); assert!(matches!(toks[3].tok, Tok::RParen)); } #[test] fn comments_are_dropped() { let toks = tokenize("(a ; this is a comment\n b)").unwrap(); assert_eq!(toks.len(), 4); } #[test] fn integers() { let toks = tokenize("42 -7 0").unwrap(); assert_eq!(toks.len(), 3); assert!(matches!(toks[0].tok, Tok::Int(42))); assert!(matches!(toks[1].tok, Tok::Int(-7))); assert!(matches!(toks[2].tok, Tok::Int(0))); } #[test] fn operators_are_idents() { let toks = tokenize("+ == <= io/print_int std_list.map").unwrap(); let names: Vec<_> = toks .iter() .map(|t| match &t.tok { Tok::Ident(s) => s.clone(), _ => panic!("not ident"), }) .collect(); assert_eq!(names, vec!["+", "==", "<=", "io/print_int", "std_list.map"]); } #[test] fn string_with_escapes() { let toks = tokenize(r#""hello\nworld""#).unwrap(); assert_eq!(toks.len(), 1); assert!(matches!(&toks[0].tok, Tok::Str(s) if s == "hello\nworld")); } #[test] fn negative_lone_minus_is_ident() { // `-` alone (no digit follows) is an ident, not a (failed) integer. let toks = tokenize("-").unwrap(); assert_eq!(toks.len(), 1); assert!(matches!(&toks[0].tok, Tok::Ident(s) if s == "-")); } #[test] fn string_preserves_multibyte_utf8() { let toks = tokenize(r#""em — dash and naïve""#).unwrap(); assert_eq!(toks.len(), 1); assert!(matches!(&toks[0].tok, Tok::Str(s) if s == "em — dash and naïve")); } /// A `.` token lexes as /// `Tok::Float` carrying the IEEE-754 binary64 bit pattern of /// `f64::from_str(raw).unwrap().to_bits()`. Pinned literal: /// `1.5_f64` → bit pattern `0x3FF8000000000000`. #[test] fn float_basic_decimal() { let toks = tokenize("1.5").unwrap(); assert_eq!(toks.len(), 1); assert!(matches!(toks[0].tok, Tok::Float(0x3ff8_0000_0000_0000))); } /// Spec A2 grammar: scientific-notation forms with a fractional /// part (`1.5e3`, `2.0e-10`). #[test] fn float_scientific_with_fraction() { let toks = tokenize("1.5e3 2.0e-10").unwrap(); assert_eq!(toks.len(), 2); let bits1500 = 1.5e3_f64.to_bits(); let bits_neg10 = 2.0e-10_f64.to_bits(); match toks[0].tok { Tok::Float(b) => assert_eq!(b, bits1500), ref other => panic!("expected Tok::Float, got {other:?}"), } match toks[1].tok { Tok::Float(b) => assert_eq!(b, bits_neg10), ref other => panic!("expected Tok::Float, got {other:?}"), } } /// Spec A2 grammar: exponent-only forms (`1e10`, `3e-5`). #[test] fn float_pure_exponent() { let toks = tokenize("1e10 3e-5").unwrap(); assert_eq!(toks.len(), 2); match toks[0].tok { Tok::Float(b) => assert_eq!(b, 1e10_f64.to_bits()), ref other => panic!("expected Tok::Float, got {other:?}"), } match toks[1].tok { Tok::Float(b) => assert_eq!(b, 3e-5_f64.to_bits()), ref other => panic!("expected Tok::Float, got {other:?}"), } } /// Negative float literals follow the existing negative-int rule /// (`-` followed by a digit is part of the numeric token), so /// `-1.5` and `-1.5e-3` lex as single Tok::Float values. #[test] fn float_negative() { let toks = tokenize("-1.5 -1.5e-3").unwrap(); assert_eq!(toks.len(), 2); match toks[0].tok { Tok::Float(b) => assert_eq!(b, (-1.5_f64).to_bits()), ref other => panic!("expected Tok::Float, got {other:?}"), } match toks[1].tok { Tok::Float(b) => assert_eq!(b, (-1.5e-3_f64).to_bits()), ref other => panic!("expected Tok::Float, got {other:?}"), } } /// Edge case: `0.0` and `-0.0` lex as distinct Tok::Float bit /// patterns (per spec A5: -0 and +0 are distinct at the bit level). #[test] fn float_signed_zero() { let toks = tokenize("0.0 -0.0").unwrap(); assert_eq!(toks.len(), 2); match toks[0].tok { Tok::Float(b) => assert_eq!(b, 0u64), ref other => panic!("expected Tok::Float, got {other:?}"), } match toks[1].tok { Tok::Float(b) => assert_eq!(b, 0x8000_0000_0000_0000u64), ref other => panic!("expected Tok::Float, got {other:?}"), } } /// Spec A2 rejects bare trailing dot — `5.` is not a float literal. #[test] fn float_rejects_trailing_dot() { let err = tokenize("5.").unwrap_err(); assert!( matches!(err, LexError::InvalidFloat { ref literal, .. } if literal == "5."), "expected InvalidFloat for `5.`, got {err:?}" ); } /// Spec A2 rejects missing fractional part before exponent — `5.e10` /// has the `.` shape with no fraction digits. #[test] fn float_rejects_missing_fraction_before_exponent() { let err = tokenize("5.e10").unwrap_err(); assert!( matches!(err, LexError::InvalidFloat { ref literal, .. } if literal == "5.e10"), "expected InvalidFloat for `5.e10`, got {err:?}" ); } /// Spec A2 rejects empty exponent — `1.5e` has no exponent digits. #[test] fn float_rejects_empty_exponent() { let err = tokenize("1.5e").unwrap_err(); assert!( matches!(err, LexError::InvalidFloat { ref literal, .. } if literal == "1.5e"), "expected InvalidFloat for `1.5e`, got {err:?}" ); } /// Spec A2 rejects sign-only exponent — `1.5e+` has no exponent /// digits after the sign. #[test] fn float_rejects_sign_only_exponent() { let err = tokenize("1.5e+").unwrap_err(); assert!( matches!(err, LexError::InvalidFloat { ref literal, .. } if literal == "1.5e+"), "expected InvalidFloat for `1.5e+`, got {err:?}" ); } /// Spec A2 rejects double dot — `1..5`. #[test] fn float_rejects_double_dot() { let err = tokenize("1..5").unwrap_err(); assert!( matches!(err, LexError::InvalidFloat { ref literal, .. } if literal == "1..5"), "expected InvalidFloat for `1..5`, got {err:?}" ); } /// Spec A2 grammar accepts both `e` and `E` for the exponent /// marker. The `looks_like_float` validator handles both bytes; /// pin the uppercase form explicitly. #[test] fn float_uppercase_exponent() { let toks = tokenize("1.5E3 2E-5").unwrap(); assert_eq!(toks.len(), 2); match toks[0].tok { Tok::Float(b) => assert_eq!(b, 1.5e3_f64.to_bits()), ref other => panic!("expected Tok::Float, got {other:?}"), } match toks[1].tok { Tok::Float(b) => assert_eq!(b, 2e-5_f64.to_bits()), ref other => panic!("expected Tok::Float, got {other:?}"), } } /// Spec A2 rejects bare leading dot at the *float* level — `.5` is /// not a float literal. The lexer's digit-lead rule does not fire /// (first byte `.` is not a digit and not `-`-followed-by-digit), /// so `.5` falls through to the maximal-non-paren-non-whitespace /// run and lexes as `Tok::Ident(".5")`. Downstream parser / /// typecheck stages produce the appropriate diagnostic. #[test] fn float_leading_dot_lexes_as_ident() { let toks = tokenize(".5").unwrap(); assert_eq!(toks.len(), 1); assert!(matches!(&toks[0].tok, Tok::Ident(s) if s == ".5")); } }