pub(super) mod colors;
pub(crate) mod cursor;
pub(super) mod dimensions;
pub(super) mod functions;
pub(super) mod lists;
pub(crate) mod operators;
pub(crate) mod parser;
pub(crate) mod scan;
pub(super) mod strings;
use crate::ast::CssValue;
use crate::escapes::{ends_with_live_hex_escape, trim_end_preserving_escape, trim_start_css};
use crate::parser::value::lists::ValueSeparator;
use bumpalo::Bump;
use tsv_lang::Span;
use colors::{parse_color, parse_color_function};
use dimensions::parse_dimension;
use functions::parse_function_arguments;
use strings::parse_string_literal;
#[expect(clippy::too_many_arguments)]
pub(super) fn parse_value_from_source<'arena>(
source: &str,
source_relative_span: Span,
base_offset: u32,
class: Option<ValueSeparator>,
group_closes_value: bool,
colon_is_operator: bool,
room: u32,
arena: &'arena Bump,
) -> Result<CssValue<'arena>, u32> {
let value_str = source_relative_span.extract(source);
let absolute_start = base_offset + source_relative_span.start;
let Some((trimmed, leading)) = locate_value(value_str) else {
return Ok(CssValue::Identifier {
span: Span {
start: absolute_start,
end: base_offset + source_relative_span.end,
},
});
};
let start = absolute_start + leading as u32;
let absolute_span = Span {
start,
end: start + trimmed.len() as u32,
};
let (class, group_closes_text) = if leading == 0 && trimmed.len() == value_str.len() {
(class, group_closes_value)
} else {
(None, false)
};
let parser = if colon_is_operator {
parser::ValueParser::new_custom_property(trimmed, absolute_span)
} else {
parser::ValueParser::new(trimmed, absolute_span)
};
if trimmed.len() <= room as usize {
return Ok(parser.parse_scanned(arena, class, group_closes_text));
}
FunctionLevels::within(room, || {
parser.parse_scanned(arena, class, group_closes_text)
})
}
#[derive(Clone, Copy)]
struct FunctionLevels {
room: u32,
refused_at: u32,
}
thread_local! {
static FUNCTION_LEVELS: std::cell::Cell<FunctionLevels> = const {
std::cell::Cell::new(FunctionLevels::UNBOUNDED)
};
}
impl FunctionLevels {
const UNBOUNDED: FunctionLevels = FunctionLevels {
room: u32::MAX,
refused_at: u32::MAX,
};
fn within<T>(room: u32, parse: impl FnOnce() -> T) -> Result<T, u32> {
struct Restore(FunctionLevels);
impl Drop for Restore {
fn drop(&mut self) {
FUNCTION_LEVELS.set(self.0);
}
}
let _restore = Restore(FUNCTION_LEVELS.replace(FunctionLevels {
room,
refused_at: u32::MAX,
}));
let value = parse();
let refused_at = FUNCTION_LEVELS.get().refused_at;
if refused_at == u32::MAX {
Ok(value)
} else {
Err(refused_at)
}
}
#[inline]
fn enter(open: u32) -> bool {
let mut levels = FUNCTION_LEVELS.get();
if levels.room == 0 {
if levels.refused_at == u32::MAX {
levels.refused_at = open;
FUNCTION_LEVELS.set(levels);
}
return false;
}
levels.room -= 1;
FUNCTION_LEVELS.set(levels);
true
}
#[inline]
fn leave() {
let mut levels = FUNCTION_LEVELS.get();
levels.room += 1;
FUNCTION_LEVELS.set(levels);
}
}
fn locate_value(value_str: &str) -> Option<(&str, usize)> {
let bytes = value_str.as_bytes();
let settled = |b: u8| !b.is_ascii_whitespace();
if let (Some(&first), Some(&last)) = (bytes.first(), bytes.last())
&& settled(first)
&& settled(last)
{
return Some((value_str, 0));
}
let after_leading = trim_start_css(value_str);
let trimmed = trim_end_preserving_escape(after_leading);
if trimmed.is_empty() {
return None;
}
Some((trimmed, value_str.len() - after_leading.len()))
}
#[inline]
pub(crate) fn parse_single_value<'arena>(
s: &str,
span: Span,
colon_is_operator: bool,
head_welds: bool,
group_closes_run: bool,
arena: &'arena Bump,
) -> CssValue<'arena> {
if let Some(paren_pos) = plain_function_paren(s.as_bytes())
&& let Some((name, args)) = plain_function_parts(s, paren_pos, group_closes_run)
{
debug_assert!(
operators::split_value_run(s, colon_is_operator, head_welds, arena).is_none(),
"a plain-name function run splits: {s:?}"
);
return parse_function_leaf(s, span, paren_pos, name, args, arena);
}
if let Some(tokens) = operators::split_value_run(s, colon_is_operator, head_welds, arena) {
let mut values = bumpalo::collections::Vec::with_capacity_in(tokens.len(), arena);
for token in tokens {
let member = &s[token.start..token.end];
let member_span = Span {
start: span.start + token.start as u32,
end: span.start + token.end as u32,
};
values.push(if token.is_operator {
CssValue::Operator { span: member_span }
} else {
parse_leaf_value(member, member_span, arena)
});
}
return CssValue::List {
values: values.into_bump_slice(),
span,
};
}
parse_leaf_value(s, span, arena)
}
fn parse_leaf_value<'arena>(s: &str, span: Span, arena: &'arena Bump) -> CssValue<'arena> {
if let Some(val) = parse_string_literal(s, span, arena) {
return val;
}
let bytes = s.as_bytes();
debug_assert!(
bytes.last() == Some(&b')')
|| bytes
.iter()
.position(|&b| b == b'(')
.and_then(|paren_pos| extract_function_parts(s, paren_pos))
.is_none(),
"a value not ending in `)` was read as a function: {s:?}"
);
if bytes.last() == Some(&b')')
&& let Some(paren_pos) = bytes.iter().position(|&b| b == b'(')
&& let Some((name, args)) = extract_function_parts(s, paren_pos)
{
return parse_function_leaf(s, span, paren_pos, name, args, arena);
}
if let Some(color) = parse_color(s) {
return CssValue::Color { color, span };
}
if let Some(dim) = parse_dimension(s, span) {
return dim;
}
CssValue::Identifier { span }
}
pub(in crate::parser) fn plain_function_paren(bytes: &[u8]) -> Option<usize> {
if bytes.last() != Some(&b')') {
return None;
}
if bytes[0] == b'(' {
return Some(0);
}
let head = usize::from(bytes[0] == b'-');
if !bytes
.get(head)
.is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_')
{
return None;
}
for (i, &b) in bytes.iter().enumerate().skip(head + 1) {
match b {
b'(' => return Some(i),
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-' => {}
_ => return None,
}
}
None
}
fn plain_function_parts(s: &str, paren_pos: usize, group_closes_run: bool) -> Option<(&str, &str)> {
let close = if group_closes_run {
debug_assert_eq!(
scan::matching_close_paren(s, paren_pos),
Some(s.len() - 1),
"the declaration scan closed {s:?}'s group at its end, the paren scan did not"
);
s.len() - 1
} else {
scan::matching_close_paren(s, paren_pos)?
};
let parts = (close == s.len() - 1).then(|| (&s[..paren_pos], &s[paren_pos + 1..close]));
debug_assert_eq!(
parts,
extract_function_parts(s, paren_pos),
"a plain-name function read two ways: {s:?}"
);
parts
}
fn parse_function_leaf<'arena>(
s: &str,
span: Span,
paren_pos: usize,
name: &str,
args: &str,
arena: &'arena Bump,
) -> CssValue<'arena> {
if let Some(color) = parse_color_function(name, args) {
return CssValue::Color { color, span };
}
let args_start = paren_pos + 1;
let args_span = Span {
start: span.start + args_start as u32,
end: span.start + args_start as u32 + args.len() as u32,
};
let name_start = name.as_ptr() as usize - s.as_ptr() as usize;
let name_span = Span {
start: span.start + name_start as u32,
end: span.start + (name_start + name.len()) as u32,
};
if !FunctionLevels::enter(span.start + paren_pos as u32) {
return CssValue::Identifier { span };
}
let args = parse_function_arguments(args, args_span, arena);
FunctionLevels::leave();
CssValue::Function {
name_span,
args,
span,
}
}
fn extract_function_parts(s: &str, paren_pos: usize) -> Option<(&str, &str)> {
let name_region = trim_start_css(&s[..paren_pos]);
let mut name_part = trim_end_preserving_escape(name_region);
if name_part.len() < name_region.len()
&& matches!(name_region.as_bytes()[name_part.len()], b' ' | b'\t')
&& ends_with_live_hex_escape(name_part)
{
name_part = &name_region[..=name_part.len()];
}
if !name_part.is_empty() && !is_function_name(name_part) {
return None;
}
let close_pos = scan::matching_close_paren(s, paren_pos)?;
if close_pos != s.len() - 1 {
return None;
}
let args = &s[paren_pos + 1..close_pos];
Some((name_part, args))
}
fn is_function_name(name: &str) -> bool {
let name = match name.strip_prefix('+') {
Some(rest) if !rest.is_empty() => rest,
_ => name,
};
if name.chars().all(is_value_word_code_point) {
return true;
}
escaped_name_is_value_word(name)
}
fn is_value_word_code_point(c: char) -> bool {
if c.is_ascii() {
return !matches!(
c,
' ' | '\t' | '\n' | '\r' | '\u{b}' | '\u{c}'
| ','
| ':'
| '@'
| '*'
| '+'
| '\''
| '"'
| '{'
| '}'
| ';'
| '('
| '\\'
| ')'
);
}
crate::lexer::is_non_ascii_identifier_codepoint(c)
}
#[cold]
#[inline(never)]
fn escaped_name_is_value_word(name: &str) -> bool {
let bytes = name.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\' {
let Some(len) = crate::escapes::escape_len(name, i) else {
return false;
};
i += len;
continue;
}
let Some(ch) = name[i..].chars().next() else {
return false;
};
if !is_value_word_code_point(ch) {
return false;
}
i += ch.len_utf8();
}
true
}
#[cfg(test)]
mod function_name_tests {
use super::{extract_function_parts, is_function_name};
#[test]
fn an_escape_is_word_content() {
for name in [
"fn", "a-b_1", "fé", r"\66 n", r"v\61 r", r"a\28 b", r"a\29 b", r"\31 fn", r"a\)b",
r#"a\"b"#, r"a\'b", r"a\\b", r"a\ b", r"\-fn",
] {
assert!(is_function_name(name), "{name:?} is a word");
}
}
#[test]
fn the_word_class_is_exactly_this_exclusion_set() {
const EXCLUDED: &[char] = &[
' ', ',', ':', '@', '*', '+', '\'', '"', '{', '}',
';', '(',
')',
];
for c in (0x20u8..=0x7e).map(char::from) {
if c == '\\' {
continue;
}
let want = !EXCLUDED.contains(&c);
for name in [format!("aa{c}bb"), format!("{c}aa"), format!("aa{c}")] {
let want = want || (c == '+' && name == "+aa");
assert_eq!(
is_function_name(&name),
want,
"{name:?}: expected word content = {want}"
);
}
}
assert!(is_function_name("+aa"), "a welded `+` heads a name");
assert!(
!is_function_name("+"),
"a lone `+` is an operator, never a name"
);
for c in ['\t', '\n', '\r', '\u{b}', '\u{c}'] {
assert!(
!is_function_name(&format!("aa{c}bb")),
"{c:?} is whitespace"
);
}
assert!(is_function_name("a\u{b0}"), "U+00B0 is at or above U+00A0");
assert!(!is_function_name("a\u{85}"), "U+0085 is below it");
}
#[test]
fn the_exclusion_set_reasons() {
for name in ["'x'fn", "\"x\"fn", "a b", "a,b", "a:b", "a@b", "a*b", "a+b"] {
assert!(!is_function_name(name), "{name:?} is printed separately");
}
for name in ["a;b", "a{b", "a}b", "a{1.50}c"] {
assert!(!is_function_name(name), "{name:?} is read across");
}
assert!(!is_function_name("a)b"), "a stray close paren");
for name in ["a[b]c", "a[1.50]c", "a[b]"] {
assert!(is_function_name(name), "{name:?} is a word");
}
}
#[test]
fn the_first_paren_byte_locates_the_function_or_refuses_it() {
fn parts(s: &str) -> Option<(&str, &str)> {
let open = s.bytes().position(|b| b == b'(')?;
extract_function_parts(s, open)
}
assert_eq!(parts(r"\66 n(.10)"), Some((r"\66 n", ".10")));
assert_eq!(parts(r"a\29 b(.10)"), Some((r"a\29 b", ".10")));
assert_eq!(parts(r"a\)b(.10)"), Some((r"a\)b", ".10")));
assert_eq!(parts(r"a\(b(.10)"), None);
assert_eq!(parts(r"a\(b(c))"), None);
assert_eq!(parts("'x'fn(.10)"), None);
assert_eq!(parts("fn(a)b"), None);
assert_eq!(parts("fn(a"), None);
}
}
#[cfg(test)]
mod value_span_tests {
use super::parse_value_from_source;
use bumpalo::Bump;
use tsv_lang::Span;
fn value_span(source: &str, start: u32, end: u32) -> Span {
let arena = Bump::new();
parse_value_from_source(
source,
Span { start, end },
0,
None,
false,
false,
256,
&arena,
)
.expect("within the room")
.span()
}
#[test]
fn trims_the_span_the_same_either_way() {
assert_eq!(value_span("red", 0, 3), Span { start: 0, end: 3 });
assert_eq!(value_span("a red b", 2, 5), Span { start: 2, end: 5 });
for (source, span, want) in [
(" red", (0, 4), (1, 4)),
("red ", (0, 4), (0, 3)),
(" red ", (0, 7), (2, 5)),
("\tred\t", (0, 5), (1, 4)),
("\nred\n", (0, 5), (1, 4)),
("\rred\r", (0, 5), (1, 4)),
("\x0cred\x0c", (0, 5), (1, 4)),
] {
assert_eq!(
value_span(source, span.0, span.1),
Span {
start: want.0,
end: want.1
},
"value span for {source:?}"
);
}
}
#[test]
fn non_css_whitespace_is_content() {
for (source, span) in [
("\u{a0}red\u{a0}", (0, 7)), ("\u{3000}red\u{3000}", (0, 9)), ("\x0bred\x0b", (0, 5)), ("é", (0, 2)),
] {
assert_eq!(
value_span(source, span.0, span.1),
Span {
start: span.0,
end: span.1
},
"value span for {source:?}"
);
}
}
#[test]
fn ascii_space_trims_beside_a_kept_nbsp() {
assert_eq!(value_span(" \u{a0}red", 0, 6), Span { start: 1, end: 6 });
}
#[test]
fn an_escapes_payload_stays_in_the_span() {
assert_eq!(value_span(r"50px\ ", 0, 6), Span { start: 0, end: 6 });
assert_eq!(value_span(r"50px\ ", 0, 8), Span { start: 0, end: 6 });
assert_eq!(value_span(r"50px\\ ", 0, 7), Span { start: 0, end: 6 });
}
}
#[cfg(test)]
mod function_levels_tests {
use super::{FUNCTION_LEVELS, FunctionLevels};
#[test]
fn a_panic_leaves_the_count_unbounded() {
let caught = std::panic::catch_unwind(|| {
FunctionLevels::within(3, || {
assert!(FunctionLevels::enter(0));
panic!("a parse panicked mid-function");
})
});
assert!(caught.is_err());
let levels = FUNCTION_LEVELS.get();
assert_eq!(
(levels.room, levels.refused_at),
(u32::MAX, u32::MAX),
"the count it found is restored"
);
}
}