use std::borrow::Cow;
use crate::lexer::{
is_es_line_terminator, is_es_line_terminator_at, is_es_whitespace, unicode_escape_len_at,
};
const LOOKAHEAD_WS_LUT: [ScanByte; 256] = {
let mut t = [ScanByte::Out; 256];
let mut i = 0;
while i < 256 {
let b = i as u8;
t[i] = if matches!(b, b' ' | b'\t' | b'\n' | b'\r' | 0x0B | 0x0C) {
ScanByte::In
} else if is_es_whitespace_lead(b) {
ScanByte::Maybe
} else {
ScanByte::Out
};
i += 1;
}
t
};
#[inline]
pub(super) fn skip_whitespace(bytes: &[u8], mut pos: usize) -> usize {
while pos < bytes.len() {
match LOOKAHEAD_WS_LUT[bytes[pos] as usize] {
ScanByte::In => pos += 1,
ScanByte::Maybe => match non_ascii_es_whitespace_len_at(bytes, pos) {
0 => break,
len => pos += len,
},
ScanByte::Out | ScanByte::Escape => break,
}
}
pos
}
#[inline]
fn non_ascii_es_whitespace_len_at(bytes: &[u8], pos: usize) -> usize {
match decode_char(bytes, pos) {
Some(c) if is_es_whitespace(c) || is_es_line_terminator(c) => c.len_utf8(),
_ => 0,
}
}
#[inline]
fn decode_char(bytes: &[u8], pos: usize) -> Option<char> {
let width = match bytes[pos] {
0xC0..=0xDF => 2,
0xE0..=0xEF => 3,
0xF0..=0xF7 => 4,
_ => return None,
};
let end = pos.checked_add(width)?;
std::str::from_utf8(bytes.get(pos..end)?)
.ok()?
.chars()
.next()
}
#[inline]
pub(super) fn skip_line_comment(bytes: &[u8], mut pos: usize) -> usize {
pos += 2;
while pos < bytes.len() && !is_es_line_terminator_at(bytes, pos) {
pos += 1;
}
pos
}
#[inline]
pub(super) fn skip_block_comment(bytes: &[u8], mut pos: usize) -> usize {
pos += 2;
while pos + 1 < bytes.len() {
if bytes[pos] == b'*' && bytes[pos + 1] == b'/' {
return pos + 2;
}
pos += 1;
}
pos
}
#[inline]
pub(super) fn skip_whitespace_and_comments(bytes: &[u8], mut pos: usize) -> usize {
loop {
let start = pos;
pos = skip_whitespace(bytes, pos);
if pos + 1 < bytes.len() && bytes[pos] == b'/' {
if bytes[pos + 1] == b'/' {
pos = skip_line_comment(bytes, pos);
} else if bytes[pos + 1] == b'*' {
pos = skip_block_comment(bytes, pos);
} else {
break;
}
} else {
break;
}
if pos == start {
break;
}
}
pos
}
const LOOKAHEAD_ID_START_LUT: [ScanByte; 256] = build_id_lut(false);
const LOOKAHEAD_ID_CONTINUE_LUT: [ScanByte; 256] = build_id_lut(true);
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ScanByte {
Out,
In,
Maybe,
Escape,
}
const fn build_id_lut(continuing: bool) -> [ScanByte; 256] {
let mut t = [ScanByte::Out; 256];
let mut i = 0;
while i < 256 {
let b = i as u8;
let ascii_member = b == b'_'
|| b == b'$'
|| if continuing {
b.is_ascii_alphanumeric()
} else {
b.is_ascii_alphabetic()
};
t[i] = if is_es_whitespace_lead(b) {
ScanByte::Maybe
} else if b == b'\\' {
ScanByte::Escape
} else if b > 127 || ascii_member {
ScanByte::In
} else {
ScanByte::Out
};
i += 1;
}
t
}
const fn is_es_whitespace_lead(b: u8) -> bool {
matches!(b, 0xC2 | 0xE1 | 0xE2 | 0xE3 | 0xEF)
}
#[inline]
pub(super) fn identifier_starts_at(bytes: &[u8], pos: usize) -> bool {
id_class_holds(&LOOKAHEAD_ID_START_LUT, bytes, pos)
}
#[inline]
fn identifier_continues_at(bytes: &[u8], pos: usize) -> bool {
id_class_holds(&LOOKAHEAD_ID_CONTINUE_LUT, bytes, pos)
}
#[inline]
fn id_class_holds(lut: &[ScanByte; 256], bytes: &[u8], pos: usize) -> bool {
match bytes.get(pos) {
Some(&b) => match lut[b as usize] {
ScanByte::In => true,
ScanByte::Maybe => non_ascii_es_whitespace_len_at(bytes, pos) == 0,
ScanByte::Escape => unicode_escape_len_at(bytes, pos) != 0,
ScanByte::Out => false,
},
None => false,
}
}
#[inline]
pub(super) fn is_word_at(bytes: &[u8], pos: usize, word: &[u8]) -> bool {
bytes[pos..].starts_with(word) && !identifier_continues_at(bytes, pos + word.len())
}
#[inline]
pub(super) fn skip_numeric_literal(bytes: &[u8], pos: usize) -> usize {
let mut cursor = pos;
if bytes.get(cursor) == Some(&b'-') {
cursor += 1;
}
if !matches!(bytes.get(cursor), Some(b'0'..=b'9' | b'.')) {
return pos;
}
let radix_prefixed = bytes[cursor] == b'0'
&& matches!(
bytes.get(cursor + 1),
Some(b'x' | b'X' | b'o' | b'O' | b'b' | b'B')
);
let mut takes_point = !radix_prefixed;
while cursor < bytes.len() {
let b = bytes[cursor];
if b == b'.' {
if !takes_point {
break;
}
takes_point = false;
} else if b.is_ascii_alphabetic() {
takes_point = false;
if !radix_prefixed
&& matches!(b, b'e' | b'E')
&& matches!(bytes.get(cursor + 1), Some(b'-' | b'+'))
{
cursor += 1;
}
} else if !(b.is_ascii_digit() || b == b'_') {
break;
}
cursor += 1;
}
cursor
}
#[inline]
pub(super) fn skip_identifier(bytes: &[u8], mut pos: usize) -> usize {
while pos < bytes.len() {
match LOOKAHEAD_ID_CONTINUE_LUT[bytes[pos] as usize] {
ScanByte::In => pos += 1,
ScanByte::Maybe if non_ascii_es_whitespace_len_at(bytes, pos) == 0 => pos += 1,
ScanByte::Escape => match unicode_escape_len_at(bytes, pos) {
0 => break,
len => pos += len,
},
ScanByte::Maybe | ScanByte::Out => break,
}
}
pos
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LeadingZeroLiteral {
LegacyOctal,
NonOctalDecimal,
}
pub(crate) fn classify_leading_zero(raw: &str) -> Option<LeadingZeroLiteral> {
let bytes = raw.as_bytes();
if bytes.first() != Some(&b'0') || !bytes.get(1).is_some_and(u8::is_ascii_digit) {
return None;
}
let mut run = bytes[1..].iter().take_while(|b| b.is_ascii_digit());
if run.all(|&b| b < b'8') {
Some(LeadingZeroLiteral::LegacyOctal)
} else {
Some(LeadingZeroLiteral::NonOctalDecimal)
}
}
pub(crate) fn parse_number_literal(raw: &str) -> Result<f64, std::num::ParseFloatError> {
let clean: Cow<'_, str> = if raw.as_bytes().contains(&b'_') {
Cow::Owned(raw.chars().filter(|&c| c != '_').collect())
} else {
Cow::Borrowed(raw)
};
let clean = clean.strip_suffix('n').unwrap_or(&clean);
if clean.len() >= 2 {
let prefix = &clean[..2];
let digits = &clean[2..];
match prefix {
"0x" | "0X" => return Ok(parse_radix_f64(digits, 16)),
"0b" | "0B" => return Ok(parse_radix_f64(digits, 2)),
"0o" | "0O" => return Ok(parse_radix_f64(digits, 8)),
_ => {}
}
}
if let Some(LeadingZeroLiteral::LegacyOctal) = classify_leading_zero(clean) {
return Ok(parse_radix_f64(&clean[1..], 8));
}
clean.parse::<f64>()
}
fn parse_radix_f64(digits: &str, radix: u32) -> f64 {
digits.chars().fold(0f64, |acc, c| {
#[expect(clippy::suboptimal_flops)]
{
acc * f64::from(radix) + f64::from(c.to_digit(radix).unwrap_or(0))
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn utf8(c: char) -> Vec<u8> {
let mut buf = [0u8; 4];
c.encode_utf8(&mut buf).as_bytes().to_vec()
}
fn in_js_s_class(cp: u32) -> bool {
matches!(
cp,
0x0009 | 0x000B | 0x000C | 0xFEFF | 0x0020 | 0x00A0 | 0x1680 | 0x2000
..=0x200A | 0x202F | 0x205F | 0x3000 | 0x000A | 0x000D | 0x2028 | 0x2029 )
}
#[test]
fn lookahead_id_luts_match_the_predicates_they_replace() {
for b in 0..=u8::MAX {
let verdict = |member: bool| {
if is_es_whitespace_lead(b) {
ScanByte::Maybe
} else if b == b'\\' {
ScanByte::Escape
} else if member {
ScanByte::In
} else {
ScanByte::Out
}
};
assert_eq!(
LOOKAHEAD_ID_START_LUT[b as usize],
verdict(b.is_ascii_alphabetic() || b == b'_' || b == b'$' || b > 127),
"id_start mismatch at byte {b:#x}"
);
assert_eq!(
LOOKAHEAD_ID_CONTINUE_LUT[b as usize],
verdict(b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || b > 127),
"id_continue mismatch at byte {b:#x}"
);
}
}
#[test]
fn es_whitespace_leads_cover_the_class() {
let mut expected = [false; 256];
for cp in 0..=0x10ffff_u32 {
let Some(c) = char::from_u32(cp) else {
continue;
};
if in_js_s_class(cp) && cp > 0x7f {
expected[utf8(c)[0] as usize] = true;
}
}
for b in 0..=u8::MAX {
assert_eq!(
is_es_whitespace_lead(b),
expected[b as usize],
"whitespace-lead filter is wrong at byte {b:#x}"
);
}
}
#[test]
fn identifier_scan_ends_at_exactly_the_js_s_class() {
for cp in 0..=0x10ffff_u32 {
let Some(c) = char::from_u32(cp) else {
continue;
};
let bytes = utf8(c);
let is_space = in_js_s_class(cp);
if is_space {
assert!(
!identifier_continues_at(&bytes, 0),
"U+{cp:04X} must end an identifier"
);
assert!(
!identifier_starts_at(&bytes, 0),
"U+{cp:04X} must not start an identifier"
);
assert_eq!(
skip_identifier(&bytes, 0),
0,
"U+{cp:04X} must not be skipped as an identifier character"
);
continue;
}
if crate::lexer::ident::is_id_continue(c) {
assert!(
identifier_continues_at(&bytes, 0),
"U+{cp:04X} is `ID_Continue` and must not end an identifier"
);
assert_eq!(
skip_identifier(&bytes, 0),
bytes.len(),
"U+{cp:04X} is `ID_Continue` and must be skipped whole"
);
}
if crate::lexer::ident::is_id_start(c) {
assert!(
identifier_starts_at(&bytes, 0),
"U+{cp:04X} is `ID_Start` and must start an identifier"
);
}
}
}
#[test]
fn is_word_at_ends_the_word_at_exactly_the_js_s_class() {
for cp in 0..=0x10ffff_u32 {
let Some(c) = char::from_u32(cp) else {
continue;
};
let mut bytes = b"extends".to_vec();
bytes.extend_from_slice(&utf8(c));
if in_js_s_class(cp) {
assert!(
is_word_at(&bytes, 0, b"extends"),
"`extends` followed by U+{cp:04X} is still the keyword"
);
} else if crate::lexer::ident::is_id_continue(c) {
assert!(
!is_word_at(&bytes, 0, b"extends"),
"`extends` followed by U+{cp:04X} is one longer identifier"
);
}
}
assert!(is_word_at(b"extends", 0, b"extends"));
}
#[test]
fn identifier_scan_hands_off_to_the_whitespace_scan() {
for cp in 0..=0x10ffff_u32 {
if !in_js_s_class(cp) {
continue;
}
let Some(c) = char::from_u32(cp) else {
continue;
};
let s = format!("in{c}keyof");
let bytes = s.as_bytes();
let after_word = skip_identifier(bytes, 0);
assert_eq!(after_word, 2, "U+{cp:04X} was absorbed into `in`");
let after_gap = skip_whitespace(bytes, after_word);
assert_eq!(
&s[after_gap..],
"keyof",
"U+{cp:04X} left the cursor off the next token"
);
}
}
#[test]
fn identifier_scan_crosses_a_unicode_escape_by_its_own_width() {
let cases: &[(&str, usize)] = &[
("\\u0054", 6),
("\\uFFFF", 6),
("\\u0030", 6), ("\\u{54}", 6),
("\\u{102A7}", 9),
("\\u{0000000000000042}", 20), ("\\u054", 0), ("\\u054 ", 0), ("\\uZZZZ", 0),
("\\u{}", 0), ("\\u{54", 0), ("\\uD800", 0), ("\\u{D800}", 0),
("\\u{110000}", 0), ("\\x0054", 0), ("\\", 0),
];
for &(src, len) in cases {
let bytes = src.as_bytes();
let admitted = len != 0;
assert_eq!(
unicode_escape_len_at(bytes, 0),
len,
"escape length at {src:?}"
);
assert_eq!(
identifier_starts_at(bytes, 0),
admitted,
"identifier start at {src:?}"
);
assert_eq!(
identifier_continues_at(bytes, 0),
admitted,
"identifier continue at {src:?}"
);
assert_eq!(skip_identifier(bytes, 0), len, "skip_identifier at {src:?}");
}
assert_eq!(skip_identifier(b"\\u0054foo", 0), 9);
assert_eq!(skip_identifier(b"x\\u0054y", 0), 8);
assert!(!is_word_at(b"keyof\\u0041", 0, b"keyof"));
assert!(is_word_at(b"keyof\\", 0, b"keyof"));
assert!(is_word_at(b"keyof x", 0, b"keyof"));
}
#[test]
fn lookahead_ws_lut_matches_the_predicate_it_replaces() {
for b in 0..=u8::MAX {
let expected = if matches!(b, b' ' | b'\t' | b'\n' | b'\r' | 0x0B | 0x0C) {
ScanByte::In
} else if is_es_whitespace_lead(b) {
ScanByte::Maybe
} else {
ScanByte::Out
};
assert_eq!(
LOOKAHEAD_WS_LUT[b as usize], expected,
"ws lut mismatch at byte {b:#x}"
);
}
}
#[test]
fn skip_whitespace_crosses_exactly_the_js_s_class() {
for cp in 0..=0x10ffff_u32 {
let Some(c) = char::from_u32(cp) else {
continue;
};
let expected = matches!(
cp,
0x0009 | 0x000B | 0x000C | 0xFEFF | 0x0020 | 0x00A0 | 0x1680 | 0x2000
..=0x200A | 0x202F | 0x205F | 0x3000 | 0x000A | 0x000D | 0x2028 | 0x2029 );
let mut buf = [0u8; 4];
let encoded = c.encode_utf8(&mut buf).len();
let bytes = &buf[..encoded];
let crossed = skip_whitespace(bytes, 0) == encoded;
assert_eq!(
crossed, expected,
"U+{cp:04X} is on the wrong side of `\\s`"
);
}
}
#[test]
fn skip_whitespace_lands_on_a_character_boundary() {
for s in [
"\u{a0}=>",
"\u{feff}=>",
"\u{3000}=>",
"\u{2028}=>",
"\u{b}=>",
" \u{a0}\t=>",
] {
let bytes = s.as_bytes();
let pos = skip_whitespace(bytes, 0);
assert!(
s.is_char_boundary(pos),
"{s:?} stopped mid-character at {pos}"
);
assert_eq!(&s[pos..], "=>", "{s:?} did not stop at the token");
}
}
#[test]
fn skip_whitespace_stops_at_non_es_whitespace() {
for s in ["\u{85}x", "\u{b5}x", "\u{180e}x", "x"] {
assert_eq!(
skip_whitespace(s.as_bytes(), 0),
0,
"{s:?} should not be crossed"
);
}
}
}