mod comments;
mod identifiers;
mod numbers;
mod strings;
pub(crate) mod token;
use comments::read_comment;
use identifiers::{
IDENT_CHUNK, ascii_identifier_chunk_stop, ascii_identifier_run_end, is_ascii_identifier_start,
is_identifier_start, read_identifier, run_stop_ends_identifier,
};
pub(crate) use identifiers::{
IDENT_CONTINUE_LUT, hyphen_starts_own_token, is_non_ascii_identifier_codepoint,
};
use numbers::read_number_into;
use strings::read_string;
pub(crate) use strings::string_end;
pub(crate) use token::{Token, TokenKind};
use tsv_lang::Error;
pub(crate) struct Lexer<'a> {
source: &'a str,
bytes: &'a [u8],
pos: usize,
decode_scratch: String,
has_decoded: bool,
base_offset: usize,
}
impl<'a> Lexer<'a> {
pub(crate) fn at_offset(source: &'a str, base_offset: usize) -> Self {
let pos = tsv_lang::leading_bom_len(source);
Self {
source,
bytes: source.as_bytes(),
pos,
decode_scratch: String::new(),
has_decoded: false,
base_offset,
}
}
#[cold]
#[inline(never)]
fn host_err(&self, err: Error) -> Error {
err.shift_position(self.base_offset)
}
#[inline]
pub(crate) fn decoded_str(&self) -> Option<&str> {
if self.has_decoded {
Some(&self.decode_scratch)
} else {
None
}
}
#[inline]
pub(crate) fn seek(&mut self, pos: usize) {
self.pos = pos;
self.has_decoded = false;
}
#[inline]
fn cur_byte(&self) -> Option<u8> {
self.bytes.get(self.pos).copied()
}
#[inline]
fn current_char(&self) -> Option<char> {
self.source[self.pos..].chars().next()
}
#[inline]
fn peek_char(&self, offset: usize) -> Option<char> {
self.source[self.pos..].chars().nth(offset)
}
pub(crate) fn token_at(&mut self, at: usize) -> Result<Token, Error> {
self.seek(at);
self.next_token()
}
fn identifier_into(&mut self, dst: &mut Token) -> Result<(), Error> {
let bytes = self.bytes;
let start = self.pos;
let run_from = start + usize::from(bytes.get(start) == Some(&b'$'));
let end = ascii_identifier_run_end(bytes, run_from);
if !run_stop_ends_identifier(bytes.get(end).copied()) {
return self.read_decoded_identifier_into(dst);
}
self.pos = end;
let identifier = Token {
kind: TokenKind::Identifier,
start: start as u32,
end: end as u32,
};
*dst = if end - start == 3
&& bytes[start..end].eq_ignore_ascii_case(b"url")
&& self.cur_byte() == Some(b'(')
{
self.consume_url_token(start as u32).unwrap_or(identifier)
} else {
identifier
};
Ok(())
}
#[cold]
#[inline(never)]
fn read_decoded_identifier_into(&mut self, dst: &mut Token) -> Result<(), Error> {
let (token, decoded) = read_identifier(self.source, &mut self.pos)?;
let is_url = match decoded.as_deref() {
None => {
token.end - token.start == 3
&& self.source.as_bytes()[token.start as usize..token.end as usize]
.eq_ignore_ascii_case(b"url")
}
Some(text) => text.eq_ignore_ascii_case("url"),
};
if is_url
&& self.cur_byte() == Some(b'(')
&& let Some(url) = self.consume_url_token(token.start)
{
self.has_decoded = false;
*dst = url;
return Ok(());
}
match decoded {
Some(s) => {
self.decode_scratch.clear();
self.decode_scratch.push_str(&s);
self.has_decoded = true;
}
None => self.has_decoded = false,
}
*dst = token;
Ok(())
}
fn consume_url_token(&mut self, url_start: u32) -> Option<Token> {
let after_paren = self.pos + 1; if url_arg_is_quoted(self.source, after_paren) {
return None;
}
let j = url_token_close(self.source.as_bytes(), after_paren).unwrap_or(self.source.len());
self.pos = j;
Some(Token {
kind: TokenKind::Url,
start: url_start,
end: j as u32,
})
}
#[inline]
pub(crate) fn next_token_into(&mut self, dst: &mut Token) -> Result<(), Error> {
self.next_token_into_local(dst)
.map_err(|err| self.host_err(err))
}
pub(crate) fn next_token(&mut self) -> Result<Token, Error> {
let mut token = Token {
kind: TokenKind::Eof,
start: 0,
end: 0,
};
self.next_token_into(&mut token)?;
Ok(token)
}
#[inline(never)]
fn next_token_into_local(&mut self, dst: &mut Token) -> Result<(), Error> {
self.has_decoded = false;
let bytes = self.bytes;
let start = self.pos;
let Some(&b) = bytes.get(start) else {
*dst = Token {
kind: TokenKind::Eof,
start: start as u32,
end: start as u32,
};
return Ok(());
};
macro_rules! single_byte_token {
($kind:expr) => {{
self.pos = start + 1;
$kind
}};
}
let kind = match b {
b'\t' | b'\n' | 0x0B | 0x0C | b'\r' | b' ' => {
let end = ascii_whitespace_run_end(bytes, start + 1);
if bytes.get(end).is_some_and(|b| !b.is_ascii()) {
return self.next_token_dispatch(dst);
}
self.pos = end;
TokenKind::Whitespace
}
b'/' if bytes.get(start + 1) == Some(&b'*') => return self.next_token_dispatch(dst),
b'"' | b'\'' | b'0'..=b'9' => return self.next_token_dispatch(dst),
b'.' if bytes.get(start + 1).is_some_and(u8::is_ascii_digit) => {
return self.next_token_dispatch(dst);
}
b'-' | b'+' if sign_opens_number(bytes, start) => {
return self.next_token_dispatch(dst);
}
b'{' => single_byte_token!(TokenKind::LeftBrace),
b'}' => single_byte_token!(TokenKind::RightBrace),
b'[' => single_byte_token!(TokenKind::LeftBracket),
b']' => single_byte_token!(TokenKind::RightBracket),
b'(' => single_byte_token!(TokenKind::LeftParen),
b')' => single_byte_token!(TokenKind::RightParen),
b':' => single_byte_token!(TokenKind::Colon),
b';' => single_byte_token!(TokenKind::Semicolon),
b',' => single_byte_token!(TokenKind::Comma),
b'.' => single_byte_token!(TokenKind::Dot),
b'#' => single_byte_token!(TokenKind::Hash),
b'>' => single_byte_token!(TokenKind::GreaterThan),
b'<' => single_byte_token!(TokenKind::LessThan),
b'+' => single_byte_token!(TokenKind::Plus),
b'~' => single_byte_token!(TokenKind::Tilde),
b'*' => single_byte_token!(TokenKind::Asterisk),
b'&' => single_byte_token!(TokenKind::Ampersand),
b'@' => single_byte_token!(TokenKind::AtSign),
b'/' => single_byte_token!(TokenKind::Slash),
b'=' => single_byte_token!(TokenKind::Equals),
b'%' => single_byte_token!(TokenKind::Percent),
b'^' => single_byte_token!(TokenKind::Caret),
b'?' => single_byte_token!(TokenKind::Question),
b'$' => return self.next_token_dispatch(dst),
b'!' => single_byte_token!(TokenKind::Bang),
b'|' => {
if bytes.get(start + 1) == Some(&b'|') {
self.pos = start + 2;
TokenKind::ColumnCombinator
} else {
single_byte_token!(TokenKind::Pipe)
}
}
b'u' | b'U' => return self.next_token_dispatch(dst),
b'a'..=b'z' | b'A'..=b'Z' | b'-' | b'_' => {
let from = start + 1;
let Some(chunk) = bytes.get(from..from + IDENT_CHUNK) else {
return self.next_token_dispatch(dst);
};
let Some(i) = ascii_identifier_chunk_stop(chunk) else {
return self.finish_long_identifier_into(dst, from);
};
let end = from + i;
if !run_stop_ends_identifier(bytes.get(end).copied()) {
return self.read_decoded_identifier_into(dst);
}
self.pos = end;
TokenKind::Identifier
}
b'\\' => return self.read_decoded_identifier_into(dst),
_ => return self.next_token_dispatch(dst),
};
*dst = Token {
kind,
start: start as u32,
end: self.pos as u32,
};
Ok(())
}
#[inline(never)]
fn finish_long_identifier_into(&mut self, dst: &mut Token, from: usize) -> Result<(), Error> {
let start = self.pos;
let end = ascii_identifier_run_end(self.bytes, from + IDENT_CHUNK);
if !run_stop_ends_identifier(self.bytes.get(end).copied()) {
return self.read_decoded_identifier_into(dst);
}
self.pos = end;
*dst = Token {
kind: TokenKind::Identifier,
start: start as u32,
end: end as u32,
};
Ok(())
}
#[inline(never)]
fn next_token_dispatch(&mut self, dst: &mut Token) -> Result<(), Error> {
let bytes = self.bytes;
let start = self.pos;
let first = bytes[start];
let kind = match first {
_ if is_ascii_css_whitespace(first) => {
let end = ascii_whitespace_run_end(bytes, start + 1);
debug_assert!(bytes.get(end).is_some_and(|b| !b.is_ascii()));
self.pos = non_ascii_whitespace_run_end(self.source, end);
TokenKind::Whitespace
}
b'/' => {
debug_assert_eq!(bytes.get(start + 1), Some(&b'*'));
*dst = read_comment(self.source, &mut self.pos)?;
return Ok(());
}
b'"' | b'\'' => {
*dst = read_string(self.source, &mut self.pos, first as char)?;
return Ok(());
}
b'0'..=b'9' | b'.' | b'+' => {
debug_assert!(
first.is_ascii_digit()
|| (first == b'.' && bytes.get(start + 1).is_some_and(u8::is_ascii_digit))
|| (first == b'+' && sign_opens_number(bytes, start))
);
read_number_into(self.source, &mut self.pos, dst);
return Ok(());
}
b'-' if sign_opens_number(bytes, start) => {
read_number_into(self.source, &mut self.pos, dst);
return Ok(());
}
b'$' if self.peek_char(1).is_some_and(is_identifier_start) => {
return self.identifier_into(dst);
}
b'$' => {
self.pos = start + 1;
TokenKind::Dollar
}
_ if is_ascii_identifier_start(first) => {
debug_assert!(first | 0x20 == b'u' || bytes.len() - start <= IDENT_CHUNK);
return self.identifier_into(dst);
}
_ if first < 0x80 => {
return Err(Error::lex(
format!("Unexpected character in CSS: '{}'", first as char),
start,
));
}
_ => match self.current_char() {
Some(ch) if is_identifier_start(ch) => return self.identifier_into(dst),
Some(ch) if ch.is_whitespace() => {
self.pos = non_ascii_whitespace_run_end(self.source, start);
TokenKind::Whitespace
}
Some(ch) => {
return Err(Error::lex(
format!("Unexpected character in CSS: '{ch}'"),
start,
));
}
None => TokenKind::Eof,
},
};
*dst = Token {
kind,
start: start as u32,
end: self.pos as u32,
};
Ok(())
}
}
const ASCII_WHITESPACE_LUT: [bool; 256] = {
let mut t = [false; 256];
let mut i = 0;
while i < 256 {
t[i] = is_ascii_css_whitespace(i as u8);
i += 1;
}
t
};
#[inline]
fn ascii_whitespace_run_end(bytes: &[u8], from: usize) -> usize {
let mut pos = from;
while pos < bytes.len() && ASCII_WHITESPACE_LUT[bytes[pos] as usize] {
pos += 1;
}
pos
}
#[inline]
fn sign_opens_number(bytes: &[u8], sign: usize) -> bool {
match bytes.get(sign + 1) {
Some(b) if b.is_ascii_digit() => true,
Some(b'.') => bytes.get(sign + 2).is_some_and(u8::is_ascii_digit),
_ => false,
}
}
#[cold]
#[inline(never)]
fn non_ascii_whitespace_run_end(source: &str, mut pos: usize) -> usize {
let bytes = source.as_bytes();
loop {
match bytes.get(pos) {
Some(&b) if is_ascii_css_whitespace(b) => pos += 1,
Some(&b) if b.is_ascii() => return pos,
Some(_) => match source[pos..].chars().next() {
Some(ch) if ch.is_whitespace() && !is_non_ascii_identifier_codepoint(ch) => {
pos += ch.len_utf8();
}
_ => return pos,
},
None => return pos,
}
}
}
#[inline]
pub(crate) const fn is_ascii_css_whitespace(b: u8) -> bool {
matches!(b, b'\t' | b'\n' | 0x0B | 0x0C | b'\r' | b' ')
}
pub(crate) fn url_arg_is_quoted(source: &str, after_paren: usize) -> bool {
let mut i = after_paren;
while let Some(ch) = source[i..].chars().next() {
if ch.is_whitespace() {
i += ch.len_utf8();
} else {
break;
}
}
matches!(source[i..].chars().next(), Some('"' | '\''))
}
pub(crate) fn url_token_close(bytes: &[u8], after_paren: usize) -> Option<usize> {
let len = bytes.len();
let mut j = after_paren;
loop {
while j < len && bytes[j] != b'\\' && bytes[j] != b')' {
j += 1;
}
if j >= len {
return None; }
if bytes[j] == b')' {
return Some(j + 1);
}
j += 1;
if j < len {
j += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn reference_run_end(source: &str, mut pos: usize) -> usize {
while let Some(ch) = source[pos..].chars().next() {
let is_run = if ch.is_ascii() {
is_ascii_css_whitespace(ch as u8)
} else {
ch.is_whitespace() && !is_non_ascii_identifier_codepoint(ch)
};
if !is_run {
break;
}
pos += ch.len_utf8();
}
pos
}
fn followers() -> Vec<String> {
let mut out: Vec<String> = (0u32..=0xFF)
.map(|c| char::from_u32(c).expect("a Latin-1 code point").to_string())
.collect();
for lead in 0xC4u8..=0xF4 {
let bytes: &[u8] = match lead {
0xE0 => &[0xE0, 0xA0, 0x80],
0xE1..=0xEF => &[lead, 0x80, 0x80],
0xF0 => &[0xF0, 0x90, 0x80, 0x80],
0xF1..=0xF4 => &[lead, 0x80, 0x80, 0x80],
_ => &[lead, 0x80],
};
let bytes = bytes.to_vec();
out.push(String::from_utf8(bytes).expect("a valid UTF-8 sequence"));
}
for c in [
0x1680, 0x2000, 0x200A, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000, 0xFEFF, 0x1F600,
] {
out.push(char::from_u32(c).expect("a code point").to_string());
}
out
}
#[test]
fn whitespace_runs_end_where_the_class_ends() {
let members = [" ", "\t", "\n", "\r", "\u{b}", "\u{c}", "\r\n", "\u{85}"];
let mut followers = followers();
followers.push(String::new());
let mut graded = 0;
for len in 1..=9 {
for rot in 0..members.len() {
let run: String = (0..len)
.map(|i| members[(rot + i * 3) % members.len()])
.collect();
for follower in &followers {
for prefix in ["", "a", "a{b:"] {
let tail = if follower.is_empty() { "" } else { "x" };
let source = format!("{prefix}{run}{follower}{tail}");
let mut lexer = Lexer::at_offset(&source, 0);
let token = loop {
let token = lexer.next_token().expect("the prefix lexes");
if token.start as usize >= prefix.len() {
break token;
}
};
assert_eq!(token.start as usize, prefix.len(), "{source:?}");
assert_eq!(token.kind, TokenKind::Whitespace, "{source:?}");
assert_eq!(
token.end as usize,
reference_run_end(&source, prefix.len()),
"{source:?}"
);
graded += 1;
}
}
}
}
assert!(graded > 60_000, "{graded}");
}
#[test]
fn every_identifier_length_ends_where_its_run_does() {
let class: Vec<u8> = (b'a'..=b'z')
.chain(b'A'..=b'Z')
.chain(b'0'..=b'9')
.chain([b'-', b'_'])
.collect();
let stops = [
("", 0),
(" ", 0),
(";", 0),
(":", 0),
("(", 0),
(")", 0),
(",", 0),
(".", 0),
("\u{7f}", 0),
("é", 2),
("\u{a0}", 2),
("\\72 ", 4),
("\\72;", 3),
];
let mut graded = 0;
for len in 1..=26usize {
for lead in [b'x', b'u', b'U', b'-', b'_', b'Q'] {
for (k, &c) in class.iter().enumerate() {
let mut name = vec![b'e'; len];
name[0] = lead;
if len > 1 {
name[1 + k % (len - 1)] = c;
}
if lead == b'-' && len > 1 && name[1].is_ascii_digit() {
name[1] = b'-';
}
let name = String::from_utf8(name).expect("ASCII");
if name.eq_ignore_ascii_case("url") {
continue;
}
for (stop, taken) in stops {
for prefix in ["", "a{b:"] {
for tail in ["", " x", " padding-past-a-chunk;"] {
if stop.is_empty() && !tail.is_empty() {
continue;
}
let source = format!("{prefix}{name}{stop}{tail}");
let mut lexer = Lexer::at_offset(&source, 0);
let token = loop {
let token = lexer.next_token().expect("the prefix lexes");
if token.start as usize >= prefix.len() {
break token;
}
};
assert_eq!(token.kind, TokenKind::Identifier, "{source:?}");
assert_eq!(
(token.start as usize, token.end as usize),
(prefix.len(), prefix.len() + name.len() + taken),
"{source:?}"
);
graded += 1;
}
}
}
}
}
}
assert!(graded > 100_000, "{graded}");
}
#[test]
fn names_route_to_the_url_reading_their_decoded_value_calls_for() {
let symbols = [
("u", "u"),
("U", "U"),
("r", "r"),
("R", "R"),
("l", "l"),
("L", "L"),
("x", "x"),
("-", "-"),
("_", "_"),
("\\72", "r"),
("é", "é"),
];
let followers = [
("(", false),
("(\"", true),
("( '", true),
("(a) b", false),
("", false),
(" ", false),
("\\", false),
];
let mut graded = 0;
for len in 1..=4u32 {
for mut index in 0..symbols.len().pow(len) {
let mut name = String::new();
let mut decoded = String::new();
let mut ends_in_hex_escape = false;
for _ in 0..len {
let (spelling, value) = symbols[index % symbols.len()];
index /= symbols.len();
name.push_str(spelling);
decoded.push_str(value);
ends_in_hex_escape = spelling.starts_with('\\');
}
for (follower, quoted) in followers {
let source = format!("{name}{follower}");
let is_url =
decoded.eq_ignore_ascii_case("url") && follower.starts_with('(') && !quoted;
let (kind, end) = if is_url {
let close = source[name.len()..].find(')');
let end = close.map_or(source.len(), |close| name.len() + close + 1);
(TokenKind::Url, end)
} else {
let terminator = ends_in_hex_escape && follower.starts_with(' ');
(TokenKind::Identifier, name.len() + usize::from(terminator))
};
let token = Lexer::at_offset(&source, 0)
.next_token()
.unwrap_or_else(|err| panic!("{source:?}: {err:?}"));
assert_eq!(token.kind, kind, "{source:?}");
assert_eq!((token.start, token.end as usize), (0, end), "{source:?}");
graded += 1;
}
}
}
assert!(graded > 100_000, "{graded}");
}
}