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;
struct CssScanResult<'a> {
inherited_definitions: HashSet<&'a str>,
local_definitions: HashSet<&'a str>,
comments: Vec<CssComment>,
requirements: Vec<CssFallbackChain>,
}
impl CssScanResult<'_> {
fn into_tokens(self) -> HashSet<String> {
self.requirements
.into_iter()
.flat_map(|chain| chain.tokens)
.filter(|token| !self.inherited_definitions.contains(token.as_str()))
.collect()
}
fn definitions(&self) -> HashSet<String> {
self.inherited_definitions
.iter()
.copied()
.chain(self.local_definitions.iter().copied())
.map(str::to_string)
.collect()
}
}
#[derive(Clone, Copy, Default)]
struct CssRuleScope {
id: usize,
unconditional_group: bool,
defines_defaults: bool,
definition_start: usize,
requirement_start: usize,
}
type CssRequirementsAndStripped<'a> = (HashSet<String>, Vec<CssFallbackChain>, Cow<'a, str>);
type CssRequirementsAndComments = (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>> {
Ok(scan_css(css_content, LegalComments::Inline, false)?.into_tokens())
}
pub fn extract_definitions(&mut self, css_content: &str) -> Result<HashSet<String>> {
Ok(scan_css(css_content, LegalComments::Inline, false)?.definitions())
}
pub fn extract_tokens_and_definitions(
&mut self,
css_content: &str,
) -> Result<(HashSet<String>, HashSet<String>)> {
let scan = scan_css(css_content, LegalComments::Inline, false)?;
let definitions = scan.definitions();
Ok((scan.into_tokens(), definitions))
}
pub(crate) fn extract_definitions_requirements_and_strip_comments<'a>(
&mut self,
css_content: &'a str,
legal_comments: LegalComments,
) -> Result<CssRequirementsAndStripped<'a>> {
let scan = scan_css(css_content, legal_comments, true)?;
let mut comment_ranges = removable_ranges(&scan.comments);
let stripped = comment_policy::strip_ranges(css_content, comment_ranges.as_mut_slice());
Ok((
scan.inherited_definitions
.into_iter()
.map(str::to_string)
.collect(),
scan.requirements,
stripped,
))
}
pub(crate) fn extract_definitions_requirements_and_comments(
&mut self,
css_content: &str,
legal_comments: LegalComments,
) -> Result<CssRequirementsAndComments> {
let mut scan = scan_css(css_content, legal_comments, true)?;
scan.comments
.sort_unstable_by_key(|comment| comment.start_byte);
Ok((
scan.inherited_definitions
.into_iter()
.map(str::to_string)
.collect(),
scan.requirements,
scan.comments,
))
}
}
fn scan_css(
source: &str,
legal_comments: LegalComments,
collect_comments: bool,
) -> Result<CssScanResult<'_>> {
let bytes = source.as_bytes();
let mut inherited_definitions = HashSet::new();
let mut local_definitions = HashSet::new();
let mut active_definitions = HashSet::new();
let mut definition_stack = Vec::new();
let mut comments = Vec::new();
let mut requirements = Vec::new();
let mut requirement_scopes = Vec::new();
let mut scopes: Vec<CssRuleScope> = Vec::new();
let mut scope = CssRuleScope::default();
let mut next_scope_id = 0;
let mut prelude_start = 0;
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) {
if scope.defines_defaults {
inherited_definitions.insert(name);
} else {
local_definitions.insert(name);
if active_definitions.insert((scope.id, name)) {
definition_stack.push(name);
}
}
}
index = end;
} else {
index += 1;
}
}
b'v' if source[index..].starts_with("var(") => {
let start = requirements.len();
index = scan_var_call(source, index, &mut requirements)?;
requirement_scopes.resize(
requirement_scopes.len() + requirements.len() - start,
scope.id,
);
}
b'{' => {
let unconditional = brace_depth == 0 || scope.unconditional_group;
if brace_depth != 0 {
scopes.push(scope);
}
next_scope_id += 1;
scope = css_rule_scope(&source[prelude_start..index], next_scope_id, unconditional);
scope.definition_start = definition_stack.len();
scope.requirement_start = requirements.len();
brace_depth += 1;
index += 1;
prelude_start = index;
}
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)
)));
}
finish_css_rule(
&scope,
&mut active_definitions,
&mut definition_stack,
&mut requirements,
&mut requirement_scopes,
);
brace_depth -= 1;
scope = scopes.pop().unwrap_or_default();
index += 1;
prelude_start = index;
}
b';' if paren_depth == 0 && bracket_depth == 0 => {
index += 1;
prelude_start = index;
}
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(),
));
}
finish_css_rule(
&scope,
&mut active_definitions,
&mut definition_stack,
&mut requirements,
&mut requirement_scopes,
);
Ok(CssScanResult {
inherited_definitions,
local_definitions,
comments,
requirements,
})
}
fn finish_css_rule<'a>(
scope: &CssRuleScope,
definitions: &mut HashSet<(usize, &'a str)>,
definition_stack: &mut Vec<&'a str>,
requirements: &mut Vec<CssFallbackChain>,
requirement_scopes: &mut Vec<usize>,
) {
if definition_stack.len() == scope.definition_start {
return;
}
let mut kept = scope.requirement_start;
for index in scope.requirement_start..requirements.len() {
if requirement_scopes[index] == scope.id {
requirements[index]
.tokens
.retain(|token| !definitions.contains(&(scope.id, token.as_str())));
}
if !requirements[index].tokens.is_empty() {
requirements.swap(kept, index);
requirement_scopes.swap(kept, index);
kept += 1;
}
}
requirements.truncate(kept);
requirement_scopes.truncate(kept);
for name in definition_stack.drain(scope.definition_start..) {
definitions.remove(&(scope.id, name));
}
}
fn css_rule_scope(prelude: &str, id: usize, unconditional: bool) -> CssRuleScope {
let prelude = skip_css_trivia(prelude);
let name_end = prelude
.bytes()
.position(|byte| byte.is_ascii_whitespace() || byte == b'/')
.unwrap_or(prelude.len());
let name = &prelude[..name_end];
let defines_defaults = unconditional
&& (name.eq_ignore_ascii_case(":host") || name.eq_ignore_ascii_case(":root"))
&& skip_css_trivia(&prelude[name_end..]).is_empty();
CssRuleScope {
id,
unconditional_group: unconditional && name.eq_ignore_ascii_case("@layer"),
defines_defaults,
definition_start: 0,
requirement_start: 0,
}
}
fn skip_css_trivia(mut source: &str) -> &str {
loop {
source = source.trim_start();
if source.starts_with("/*") {
source = &source[crate::css_scan::block_comment_end(source, 0)..];
} else if comment_policy::is_css_line_comment_start(source, 0) {
source = &source[comment_policy::find_css_line_comment_end(source, 2)..];
} else {
return source;
}
}
}
fn scan_var_call(
source: &str,
start: usize,
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 {
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 requirements = scan_css(css, LegalComments::Inline, false)
.expect("scan failed")
.requirements;
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 requirements = scan_css(css, LegalComments::Inline, false)
.expect("scan failed")
.requirements;
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 scoped_definitions_only_satisfy_usages_in_their_own_rule() {
let mut parser = CssParser::new();
for css in [
".green { --brand: green; } button { color: var(--brand); }",
"button { color: var(--brand); } .green { --brand: green; }",
"@media (width > 10000px) { :root { --brand: green; } } button { color: var(--brand); }",
".green { --brand: green; & + button { color: var(--brand); } }",
] {
assert_eq!(
parser.extract_tokens(css).expect("extract"),
HashSet::from(["brand".to_string()]),
"{css}"
);
}
let css = ".green { color: var(--local); --local: var(--brand); }";
let (tokens, definitions) = parser.extract_tokens_and_definitions(css).expect("extract");
assert_eq!(tokens, HashSet::from(["brand".to_string()]));
assert_eq!(definitions, HashSet::from(["local".to_string()]));
}
#[test]
fn unconditional_defaults_survive_comments_and_cascade_layers() {
let mut parser = CssParser::new();
for css in [
"/* license */ :host /* default */ { --brand: green; } button { color: var(--brand); }",
"@layer reset; @layer theme { :root { --brand: green; } } button { color: var(--brand); }",
"@layer theme { @layer defaults { :host { --brand: green; } } } button { color: var(--brand); }",
".green { --local: red; color: var(--local); } .purple { color: var(--local); --local: purple; }",
] {
assert!(parser.extract_tokens(css).expect("extract").is_empty(), "{css}");
}
}
#[test]
fn nested_rule_compaction_preserves_outer_and_sibling_requirements() {
let css = ".outer {
color: var(--local);
.inner { --inner: 1px; margin: var(--inner); }
--local: var(--brand);
--local: var(--brand);
}
.sibling { color: var(--local); }";
let mut parser = CssParser::new();
assert_eq!(
parser.extract_tokens(css).expect("extract"),
HashSet::from(["brand".to_string(), "local".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 (_defs, _requirements, stripped) = parser
.extract_definitions_requirements_and_strip_comments(
"/* remove */.x{color:var(--a)}/*! keep */",
LegalComments::Inline,
)
.expect("scan failed");
assert_eq!(stripped, ".x{color:var(--a)}/*! keep */");
}
}