1use crate::{comment_policy, LegalComments, ParserError, Result};
7use std::borrow::Cow;
8use std::collections::HashSet;
9use std::fmt;
10use webui_tokens::CssFallbackChain;
11
12pub struct CssParser;
14
15struct CssScanResult<'a> {
16 inherited_definitions: HashSet<&'a str>,
18 local_definitions: HashSet<&'a str>,
19 comments: Vec<CssComment>,
20 requirements: Vec<CssFallbackChain>,
21}
22
23impl CssScanResult<'_> {
24 fn into_tokens(self) -> HashSet<String> {
25 self.requirements
26 .into_iter()
27 .flat_map(|chain| chain.tokens)
28 .filter(|token| !self.inherited_definitions.contains(token.as_str()))
29 .collect()
30 }
31
32 fn definitions(&self) -> HashSet<String> {
33 self.inherited_definitions
34 .iter()
35 .copied()
36 .chain(self.local_definitions.iter().copied())
37 .map(str::to_string)
38 .collect()
39 }
40}
41
42#[derive(Clone, Copy, Default)]
43struct CssRuleScope {
44 id: usize,
45 unconditional_group: bool,
46 defines_defaults: bool,
47 definition_start: usize,
48 requirement_start: usize,
49}
50
51type CssRequirementsAndStripped<'a> = (HashSet<String>, Vec<CssFallbackChain>, Cow<'a, str>);
52type CssRequirementsAndComments = (HashSet<String>, Vec<CssFallbackChain>, Vec<CssComment>);
53
54fn css_loc(source: &str, offset: usize) -> String {
60 let (line, column) = crate::diagnostic::line_column(source, offset);
61 format!("at line {line}, column {column}")
62}
63
64#[derive(Debug, Clone, Copy)]
65pub(crate) struct CssComment {
66 pub start_byte: usize,
67 pub end_byte: usize,
68 pub preserve: bool,
69}
70
71impl fmt::Debug for CssParser {
72 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73 f.debug_struct("CssParser").finish()
74 }
75}
76
77impl CssParser {
78 pub fn new() -> Self {
80 Self
81 }
82
83 pub fn extract_tokens(&mut self, css_content: &str) -> Result<HashSet<String>> {
85 Ok(scan_css(css_content, LegalComments::Inline, false)?.into_tokens())
86 }
87
88 pub fn extract_definitions(&mut self, css_content: &str) -> Result<HashSet<String>> {
90 Ok(scan_css(css_content, LegalComments::Inline, false)?.definitions())
91 }
92
93 pub fn extract_tokens_and_definitions(
95 &mut self,
96 css_content: &str,
97 ) -> Result<(HashSet<String>, HashSet<String>)> {
98 let scan = scan_css(css_content, LegalComments::Inline, false)?;
99 let definitions = scan.definitions();
100 Ok((scan.into_tokens(), definitions))
101 }
102
103 pub(crate) fn extract_definitions_requirements_and_strip_comments<'a>(
105 &mut self,
106 css_content: &'a str,
107 legal_comments: LegalComments,
108 ) -> Result<CssRequirementsAndStripped<'a>> {
109 let scan = scan_css(css_content, legal_comments, true)?;
110 let mut comment_ranges = removable_ranges(&scan.comments);
111 let stripped = comment_policy::strip_ranges(css_content, comment_ranges.as_mut_slice());
112 Ok((
113 scan.inherited_definitions
114 .into_iter()
115 .map(str::to_string)
116 .collect(),
117 scan.requirements,
118 stripped,
119 ))
120 }
121
122 pub(crate) fn extract_definitions_requirements_and_comments(
124 &mut self,
125 css_content: &str,
126 legal_comments: LegalComments,
127 ) -> Result<CssRequirementsAndComments> {
128 let mut scan = scan_css(css_content, legal_comments, true)?;
129 scan.comments
130 .sort_unstable_by_key(|comment| comment.start_byte);
131 Ok((
132 scan.inherited_definitions
133 .into_iter()
134 .map(str::to_string)
135 .collect(),
136 scan.requirements,
137 scan.comments,
138 ))
139 }
140}
141
142fn scan_css(
143 source: &str,
144 legal_comments: LegalComments,
145 collect_comments: bool,
146) -> Result<CssScanResult<'_>> {
147 let bytes = source.as_bytes();
148 let mut inherited_definitions = HashSet::new();
149 let mut local_definitions = HashSet::new();
150 let mut active_definitions = HashSet::new();
151 let mut definition_stack = Vec::new();
152 let mut comments = Vec::new();
153 let mut requirements = Vec::new();
154 let mut requirement_scopes = Vec::new();
155 let mut scopes: Vec<CssRuleScope> = Vec::new();
156 let mut scope = CssRuleScope::default();
157 let mut next_scope_id = 0;
158 let mut prelude_start = 0;
159 let mut index = 0usize;
160 let mut quote: u8 = 0;
161 let mut brace_depth = 0usize;
162 let mut paren_depth = 0usize;
163 let mut bracket_depth = 0usize;
164
165 while index < bytes.len() {
166 if quote != 0 {
167 if bytes[index] == b'\\' {
168 index = (index + 2).min(bytes.len());
169 continue;
170 }
171 if bytes[index] == quote {
172 quote = 0;
173 }
174 index += 1;
175 continue;
176 }
177
178 match bytes[index] {
179 b'"' | b'\'' => {
180 quote = bytes[index];
181 index += 1;
182 }
183 b'/' if index + 1 < bytes.len() && bytes[index + 1] == b'*' => {
184 let Some(offset) = source[index + 2..].find("*/") else {
185 return Err(ParserError::Css(format!(
186 "Unterminated CSS block comment {}. Close the comment with `*/` before building.",
187 css_loc(source, index)
188 )));
189 };
190 let end = index + 2 + offset + 2;
191 if collect_comments {
192 let comment = &source[index..end];
193 comments.push(CssComment {
194 start_byte: index,
195 end_byte: end,
196 preserve: comment_policy::should_preserve_css_comment(
197 comment,
198 legal_comments,
199 ),
200 });
201 }
202 index = end;
203 }
204 b'/' if comment_policy::is_css_line_comment_start(source, index) => {
205 let end = comment_policy::find_css_line_comment_end(source, index + 2);
206 if collect_comments {
207 let comment = &source[index..end];
208 comments.push(CssComment {
209 start_byte: index,
210 end_byte: end,
211 preserve: comment_policy::should_preserve_css_comment(
212 comment,
213 legal_comments,
214 ),
215 });
216 }
217 index = end;
218 }
219 b'-' if bytes.get(index + 1) == Some(&b'-') => {
220 if let Some((name, end)) = parse_custom_property_name(source, index) {
221 if is_custom_property_definition(source, end) {
222 if scope.defines_defaults {
223 inherited_definitions.insert(name);
224 } else {
225 local_definitions.insert(name);
226 if active_definitions.insert((scope.id, name)) {
227 definition_stack.push(name);
228 }
229 }
230 }
231 index = end;
232 } else {
233 index += 1;
234 }
235 }
236 b'v' if source[index..].starts_with("var(") => {
237 let start = requirements.len();
238 index = scan_var_call(source, index, &mut requirements)?;
239 requirement_scopes.resize(
240 requirement_scopes.len() + requirements.len() - start,
241 scope.id,
242 );
243 }
244 b'{' => {
245 let unconditional = brace_depth == 0 || scope.unconditional_group;
246 if brace_depth != 0 {
247 scopes.push(scope);
248 }
249 next_scope_id += 1;
250 scope = css_rule_scope(&source[prelude_start..index], next_scope_id, unconditional);
251 scope.definition_start = definition_stack.len();
252 scope.requirement_start = requirements.len();
253 brace_depth += 1;
254 index += 1;
255 prelude_start = index;
256 }
257 b'}' => {
258 if brace_depth == 0 {
259 return Err(ParserError::Css(format!(
260 "Unexpected CSS closing brace {}. Remove the extra `}}` or add a matching opening `{{`.",
261 css_loc(source, index)
262 )));
263 }
264 finish_css_rule(
265 &scope,
266 &mut active_definitions,
267 &mut definition_stack,
268 &mut requirements,
269 &mut requirement_scopes,
270 );
271 brace_depth -= 1;
272 scope = scopes.pop().unwrap_or_default();
274 index += 1;
275 prelude_start = index;
276 }
277 b';' if paren_depth == 0 && bracket_depth == 0 => {
278 index += 1;
279 prelude_start = index;
280 }
281 b'(' => {
282 paren_depth += 1;
283 index += 1;
284 }
285 b')' => {
286 if paren_depth == 0 {
287 return Err(ParserError::Css(format!(
288 "Unexpected CSS closing parenthesis {}. Remove the extra `)` or add a matching opening `(`.",
289 css_loc(source, index)
290 )));
291 }
292 paren_depth -= 1;
293 index += 1;
294 }
295 b'[' => {
296 bracket_depth += 1;
297 index += 1;
298 }
299 b']' => {
300 if bracket_depth == 0 {
301 return Err(ParserError::Css(format!(
302 "Unexpected CSS closing bracket {}. Remove the extra `]` or add a matching opening `[`.",
303 css_loc(source, index)
304 )));
305 }
306 bracket_depth -= 1;
307 index += 1;
308 }
309 _ => index += 1,
310 }
311 }
312
313 if quote != 0 {
314 return Err(ParserError::Css(format!(
315 "Unterminated CSS string literal. Close the `{}` quote before building.",
316 char::from(quote)
317 )));
318 }
319 if brace_depth != 0 {
320 return Err(ParserError::Css(
321 "Unterminated CSS rule block. Add the missing `}` before building.".to_string(),
322 ));
323 }
324 if paren_depth != 0 {
325 return Err(ParserError::Css(
326 "Unterminated CSS parenthesized expression. Add the missing `)` before building."
327 .to_string(),
328 ));
329 }
330 if bracket_depth != 0 {
331 return Err(ParserError::Css(
332 "Unterminated CSS bracket expression. Add the missing `]` before building.".to_string(),
333 ));
334 }
335
336 finish_css_rule(
337 &scope,
338 &mut active_definitions,
339 &mut definition_stack,
340 &mut requirements,
341 &mut requirement_scopes,
342 );
343 Ok(CssScanResult {
344 inherited_definitions,
345 local_definitions,
346 comments,
347 requirements,
348 })
349}
350
351fn finish_css_rule<'a>(
352 scope: &CssRuleScope,
353 definitions: &mut HashSet<(usize, &'a str)>,
354 definition_stack: &mut Vec<&'a str>,
355 requirements: &mut Vec<CssFallbackChain>,
356 requirement_scopes: &mut Vec<usize>,
357) {
358 if definition_stack.len() == scope.definition_start {
359 return;
360 }
361 let mut kept = scope.requirement_start;
362 for index in scope.requirement_start..requirements.len() {
363 if requirement_scopes[index] == scope.id {
364 requirements[index]
365 .tokens
366 .retain(|token| !definitions.contains(&(scope.id, token.as_str())));
367 }
368 if !requirements[index].tokens.is_empty() {
369 requirements.swap(kept, index);
370 requirement_scopes.swap(kept, index);
371 kept += 1;
372 }
373 }
374 requirements.truncate(kept);
375 requirement_scopes.truncate(kept);
376 for name in definition_stack.drain(scope.definition_start..) {
377 definitions.remove(&(scope.id, name));
378 }
379}
380
381fn css_rule_scope(prelude: &str, id: usize, unconditional: bool) -> CssRuleScope {
382 let prelude = skip_css_trivia(prelude);
383 let name_end = prelude
384 .bytes()
385 .position(|byte| byte.is_ascii_whitespace() || byte == b'/')
386 .unwrap_or(prelude.len());
387 let name = &prelude[..name_end];
388 let defines_defaults = unconditional
389 && (name.eq_ignore_ascii_case(":host") || name.eq_ignore_ascii_case(":root"))
390 && skip_css_trivia(&prelude[name_end..]).is_empty();
391 CssRuleScope {
392 id,
393 unconditional_group: unconditional && name.eq_ignore_ascii_case("@layer"),
394 defines_defaults,
395 definition_start: 0,
396 requirement_start: 0,
397 }
398}
399
400fn skip_css_trivia(mut source: &str) -> &str {
401 loop {
402 source = source.trim_start();
403 if source.starts_with("/*") {
404 source = &source[crate::css_scan::block_comment_end(source, 0)..];
405 } else if comment_policy::is_css_line_comment_start(source, 0) {
406 source = &source[comment_policy::find_css_line_comment_end(source, 2)..];
407 } else {
408 return source;
409 }
410 }
411}
412
413fn scan_var_call(
414 source: &str,
415 start: usize,
416 requirements: &mut Vec<CssFallbackChain>,
417) -> Result<usize> {
418 struct VarFrame<'a> {
419 close_depth: usize,
420 tokens: Vec<&'a str>,
421 after_fallback_comma: bool,
422 has_literal_fallback: bool,
423 fallback_has_unsafe_var: bool,
424 }
425
426 impl<'a> VarFrame<'a> {
427 fn new(close_depth: usize) -> Self {
428 Self {
429 close_depth,
430 tokens: Vec::new(),
431 after_fallback_comma: false,
432 has_literal_fallback: false,
433 fallback_has_unsafe_var: false,
434 }
435 }
436
437 fn has_safe_literal_fallback(&self) -> bool {
438 self.has_literal_fallback && !self.fallback_has_unsafe_var
439 }
440 }
441
442 let bytes = source.as_bytes();
443 let mut index = start + 4;
444 let mut depth = 1usize;
445 let mut brace_depth = 0usize;
446 let mut bracket_depth = 0usize;
447 let mut quote: u8 = 0;
448 let mut frames = vec![VarFrame::new(1)];
449
450 while index < bytes.len() {
451 if quote != 0 {
452 if bytes[index] == b'\\' {
453 index = (index + 2).min(bytes.len());
454 continue;
455 }
456 if bytes[index] == quote {
457 quote = 0;
458 }
459 index += 1;
460 continue;
461 }
462
463 match bytes[index] {
464 b'"' | b'\'' => {
465 if let Some(frame) = frames.last_mut() {
466 if frame.after_fallback_comma {
467 frame.has_literal_fallback = true;
468 }
469 }
470 quote = bytes[index];
471 index += 1;
472 }
473 b'/' if index + 1 < bytes.len() && bytes[index + 1] == b'*' => {
474 let Some(offset) = source[index + 2..].find("*/") else {
475 return Err(ParserError::Css(format!(
476 "Unterminated CSS block comment inside var() {}. Close the comment with `*/` before building.",
477 css_loc(source, index)
478 )));
479 };
480 index += 2 + offset + 2;
481 }
482 b'/' if comment_policy::is_css_line_comment_start(source, index) => {
483 index = comment_policy::find_css_line_comment_end(source, index + 2);
484 }
485 b'v' if source[index..].starts_with("var(") => {
486 depth += 1;
487 frames.push(VarFrame::new(depth));
488 index += 4;
489 }
490 b',' => {
491 if let Some(frame) = frames.last_mut() {
492 frame.after_fallback_comma = true;
493 }
494 index += 1;
495 }
496 b'(' => {
497 if let Some(frame) = frames.last_mut() {
498 if frame.after_fallback_comma {
499 frame.has_literal_fallback = true;
500 }
501 }
502 depth += 1;
503 index += 1;
504 }
505 b')' => {
506 let closes_var = frames
507 .last()
508 .is_some_and(|frame| frame.close_depth == depth);
509 depth = depth.saturating_sub(1);
510 index += 1;
511 if closes_var {
512 let Some(frame) = frames.pop() else {
513 return Err(ParserError::Css(format!(
514 "Unterminated CSS var() call {}. Add the missing `)` before building.",
515 css_loc(source, start)
516 )));
517 };
518 if !frames.is_empty() {
519 let safe_literal = frame.has_safe_literal_fallback();
520 if let Some(parent) = frames.last_mut() {
521 if parent.after_fallback_comma {
522 if safe_literal {
523 parent.has_literal_fallback = true;
524 } else {
525 parent.fallback_has_unsafe_var = true;
526 }
527 }
528 parent.tokens.extend(frame.tokens);
529 }
530 continue;
531 }
532 if brace_depth != 0 {
533 return Err(ParserError::Css(format!(
534 "Unterminated CSS brace expression inside var() {}. Add the missing `}}` before building.",
535 css_loc(source, start)
536 )));
537 }
538 if bracket_depth != 0 {
539 return Err(ParserError::Css(format!(
540 "Unterminated CSS bracket expression inside var() {}. Add the missing `]` before building.",
541 css_loc(source, start)
542 )));
543 }
544 if !frame.tokens.is_empty() {
545 let has_safe_literal_fallback = frame.has_safe_literal_fallback();
546 let mut chain = Vec::with_capacity(frame.tokens.len());
547 for token in frame.tokens {
548 if !chain.iter().any(|existing| existing == token) {
549 chain.push(token.to_string());
550 }
551 }
552 requirements.push(CssFallbackChain {
553 tokens: chain,
554 has_literal_fallback: has_safe_literal_fallback,
555 });
556 }
557 return Ok(index);
558 }
559 }
560 b'{' => {
561 if let Some(frame) = frames.last_mut() {
562 if frame.after_fallback_comma {
563 frame.has_literal_fallback = true;
564 }
565 }
566 brace_depth += 1;
567 index += 1;
568 }
569 b'}' => {
570 if brace_depth == 0 {
571 return Err(ParserError::Css(format!(
572 "Unterminated CSS var() call {}. Add the missing `)` before the closing `}}`.",
573 css_loc(source, start)
574 )));
575 }
576 brace_depth -= 1;
577 index += 1;
578 }
579 b'[' => {
580 if let Some(frame) = frames.last_mut() {
581 if frame.after_fallback_comma {
582 frame.has_literal_fallback = true;
583 }
584 }
585 bracket_depth += 1;
586 index += 1;
587 }
588 b']' => {
589 if bracket_depth == 0 {
590 return Err(ParserError::Css(format!(
591 "Unexpected CSS closing bracket inside var() {}. Remove the extra `]` or add a matching opening `[`.",
592 css_loc(source, index)
593 )));
594 }
595 bracket_depth -= 1;
596 index += 1;
597 }
598 b'-' if bytes.get(index + 1) == Some(&b'-') => {
599 if let Some((name, end)) = parse_custom_property_name(source, index) {
600 if let Some(frame) = frames.last_mut() {
601 frame.tokens.push(name);
602 }
603 index = end;
604 } else {
605 index += 1;
606 }
607 }
608 _ => {
609 if let Some(frame) = frames.last_mut() {
610 if frame.after_fallback_comma && !bytes[index].is_ascii_whitespace() {
611 frame.has_literal_fallback = true;
612 }
613 }
614 index += 1;
615 }
616 }
617 }
618
619 if quote != 0 {
620 return Err(ParserError::Css(format!(
621 "Unterminated CSS string literal inside var() {}. Close the `{}` quote before building.",
622 css_loc(source, start),
623 char::from(quote)
624 )));
625 }
626
627 Err(ParserError::Css(format!(
628 "Unterminated CSS var() call {}. Add the missing `)` before building.",
629 css_loc(source, start)
630 )))
631}
632
633fn parse_custom_property_name(source: &str, start: usize) -> Option<(&str, usize)> {
634 let bytes = source.as_bytes();
635 if bytes.get(start..start + 2) != Some(b"--") {
636 return None;
637 }
638
639 let mut end = start + 2;
640 while end < bytes.len()
641 && (bytes[end].is_ascii_alphanumeric() || matches!(bytes[end], b'_' | b'-'))
642 {
643 end += 1;
644 }
645
646 (end > start + 2).then(|| (&source[start + 2..end], end))
647}
648
649fn is_custom_property_definition(source: &str, name_end: usize) -> bool {
650 let bytes = source.as_bytes();
651 let mut cursor = name_end;
652 while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
653 cursor += 1;
654 }
655 if bytes.get(cursor) != Some(&b':') {
656 return false;
657 }
658
659 let mut before = name_end;
661 while before > 0
662 && (bytes[before - 1].is_ascii_alphanumeric() || matches!(bytes[before - 1], b'_' | b'-'))
663 {
664 before -= 1;
665 }
666 before = skip_ws_and_block_comments_back(source, before);
671
672 before == 0 || matches!(bytes[before - 1], b'{' | b';')
673}
674
675fn skip_ws_and_block_comments_back(source: &str, mut pos: usize) -> usize {
681 let bytes = source.as_bytes();
682 loop {
683 while pos > 0 && bytes[pos - 1].is_ascii_whitespace() {
684 pos -= 1;
685 }
686 if pos >= 2 && &source[pos - 2..pos] == "*/" {
688 match source[..pos - 2].rfind("/*") {
689 Some(open) => pos = open,
690 None => break,
693 }
694 } else {
695 break;
696 }
697 }
698 pos
699}
700
701fn removable_ranges(comments: &[CssComment]) -> Vec<(usize, usize)> {
702 let mut ranges = Vec::with_capacity(comments.len());
703 for comment in comments {
704 if !comment.preserve {
705 ranges.push((comment.start_byte, comment.end_byte));
706 }
707 }
708 ranges
709}
710
711impl Default for CssParser {
712 fn default() -> Self {
713 Self::new()
714 }
715}
716
717#[cfg(test)]
718#[allow(clippy::disallowed_methods)]
719mod tests {
720 use super::*;
721
722 #[test]
723 fn test_extract_single_var() {
724 let mut parser = CssParser::new();
725 let tokens = parser
726 .extract_tokens(".btn { color: var(--colorPrimary); }")
727 .expect("extract_tokens failed");
728 assert_eq!(tokens, HashSet::from(["colorPrimary".to_string()]));
729 }
730
731 #[test]
732 fn test_extract_multiple_vars() {
733 let css = r#"
734 .btn {
735 color: var(--textColor);
736 background: var(--bgColor);
737 border-radius: var(--radius);
738 }
739 "#;
740 let mut parser = CssParser::new();
741 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
742 assert_eq!(
743 tokens,
744 HashSet::from([
745 "textColor".to_string(),
746 "bgColor".to_string(),
747 "radius".to_string(),
748 ])
749 );
750 }
751
752 #[test]
753 fn test_extract_nested_fallback() {
754 let css = ".x { color: var(--primary, var(--fallback)); }";
755 let mut parser = CssParser::new();
756 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
757 assert_eq!(
758 tokens,
759 HashSet::from(["primary".to_string(), "fallback".to_string()])
760 );
761 }
762
763 #[test]
764 fn test_extract_deeply_nested_fallbacks() {
765 let css = ".x { color: var(--a, var(--b, var(--c))); }";
766 let mut parser = CssParser::new();
767 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
768 assert_eq!(
769 tokens,
770 HashSet::from(["a".to_string(), "b".to_string(), "c".to_string()])
771 );
772 }
773
774 #[test]
775 fn test_extract_nested_fallbacks_excludes_locally_defined_tokens() {
776 let css = r#"
777 :root { --token-a: red; }
778 .x { --foo-bar: var(--token-a, var(--token-b, var(--token-c))); }
779 "#;
780 let mut parser = CssParser::new();
781 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
782 assert_eq!(
783 tokens,
784 HashSet::from(["token-b".to_string(), "token-c".to_string()])
785 );
786 }
787
788 #[test]
789 fn test_literal_fallback_ignored() {
790 let css = ".x { font-size: var(--size, 16px); }";
791 let mut parser = CssParser::new();
792 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
793 assert_eq!(tokens, HashSet::from(["size".to_string()]));
794 }
795
796 #[test]
797 fn test_nested_var_without_literal_keeps_chain_required() {
798 let css = ".x { margin: var(--gap, calc(var(--spacing-m) * 2)); }";
799 let requirements = scan_css(css, LegalComments::Inline, false)
800 .expect("scan failed")
801 .requirements;
802
803 assert_eq!(requirements.len(), 1);
804 assert_eq!(requirements[0].tokens, vec!["gap", "spacing-m"]);
805 assert!(
806 !requirements[0].has_literal_fallback,
807 "calc(var(--spacing-m) * 2) is not a safe literal fallback"
808 );
809 }
810
811 #[test]
812 fn test_nested_var_with_literal_keeps_chain_optional() {
813 let css = ".x { margin: var(--gap, calc(var(--spacing-m, 1px) * 2)); }";
814 let requirements = scan_css(css, LegalComments::Inline, false)
815 .expect("scan failed")
816 .requirements;
817
818 assert_eq!(requirements.len(), 1);
819 assert_eq!(requirements[0].tokens, vec!["gap", "spacing-m"]);
820 assert!(
821 requirements[0].has_literal_fallback,
822 "calc(var(--spacing-m, 1px) * 2) has a safe literal fallback"
823 );
824 }
825
826 #[test]
827 fn test_var_fallback_block_comment_ignored() {
828 let css = ".x { color: var(--primary, /* --debug-only */ red); }";
829 let mut parser = CssParser::new();
830 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
831 assert_eq!(tokens, HashSet::from(["primary".to_string()]));
832 }
833
834 #[test]
835 fn test_var_fallback_line_comment_ignored() {
836 let css = ".x { color: var(--primary,\n // --debug-only\n red); }";
837 let mut parser = CssParser::new();
838 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
839 assert_eq!(tokens, HashSet::from(["primary".to_string()]));
840 }
841
842 #[test]
843 fn test_exclude_local_definitions() {
844 let css = r#"
845 :root { --bar: 12px; }
846 .x { width: var(--bar); }
847 "#;
848 let mut parser = CssParser::new();
849 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
850 assert!(
851 tokens.is_empty(),
852 "Locally defined --bar should be excluded: {tokens:?}"
853 );
854 }
855
856 #[test]
857 fn test_exclude_definitions_keep_unrelated_usages() {
858 let css = r#"
859 :host { --local: 5px; }
860 .x { color: var(--external); }
861 "#;
862 let mut parser = CssParser::new();
863 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
864 assert_eq!(tokens, HashSet::from(["external".to_string()]));
865 }
866
867 #[test]
868 fn scoped_definitions_only_satisfy_usages_in_their_own_rule() {
869 let mut parser = CssParser::new();
870 for css in [
871 ".green { --brand: green; } button { color: var(--brand); }",
872 "button { color: var(--brand); } .green { --brand: green; }",
873 "@media (width > 10000px) { :root { --brand: green; } } button { color: var(--brand); }",
874 ".green { --brand: green; & + button { color: var(--brand); } }",
875 ] {
876 assert_eq!(
877 parser.extract_tokens(css).expect("extract"),
878 HashSet::from(["brand".to_string()]),
879 "{css}"
880 );
881 }
882 let css = ".green { color: var(--local); --local: var(--brand); }";
883 let (tokens, definitions) = parser.extract_tokens_and_definitions(css).expect("extract");
884 assert_eq!(tokens, HashSet::from(["brand".to_string()]));
885 assert_eq!(definitions, HashSet::from(["local".to_string()]));
886 }
887
888 #[test]
889 fn unconditional_defaults_survive_comments_and_cascade_layers() {
890 let mut parser = CssParser::new();
891 for css in [
892 "/* license */ :host /* default */ { --brand: green; } button { color: var(--brand); }",
893 "@layer reset; @layer theme { :root { --brand: green; } } button { color: var(--brand); }",
894 "@layer theme { @layer defaults { :host { --brand: green; } } } button { color: var(--brand); }",
895 ".green { --local: red; color: var(--local); } .purple { color: var(--local); --local: purple; }",
896 ] {
897 assert!(parser.extract_tokens(css).expect("extract").is_empty(), "{css}");
898 }
899 }
900
901 #[test]
902 fn nested_rule_compaction_preserves_outer_and_sibling_requirements() {
903 let css = ".outer {
904 color: var(--local);
905 .inner { --inner: 1px; margin: var(--inner); }
906 --local: var(--brand);
907 --local: var(--brand);
908 }
909 .sibling { color: var(--local); }";
910 let mut parser = CssParser::new();
911 assert_eq!(
912 parser.extract_tokens(css).expect("extract"),
913 HashSet::from(["brand".to_string(), "local".to_string()])
914 );
915 }
916
917 #[test]
918 fn test_definition_after_block_comment_is_recognized() {
919 let css = ":root {\n /*{{{tokens.light}}}*/\n --foo-bar: 100px;\n}";
922 let mut parser = CssParser::new();
923 let defs = parser
924 .extract_definitions(css)
925 .expect("extract_definitions failed");
926 assert!(
927 defs.contains("foo-bar"),
928 "definition after a comment should be recognized: {defs:?}"
929 );
930 }
931
932 #[test]
933 fn test_definition_after_comment_excludes_matching_usage() {
934 let css = ":root {\n /* note */ --foo-bar: 100px;\n}\n.x { padding: var(--foo-bar); }";
937 let mut parser = CssParser::new();
938 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
939 assert!(
940 tokens.is_empty(),
941 "--foo-bar is defined locally and must not be hoisted: {tokens:?}"
942 );
943 }
944
945 #[test]
946 fn test_empty_css() {
947 let mut parser = CssParser::new();
948 let tokens = parser.extract_tokens("").expect("extract_tokens failed");
949 assert!(tokens.is_empty());
950 }
951
952 #[test]
953 fn test_no_var_calls() {
954 let css = "body { margin: 0; color: red; }";
955 let mut parser = CssParser::new();
956 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
957 assert!(tokens.is_empty());
958 }
959
960 #[test]
961 fn test_deduplicate_same_var() {
962 let css = r#"
963 .a { color: var(--shared); }
964 .b { background: var(--shared); }
965 "#;
966 let mut parser = CssParser::new();
967 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
968 assert_eq!(tokens, HashSet::from(["shared".to_string()]));
969 }
970
971 #[test]
972 fn test_unterminated_var_call_returns_error() {
973 let css = ".bad { color: var(--dangling; } .ok { color: var(--valid); }";
974 let mut parser = CssParser::new();
975 let err = parser
976 .extract_tokens(css)
977 .expect_err("unterminated var() should fail");
978 assert!(matches!(err, ParserError::Css(message) if
979 message.contains("Unterminated CSS var() call")
980 ));
981 }
982
983 #[test]
984 fn test_unmatched_var_fallback_delimiter_returns_error() {
985 let css = ".bad { color: var(--accent, [red); }";
986 let mut parser = CssParser::new();
987 let err = parser
988 .extract_tokens(css)
989 .expect_err("unmatched var() fallback delimiter should fail");
990 assert!(matches!(err, ParserError::Css(message) if
991 message.contains("Unterminated CSS bracket expression inside var()")
992 ));
993 }
994
995 #[test]
996 fn test_balanced_var_fallback_delimiters_extract_tokens() {
997 let css = ".ok { color: var(--accent, color(display-p3 1 0 0 / 1)); }";
998 let mut parser = CssParser::new();
999 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
1000 assert_eq!(tokens, HashSet::from(["accent".to_string()]));
1001 }
1002
1003 #[test]
1004 fn test_unterminated_comment_returns_error() {
1005 let css = ".ok { color: var(--valid); } /* .bad { color: var(--ignored); }";
1006 let mut parser = CssParser::new();
1007 let err = parser
1008 .extract_tokens(css)
1009 .expect_err("unterminated comment should fail");
1010 assert!(matches!(err, ParserError::Css(message) if
1011 message.contains("Unterminated CSS block comment")
1012 ));
1013 }
1014
1015 #[test]
1016 fn test_unterminated_string_returns_error() {
1017 let css = r#".bad { content: "dangling; }"#;
1018 let mut parser = CssParser::new();
1019 let err = parser
1020 .extract_tokens(css)
1021 .expect_err("unterminated string should fail");
1022 assert!(matches!(err, ParserError::Css(message) if
1023 message.contains("Unterminated CSS string literal")
1024 ));
1025 }
1026
1027 #[test]
1028 fn test_comments_are_collected_and_stripped() {
1029 let mut parser = CssParser::new();
1030 let (_defs, _requirements, stripped) = parser
1031 .extract_definitions_requirements_and_strip_comments(
1032 "/* remove */.x{color:var(--a)}/*! keep */",
1033 LegalComments::Inline,
1034 )
1035 .expect("scan failed");
1036 assert_eq!(stripped, ".x{color:var(--a)}/*! keep */");
1037 }
1038}