use crate::{comment_policy, LegalComments, ParserError, Result};
use std::borrow::Cow;
use std::collections::HashSet;
use std::fmt;
use webui_tokens::CssFallbackChain;
pub struct CssParser;
type CssScanResult = (
HashSet<String>,
HashSet<String>,
Vec<CssComment>,
Vec<CssFallbackChain>,
);
type CssRequirementsAndStripped<'a> = (
HashSet<String>,
HashSet<String>,
Vec<CssFallbackChain>,
Cow<'a, str>,
);
type CssRequirementsAndComments = (
HashSet<String>,
HashSet<String>,
Vec<CssFallbackChain>,
Vec<CssComment>,
);
fn css_loc(source: &str, offset: usize) -> String {
let (line, column) = crate::diagnostic::line_column(source, offset);
format!("at line {line}, column {column}")
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct CssComment {
pub start_byte: usize,
pub end_byte: usize,
pub preserve: bool,
}
impl fmt::Debug for CssParser {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CssParser").finish()
}
}
impl CssParser {
pub fn new() -> Self {
Self
}
pub fn extract_tokens(&mut self, css_content: &str) -> Result<HashSet<String>> {
let (mut tokens, definitions, _comments, _requirements) =
scan_css(css_content, LegalComments::Inline, false)?;
tokens.retain(|t| !definitions.contains(t));
Ok(tokens)
}
pub fn extract_definitions(&mut self, css_content: &str) -> Result<HashSet<String>> {
let (_tokens, definitions, _comments, _requirements) =
scan_css(css_content, LegalComments::Inline, false)?;
Ok(definitions)
}
pub fn extract_tokens_and_definitions(
&mut self,
css_content: &str,
) -> Result<(HashSet<String>, HashSet<String>)> {
let (mut tokens, definitions, _comments, _requirements) =
scan_css(css_content, LegalComments::Inline, false)?;
tokens.retain(|t| !definitions.contains(t));
Ok((tokens, definitions))
}
pub(crate) fn extract_tokens_definitions_requirements_and_strip_comments<'a>(
&mut self,
css_content: &'a str,
legal_comments: LegalComments,
) -> Result<CssRequirementsAndStripped<'a>> {
let (mut tokens, definitions, comments, requirements) =
scan_css(css_content, legal_comments, true)?;
tokens.retain(|t| !definitions.contains(t));
let mut comment_ranges = removable_ranges(&comments);
let stripped = comment_policy::strip_ranges(css_content, comment_ranges.as_mut_slice());
Ok((tokens, definitions, requirements, stripped))
}
pub(crate) fn extract_tokens_definitions_requirements_and_comments(
&mut self,
css_content: &str,
legal_comments: LegalComments,
) -> Result<CssRequirementsAndComments> {
let (mut tokens, definitions, mut comments, requirements) =
scan_css(css_content, legal_comments, true)?;
tokens.retain(|t| !definitions.contains(t));
comments.sort_unstable_by_key(|comment| comment.start_byte);
Ok((tokens, definitions, requirements, comments))
}
}
fn scan_css(
source: &str,
legal_comments: LegalComments,
collect_comments: bool,
) -> Result<CssScanResult> {
let bytes = source.as_bytes();
let mut tokens = HashSet::new();
let mut definitions = HashSet::new();
let mut comments = Vec::new();
let mut requirements = Vec::new();
let mut index = 0usize;
let mut quote: u8 = 0;
let mut brace_depth = 0usize;
let mut paren_depth = 0usize;
let mut bracket_depth = 0usize;
while index < bytes.len() {
if quote != 0 {
if bytes[index] == b'\\' {
index = (index + 2).min(bytes.len());
continue;
}
if bytes[index] == quote {
quote = 0;
}
index += 1;
continue;
}
match bytes[index] {
b'"' | b'\'' => {
quote = bytes[index];
index += 1;
}
b'/' if index + 1 < bytes.len() && bytes[index + 1] == b'*' => {
let Some(offset) = source[index + 2..].find("*/") else {
return Err(ParserError::Css(format!(
"Unterminated CSS block comment {}. Close the comment with `*/` before building.",
css_loc(source, index)
)));
};
let end = index + 2 + offset + 2;
if collect_comments {
let comment = &source[index..end];
comments.push(CssComment {
start_byte: index,
end_byte: end,
preserve: comment_policy::should_preserve_css_comment(
comment,
legal_comments,
),
});
}
index = end;
}
b'/' if comment_policy::is_css_line_comment_start(source, index) => {
let end = comment_policy::find_css_line_comment_end(source, index + 2);
if collect_comments {
let comment = &source[index..end];
comments.push(CssComment {
start_byte: index,
end_byte: end,
preserve: comment_policy::should_preserve_css_comment(
comment,
legal_comments,
),
});
}
index = end;
}
b'-' if bytes.get(index + 1) == Some(&b'-') => {
if let Some((name, end)) = parse_custom_property_name(source, index) {
if is_custom_property_definition(source, end) {
definitions.insert(name.to_string());
}
index = end;
} else {
index += 1;
}
}
b'v' if source[index..].starts_with("var(") => {
index = scan_var_call(source, index, &mut tokens, &mut requirements)?;
}
b'{' => {
brace_depth += 1;
index += 1;
}
b'}' => {
if brace_depth == 0 {
return Err(ParserError::Css(format!(
"Unexpected CSS closing brace {}. Remove the extra `}}` or add a matching opening `{{`.",
css_loc(source, index)
)));
}
brace_depth -= 1;
index += 1;
}
b'(' => {
paren_depth += 1;
index += 1;
}
b')' => {
if paren_depth == 0 {
return Err(ParserError::Css(format!(
"Unexpected CSS closing parenthesis {}. Remove the extra `)` or add a matching opening `(`.",
css_loc(source, index)
)));
}
paren_depth -= 1;
index += 1;
}
b'[' => {
bracket_depth += 1;
index += 1;
}
b']' => {
if bracket_depth == 0 {
return Err(ParserError::Css(format!(
"Unexpected CSS closing bracket {}. Remove the extra `]` or add a matching opening `[`.",
css_loc(source, index)
)));
}
bracket_depth -= 1;
index += 1;
}
_ => index += 1,
}
}
if quote != 0 {
return Err(ParserError::Css(format!(
"Unterminated CSS string literal. Close the `{}` quote before building.",
char::from(quote)
)));
}
if brace_depth != 0 {
return Err(ParserError::Css(
"Unterminated CSS rule block. Add the missing `}` before building.".to_string(),
));
}
if paren_depth != 0 {
return Err(ParserError::Css(
"Unterminated CSS parenthesized expression. Add the missing `)` before building."
.to_string(),
));
}
if bracket_depth != 0 {
return Err(ParserError::Css(
"Unterminated CSS bracket expression. Add the missing `]` before building.".to_string(),
));
}
Ok((tokens, definitions, comments, requirements))
}
fn scan_var_call(
source: &str,
start: usize,
tokens: &mut HashSet<String>,
requirements: &mut Vec<CssFallbackChain>,
) -> Result<usize> {
struct VarFrame<'a> {
close_depth: usize,
tokens: Vec<&'a str>,
after_fallback_comma: bool,
has_literal_fallback: bool,
fallback_has_unsafe_var: bool,
}
impl<'a> VarFrame<'a> {
fn new(close_depth: usize) -> Self {
Self {
close_depth,
tokens: Vec::new(),
after_fallback_comma: false,
has_literal_fallback: false,
fallback_has_unsafe_var: false,
}
}
fn has_safe_literal_fallback(&self) -> bool {
self.has_literal_fallback && !self.fallback_has_unsafe_var
}
}
let bytes = source.as_bytes();
let mut index = start + 4;
let mut depth = 1usize;
let mut brace_depth = 0usize;
let mut bracket_depth = 0usize;
let mut quote: u8 = 0;
let mut frames = vec![VarFrame::new(1)];
while index < bytes.len() {
if quote != 0 {
if bytes[index] == b'\\' {
index = (index + 2).min(bytes.len());
continue;
}
if bytes[index] == quote {
quote = 0;
}
index += 1;
continue;
}
match bytes[index] {
b'"' | b'\'' => {
if let Some(frame) = frames.last_mut() {
if frame.after_fallback_comma {
frame.has_literal_fallback = true;
}
}
quote = bytes[index];
index += 1;
}
b'/' if index + 1 < bytes.len() && bytes[index + 1] == b'*' => {
let Some(offset) = source[index + 2..].find("*/") else {
return Err(ParserError::Css(format!(
"Unterminated CSS block comment inside var() {}. Close the comment with `*/` before building.",
css_loc(source, index)
)));
};
index += 2 + offset + 2;
}
b'/' if comment_policy::is_css_line_comment_start(source, index) => {
index = comment_policy::find_css_line_comment_end(source, index + 2);
}
b'v' if source[index..].starts_with("var(") => {
depth += 1;
frames.push(VarFrame::new(depth));
index += 4;
}
b',' => {
if let Some(frame) = frames.last_mut() {
frame.after_fallback_comma = true;
}
index += 1;
}
b'(' => {
if let Some(frame) = frames.last_mut() {
if frame.after_fallback_comma {
frame.has_literal_fallback = true;
}
}
depth += 1;
index += 1;
}
b')' => {
let closes_var = frames
.last()
.is_some_and(|frame| frame.close_depth == depth);
depth = depth.saturating_sub(1);
index += 1;
if closes_var {
let Some(frame) = frames.pop() else {
return Err(ParserError::Css(format!(
"Unterminated CSS var() call {}. Add the missing `)` before building.",
css_loc(source, start)
)));
};
if !frames.is_empty() {
let safe_literal = frame.has_safe_literal_fallback();
if let Some(parent) = frames.last_mut() {
if parent.after_fallback_comma {
if safe_literal {
parent.has_literal_fallback = true;
} else {
parent.fallback_has_unsafe_var = true;
}
}
parent.tokens.extend(frame.tokens);
}
continue;
}
if brace_depth != 0 {
return Err(ParserError::Css(format!(
"Unterminated CSS brace expression inside var() {}. Add the missing `}}` before building.",
css_loc(source, start)
)));
}
if bracket_depth != 0 {
return Err(ParserError::Css(format!(
"Unterminated CSS bracket expression inside var() {}. Add the missing `]` before building.",
css_loc(source, start)
)));
}
if !frame.tokens.is_empty() {
let has_safe_literal_fallback = frame.has_safe_literal_fallback();
let mut chain = Vec::with_capacity(frame.tokens.len());
for token in frame.tokens {
tokens.insert(token.to_string());
if !chain.iter().any(|existing| existing == token) {
chain.push(token.to_string());
}
}
requirements.push(CssFallbackChain {
tokens: chain,
has_literal_fallback: has_safe_literal_fallback,
});
}
return Ok(index);
}
}
b'{' => {
if let Some(frame) = frames.last_mut() {
if frame.after_fallback_comma {
frame.has_literal_fallback = true;
}
}
brace_depth += 1;
index += 1;
}
b'}' => {
if brace_depth == 0 {
return Err(ParserError::Css(format!(
"Unterminated CSS var() call {}. Add the missing `)` before the closing `}}`.",
css_loc(source, start)
)));
}
brace_depth -= 1;
index += 1;
}
b'[' => {
if let Some(frame) = frames.last_mut() {
if frame.after_fallback_comma {
frame.has_literal_fallback = true;
}
}
bracket_depth += 1;
index += 1;
}
b']' => {
if bracket_depth == 0 {
return Err(ParserError::Css(format!(
"Unexpected CSS closing bracket inside var() {}. Remove the extra `]` or add a matching opening `[`.",
css_loc(source, index)
)));
}
bracket_depth -= 1;
index += 1;
}
b'-' if bytes.get(index + 1) == Some(&b'-') => {
if let Some((name, end)) = parse_custom_property_name(source, index) {
if let Some(frame) = frames.last_mut() {
frame.tokens.push(name);
}
index = end;
} else {
index += 1;
}
}
_ => {
if let Some(frame) = frames.last_mut() {
if frame.after_fallback_comma && !bytes[index].is_ascii_whitespace() {
frame.has_literal_fallback = true;
}
}
index += 1;
}
}
}
if quote != 0 {
return Err(ParserError::Css(format!(
"Unterminated CSS string literal inside var() {}. Close the `{}` quote before building.",
css_loc(source, start),
char::from(quote)
)));
}
Err(ParserError::Css(format!(
"Unterminated CSS var() call {}. Add the missing `)` before building.",
css_loc(source, start)
)))
}
fn parse_custom_property_name(source: &str, start: usize) -> Option<(&str, usize)> {
let bytes = source.as_bytes();
if bytes.get(start..start + 2) != Some(b"--") {
return None;
}
let mut end = start + 2;
while end < bytes.len()
&& (bytes[end].is_ascii_alphanumeric() || matches!(bytes[end], b'_' | b'-'))
{
end += 1;
}
(end > start + 2).then(|| (&source[start + 2..end], end))
}
fn is_custom_property_definition(source: &str, name_end: usize) -> bool {
let bytes = source.as_bytes();
let mut cursor = name_end;
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
if bytes.get(cursor) != Some(&b':') {
return false;
}
let mut before = name_end;
while before > 0
&& (bytes[before - 1].is_ascii_alphanumeric() || matches!(bytes[before - 1], b'_' | b'-'))
{
before -= 1;
}
before = skip_ws_and_block_comments_back(source, before);
before == 0 || matches!(bytes[before - 1], b'{' | b';')
}
fn skip_ws_and_block_comments_back(source: &str, mut pos: usize) -> usize {
let bytes = source.as_bytes();
loop {
while pos > 0 && bytes[pos - 1].is_ascii_whitespace() {
pos -= 1;
}
if pos >= 2 && &source[pos - 2..pos] == "*/" {
match source[..pos - 2].rfind("/*") {
Some(open) => pos = open,
None => break,
}
} else {
break;
}
}
pos
}
fn removable_ranges(comments: &[CssComment]) -> Vec<(usize, usize)> {
let mut ranges = Vec::with_capacity(comments.len());
for comment in comments {
if !comment.preserve {
ranges.push((comment.start_byte, comment.end_byte));
}
}
ranges
}
impl Default for CssParser {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
use super::*;
#[test]
fn test_extract_single_var() {
let mut parser = CssParser::new();
let tokens = parser
.extract_tokens(".btn { color: var(--colorPrimary); }")
.expect("extract_tokens failed");
assert_eq!(tokens, HashSet::from(["colorPrimary".to_string()]));
}
#[test]
fn test_extract_multiple_vars() {
let css = r#"
.btn {
color: var(--textColor);
background: var(--bgColor);
border-radius: var(--radius);
}
"#;
let mut parser = CssParser::new();
let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
assert_eq!(
tokens,
HashSet::from([
"textColor".to_string(),
"bgColor".to_string(),
"radius".to_string(),
])
);
}
#[test]
fn test_extract_nested_fallback() {
let css = ".x { color: var(--primary, var(--fallback)); }";
let mut parser = CssParser::new();
let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
assert_eq!(
tokens,
HashSet::from(["primary".to_string(), "fallback".to_string()])
);
}
#[test]
fn test_extract_deeply_nested_fallbacks() {
let css = ".x { color: var(--a, var(--b, var(--c))); }";
let mut parser = CssParser::new();
let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
assert_eq!(
tokens,
HashSet::from(["a".to_string(), "b".to_string(), "c".to_string()])
);
}
#[test]
fn test_extract_nested_fallbacks_excludes_locally_defined_tokens() {
let css = r#"
:root { --token-a: red; }
.x { --foo-bar: var(--token-a, var(--token-b, var(--token-c))); }
"#;
let mut parser = CssParser::new();
let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
assert_eq!(
tokens,
HashSet::from(["token-b".to_string(), "token-c".to_string()])
);
}
#[test]
fn test_literal_fallback_ignored() {
let css = ".x { font-size: var(--size, 16px); }";
let mut parser = CssParser::new();
let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
assert_eq!(tokens, HashSet::from(["size".to_string()]));
}
#[test]
fn test_nested_var_without_literal_keeps_chain_required() {
let css = ".x { margin: var(--gap, calc(var(--spacing-m) * 2)); }";
let (_tokens, _definitions, _comments, requirements) =
scan_css(css, LegalComments::Inline, false).expect("scan failed");
assert_eq!(requirements.len(), 1);
assert_eq!(requirements[0].tokens, vec!["gap", "spacing-m"]);
assert!(
!requirements[0].has_literal_fallback,
"calc(var(--spacing-m) * 2) is not a safe literal fallback"
);
}
#[test]
fn test_nested_var_with_literal_keeps_chain_optional() {
let css = ".x { margin: var(--gap, calc(var(--spacing-m, 1px) * 2)); }";
let (_tokens, _definitions, _comments, requirements) =
scan_css(css, LegalComments::Inline, false).expect("scan failed");
assert_eq!(requirements.len(), 1);
assert_eq!(requirements[0].tokens, vec!["gap", "spacing-m"]);
assert!(
requirements[0].has_literal_fallback,
"calc(var(--spacing-m, 1px) * 2) has a safe literal fallback"
);
}
#[test]
fn test_var_fallback_block_comment_ignored() {
let css = ".x { color: var(--primary, /* --debug-only */ red); }";
let mut parser = CssParser::new();
let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
assert_eq!(tokens, HashSet::from(["primary".to_string()]));
}
#[test]
fn test_var_fallback_line_comment_ignored() {
let css = ".x { color: var(--primary,\n // --debug-only\n red); }";
let mut parser = CssParser::new();
let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
assert_eq!(tokens, HashSet::from(["primary".to_string()]));
}
#[test]
fn test_exclude_local_definitions() {
let css = r#"
:root { --bar: 12px; }
.x { width: var(--bar); }
"#;
let mut parser = CssParser::new();
let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
assert!(
tokens.is_empty(),
"Locally defined --bar should be excluded: {tokens:?}"
);
}
#[test]
fn test_exclude_definitions_keep_unrelated_usages() {
let css = r#"
:host { --local: 5px; }
.x { color: var(--external); }
"#;
let mut parser = CssParser::new();
let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
assert_eq!(tokens, HashSet::from(["external".to_string()]));
}
#[test]
fn test_definition_after_block_comment_is_recognized() {
let css = ":root {\n /*{{{tokens.light}}}*/\n --foo-bar: 100px;\n}";
let mut parser = CssParser::new();
let defs = parser
.extract_definitions(css)
.expect("extract_definitions failed");
assert!(
defs.contains("foo-bar"),
"definition after a comment should be recognized: {defs:?}"
);
}
#[test]
fn test_definition_after_comment_excludes_matching_usage() {
let css = ":root {\n /* note */ --foo-bar: 100px;\n}\n.x { padding: var(--foo-bar); }";
let mut parser = CssParser::new();
let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
assert!(
tokens.is_empty(),
"--foo-bar is defined locally and must not be hoisted: {tokens:?}"
);
}
#[test]
fn test_empty_css() {
let mut parser = CssParser::new();
let tokens = parser.extract_tokens("").expect("extract_tokens failed");
assert!(tokens.is_empty());
}
#[test]
fn test_no_var_calls() {
let css = "body { margin: 0; color: red; }";
let mut parser = CssParser::new();
let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
assert!(tokens.is_empty());
}
#[test]
fn test_deduplicate_same_var() {
let css = r#"
.a { color: var(--shared); }
.b { background: var(--shared); }
"#;
let mut parser = CssParser::new();
let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
assert_eq!(tokens, HashSet::from(["shared".to_string()]));
}
#[test]
fn test_unterminated_var_call_returns_error() {
let css = ".bad { color: var(--dangling; } .ok { color: var(--valid); }";
let mut parser = CssParser::new();
let err = parser
.extract_tokens(css)
.expect_err("unterminated var() should fail");
assert!(matches!(err, ParserError::Css(message) if
message.contains("Unterminated CSS var() call")
));
}
#[test]
fn test_unmatched_var_fallback_delimiter_returns_error() {
let css = ".bad { color: var(--accent, [red); }";
let mut parser = CssParser::new();
let err = parser
.extract_tokens(css)
.expect_err("unmatched var() fallback delimiter should fail");
assert!(matches!(err, ParserError::Css(message) if
message.contains("Unterminated CSS bracket expression inside var()")
));
}
#[test]
fn test_balanced_var_fallback_delimiters_extract_tokens() {
let css = ".ok { color: var(--accent, color(display-p3 1 0 0 / 1)); }";
let mut parser = CssParser::new();
let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
assert_eq!(tokens, HashSet::from(["accent".to_string()]));
}
#[test]
fn test_unterminated_comment_returns_error() {
let css = ".ok { color: var(--valid); } /* .bad { color: var(--ignored); }";
let mut parser = CssParser::new();
let err = parser
.extract_tokens(css)
.expect_err("unterminated comment should fail");
assert!(matches!(err, ParserError::Css(message) if
message.contains("Unterminated CSS block comment")
));
}
#[test]
fn test_unterminated_string_returns_error() {
let css = r#".bad { content: "dangling; }"#;
let mut parser = CssParser::new();
let err = parser
.extract_tokens(css)
.expect_err("unterminated string should fail");
assert!(matches!(err, ParserError::Css(message) if
message.contains("Unterminated CSS string literal")
));
}
#[test]
fn test_comments_are_collected_and_stripped() {
let mut parser = CssParser::new();
let (_tokens, _defs, _requirements, stripped) = parser
.extract_tokens_definitions_requirements_and_strip_comments(
"/* remove */.x{color:var(--a)}/*! keep */",
LegalComments::Inline,
)
.expect("scan failed");
assert_eq!(stripped, ".x{color:var(--a)}/*! keep */");
}
}