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
15type CssScanResult = (
16 HashSet<String>,
17 HashSet<String>,
18 Vec<CssComment>,
19 Vec<CssFallbackChain>,
20);
21type CssRequirementsAndStripped<'a> = (
22 HashSet<String>,
23 HashSet<String>,
24 Vec<CssFallbackChain>,
25 Cow<'a, str>,
26);
27type CssRequirementsAndComments = (
28 HashSet<String>,
29 HashSet<String>,
30 Vec<CssFallbackChain>,
31 Vec<CssComment>,
32);
33
34fn css_loc(source: &str, offset: usize) -> String {
40 let (line, column) = crate::diagnostic::line_column(source, offset);
41 format!("at line {line}, column {column}")
42}
43
44#[derive(Debug, Clone, Copy)]
45pub(crate) struct CssComment {
46 pub start_byte: usize,
47 pub end_byte: usize,
48 pub preserve: bool,
49}
50
51impl fmt::Debug for CssParser {
52 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53 f.debug_struct("CssParser").finish()
54 }
55}
56
57impl CssParser {
58 pub fn new() -> Self {
60 Self
61 }
62
63 pub fn extract_tokens(&mut self, css_content: &str) -> Result<HashSet<String>> {
65 let (mut tokens, definitions, _comments, _requirements) =
66 scan_css(css_content, LegalComments::Inline, false)?;
67 tokens.retain(|t| !definitions.contains(t));
68 Ok(tokens)
69 }
70
71 pub fn extract_definitions(&mut self, css_content: &str) -> Result<HashSet<String>> {
73 let (_tokens, definitions, _comments, _requirements) =
74 scan_css(css_content, LegalComments::Inline, false)?;
75 Ok(definitions)
76 }
77
78 pub fn extract_tokens_and_definitions(
80 &mut self,
81 css_content: &str,
82 ) -> Result<(HashSet<String>, HashSet<String>)> {
83 let (mut tokens, definitions, _comments, _requirements) =
84 scan_css(css_content, LegalComments::Inline, false)?;
85 tokens.retain(|t| !definitions.contains(t));
86 Ok((tokens, definitions))
87 }
88
89 pub(crate) fn extract_tokens_definitions_requirements_and_strip_comments<'a>(
91 &mut self,
92 css_content: &'a str,
93 legal_comments: LegalComments,
94 ) -> Result<CssRequirementsAndStripped<'a>> {
95 let (mut tokens, definitions, comments, requirements) =
96 scan_css(css_content, legal_comments, true)?;
97 tokens.retain(|t| !definitions.contains(t));
98 let mut comment_ranges = removable_ranges(&comments);
99 let stripped = comment_policy::strip_ranges(css_content, comment_ranges.as_mut_slice());
100 Ok((tokens, definitions, requirements, stripped))
101 }
102
103 pub(crate) fn extract_tokens_definitions_requirements_and_comments(
105 &mut self,
106 css_content: &str,
107 legal_comments: LegalComments,
108 ) -> Result<CssRequirementsAndComments> {
109 let (mut tokens, definitions, mut comments, requirements) =
110 scan_css(css_content, legal_comments, true)?;
111 tokens.retain(|t| !definitions.contains(t));
112 comments.sort_unstable_by_key(|comment| comment.start_byte);
113 Ok((tokens, definitions, requirements, comments))
114 }
115}
116
117fn scan_css(
118 source: &str,
119 legal_comments: LegalComments,
120 collect_comments: bool,
121) -> Result<CssScanResult> {
122 let bytes = source.as_bytes();
123 let mut tokens = HashSet::new();
124 let mut definitions = HashSet::new();
125 let mut comments = Vec::new();
126 let mut requirements = Vec::new();
127 let mut index = 0usize;
128 let mut quote: u8 = 0;
129 let mut brace_depth = 0usize;
130 let mut paren_depth = 0usize;
131 let mut bracket_depth = 0usize;
132
133 while index < bytes.len() {
134 if quote != 0 {
135 if bytes[index] == b'\\' {
136 index = (index + 2).min(bytes.len());
137 continue;
138 }
139 if bytes[index] == quote {
140 quote = 0;
141 }
142 index += 1;
143 continue;
144 }
145
146 match bytes[index] {
147 b'"' | b'\'' => {
148 quote = bytes[index];
149 index += 1;
150 }
151 b'/' if index + 1 < bytes.len() && bytes[index + 1] == b'*' => {
152 let Some(offset) = source[index + 2..].find("*/") else {
153 return Err(ParserError::Css(format!(
154 "Unterminated CSS block comment {}. Close the comment with `*/` before building.",
155 css_loc(source, index)
156 )));
157 };
158 let end = index + 2 + offset + 2;
159 if collect_comments {
160 let comment = &source[index..end];
161 comments.push(CssComment {
162 start_byte: index,
163 end_byte: end,
164 preserve: comment_policy::should_preserve_css_comment(
165 comment,
166 legal_comments,
167 ),
168 });
169 }
170 index = end;
171 }
172 b'/' if comment_policy::is_css_line_comment_start(source, index) => {
173 let end = comment_policy::find_css_line_comment_end(source, index + 2);
174 if collect_comments {
175 let comment = &source[index..end];
176 comments.push(CssComment {
177 start_byte: index,
178 end_byte: end,
179 preserve: comment_policy::should_preserve_css_comment(
180 comment,
181 legal_comments,
182 ),
183 });
184 }
185 index = end;
186 }
187 b'-' if bytes.get(index + 1) == Some(&b'-') => {
188 if let Some((name, end)) = parse_custom_property_name(source, index) {
189 if is_custom_property_definition(source, end) {
190 definitions.insert(name.to_string());
191 }
192 index = end;
193 } else {
194 index += 1;
195 }
196 }
197 b'v' if source[index..].starts_with("var(") => {
198 index = scan_var_call(source, index, &mut tokens, &mut requirements)?;
199 }
200 b'{' => {
201 brace_depth += 1;
202 index += 1;
203 }
204 b'}' => {
205 if brace_depth == 0 {
206 return Err(ParserError::Css(format!(
207 "Unexpected CSS closing brace {}. Remove the extra `}}` or add a matching opening `{{`.",
208 css_loc(source, index)
209 )));
210 }
211 brace_depth -= 1;
212 index += 1;
213 }
214 b'(' => {
215 paren_depth += 1;
216 index += 1;
217 }
218 b')' => {
219 if paren_depth == 0 {
220 return Err(ParserError::Css(format!(
221 "Unexpected CSS closing parenthesis {}. Remove the extra `)` or add a matching opening `(`.",
222 css_loc(source, index)
223 )));
224 }
225 paren_depth -= 1;
226 index += 1;
227 }
228 b'[' => {
229 bracket_depth += 1;
230 index += 1;
231 }
232 b']' => {
233 if bracket_depth == 0 {
234 return Err(ParserError::Css(format!(
235 "Unexpected CSS closing bracket {}. Remove the extra `]` or add a matching opening `[`.",
236 css_loc(source, index)
237 )));
238 }
239 bracket_depth -= 1;
240 index += 1;
241 }
242 _ => index += 1,
243 }
244 }
245
246 if quote != 0 {
247 return Err(ParserError::Css(format!(
248 "Unterminated CSS string literal. Close the `{}` quote before building.",
249 char::from(quote)
250 )));
251 }
252 if brace_depth != 0 {
253 return Err(ParserError::Css(
254 "Unterminated CSS rule block. Add the missing `}` before building.".to_string(),
255 ));
256 }
257 if paren_depth != 0 {
258 return Err(ParserError::Css(
259 "Unterminated CSS parenthesized expression. Add the missing `)` before building."
260 .to_string(),
261 ));
262 }
263 if bracket_depth != 0 {
264 return Err(ParserError::Css(
265 "Unterminated CSS bracket expression. Add the missing `]` before building.".to_string(),
266 ));
267 }
268
269 Ok((tokens, definitions, comments, requirements))
270}
271
272fn scan_var_call(
273 source: &str,
274 start: usize,
275 tokens: &mut HashSet<String>,
276 requirements: &mut Vec<CssFallbackChain>,
277) -> Result<usize> {
278 struct VarFrame<'a> {
279 close_depth: usize,
280 tokens: Vec<&'a str>,
281 after_fallback_comma: bool,
282 has_literal_fallback: bool,
283 fallback_has_unsafe_var: bool,
284 }
285
286 impl<'a> VarFrame<'a> {
287 fn new(close_depth: usize) -> Self {
288 Self {
289 close_depth,
290 tokens: Vec::new(),
291 after_fallback_comma: false,
292 has_literal_fallback: false,
293 fallback_has_unsafe_var: false,
294 }
295 }
296
297 fn has_safe_literal_fallback(&self) -> bool {
298 self.has_literal_fallback && !self.fallback_has_unsafe_var
299 }
300 }
301
302 let bytes = source.as_bytes();
303 let mut index = start + 4;
304 let mut depth = 1usize;
305 let mut brace_depth = 0usize;
306 let mut bracket_depth = 0usize;
307 let mut quote: u8 = 0;
308 let mut frames = vec![VarFrame::new(1)];
309
310 while index < bytes.len() {
311 if quote != 0 {
312 if bytes[index] == b'\\' {
313 index = (index + 2).min(bytes.len());
314 continue;
315 }
316 if bytes[index] == quote {
317 quote = 0;
318 }
319 index += 1;
320 continue;
321 }
322
323 match bytes[index] {
324 b'"' | b'\'' => {
325 if let Some(frame) = frames.last_mut() {
326 if frame.after_fallback_comma {
327 frame.has_literal_fallback = true;
328 }
329 }
330 quote = bytes[index];
331 index += 1;
332 }
333 b'/' if index + 1 < bytes.len() && bytes[index + 1] == b'*' => {
334 let Some(offset) = source[index + 2..].find("*/") else {
335 return Err(ParserError::Css(format!(
336 "Unterminated CSS block comment inside var() {}. Close the comment with `*/` before building.",
337 css_loc(source, index)
338 )));
339 };
340 index += 2 + offset + 2;
341 }
342 b'/' if comment_policy::is_css_line_comment_start(source, index) => {
343 index = comment_policy::find_css_line_comment_end(source, index + 2);
344 }
345 b'v' if source[index..].starts_with("var(") => {
346 depth += 1;
347 frames.push(VarFrame::new(depth));
348 index += 4;
349 }
350 b',' => {
351 if let Some(frame) = frames.last_mut() {
352 frame.after_fallback_comma = true;
353 }
354 index += 1;
355 }
356 b'(' => {
357 if let Some(frame) = frames.last_mut() {
358 if frame.after_fallback_comma {
359 frame.has_literal_fallback = true;
360 }
361 }
362 depth += 1;
363 index += 1;
364 }
365 b')' => {
366 let closes_var = frames
367 .last()
368 .is_some_and(|frame| frame.close_depth == depth);
369 depth = depth.saturating_sub(1);
370 index += 1;
371 if closes_var {
372 let Some(frame) = frames.pop() else {
373 return Err(ParserError::Css(format!(
374 "Unterminated CSS var() call {}. Add the missing `)` before building.",
375 css_loc(source, start)
376 )));
377 };
378 if !frames.is_empty() {
379 let safe_literal = frame.has_safe_literal_fallback();
380 if let Some(parent) = frames.last_mut() {
381 if parent.after_fallback_comma {
382 if safe_literal {
383 parent.has_literal_fallback = true;
384 } else {
385 parent.fallback_has_unsafe_var = true;
386 }
387 }
388 parent.tokens.extend(frame.tokens);
389 }
390 continue;
391 }
392 if brace_depth != 0 {
393 return Err(ParserError::Css(format!(
394 "Unterminated CSS brace expression inside var() {}. Add the missing `}}` before building.",
395 css_loc(source, start)
396 )));
397 }
398 if bracket_depth != 0 {
399 return Err(ParserError::Css(format!(
400 "Unterminated CSS bracket expression inside var() {}. Add the missing `]` before building.",
401 css_loc(source, start)
402 )));
403 }
404 if !frame.tokens.is_empty() {
405 let has_safe_literal_fallback = frame.has_safe_literal_fallback();
406 let mut chain = Vec::with_capacity(frame.tokens.len());
407 for token in frame.tokens {
408 tokens.insert(token.to_string());
409 if !chain.iter().any(|existing| existing == token) {
410 chain.push(token.to_string());
411 }
412 }
413 requirements.push(CssFallbackChain {
414 tokens: chain,
415 has_literal_fallback: has_safe_literal_fallback,
416 });
417 }
418 return Ok(index);
419 }
420 }
421 b'{' => {
422 if let Some(frame) = frames.last_mut() {
423 if frame.after_fallback_comma {
424 frame.has_literal_fallback = true;
425 }
426 }
427 brace_depth += 1;
428 index += 1;
429 }
430 b'}' => {
431 if brace_depth == 0 {
432 return Err(ParserError::Css(format!(
433 "Unterminated CSS var() call {}. Add the missing `)` before the closing `}}`.",
434 css_loc(source, start)
435 )));
436 }
437 brace_depth -= 1;
438 index += 1;
439 }
440 b'[' => {
441 if let Some(frame) = frames.last_mut() {
442 if frame.after_fallback_comma {
443 frame.has_literal_fallback = true;
444 }
445 }
446 bracket_depth += 1;
447 index += 1;
448 }
449 b']' => {
450 if bracket_depth == 0 {
451 return Err(ParserError::Css(format!(
452 "Unexpected CSS closing bracket inside var() {}. Remove the extra `]` or add a matching opening `[`.",
453 css_loc(source, index)
454 )));
455 }
456 bracket_depth -= 1;
457 index += 1;
458 }
459 b'-' if bytes.get(index + 1) == Some(&b'-') => {
460 if let Some((name, end)) = parse_custom_property_name(source, index) {
461 if let Some(frame) = frames.last_mut() {
462 frame.tokens.push(name);
463 }
464 index = end;
465 } else {
466 index += 1;
467 }
468 }
469 _ => {
470 if let Some(frame) = frames.last_mut() {
471 if frame.after_fallback_comma && !bytes[index].is_ascii_whitespace() {
472 frame.has_literal_fallback = true;
473 }
474 }
475 index += 1;
476 }
477 }
478 }
479
480 if quote != 0 {
481 return Err(ParserError::Css(format!(
482 "Unterminated CSS string literal inside var() {}. Close the `{}` quote before building.",
483 css_loc(source, start),
484 char::from(quote)
485 )));
486 }
487
488 Err(ParserError::Css(format!(
489 "Unterminated CSS var() call {}. Add the missing `)` before building.",
490 css_loc(source, start)
491 )))
492}
493
494fn parse_custom_property_name(source: &str, start: usize) -> Option<(&str, usize)> {
495 let bytes = source.as_bytes();
496 if bytes.get(start..start + 2) != Some(b"--") {
497 return None;
498 }
499
500 let mut end = start + 2;
501 while end < bytes.len()
502 && (bytes[end].is_ascii_alphanumeric() || matches!(bytes[end], b'_' | b'-'))
503 {
504 end += 1;
505 }
506
507 (end > start + 2).then(|| (&source[start + 2..end], end))
508}
509
510fn is_custom_property_definition(source: &str, name_end: usize) -> bool {
511 let bytes = source.as_bytes();
512 let mut cursor = name_end;
513 while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
514 cursor += 1;
515 }
516 if bytes.get(cursor) != Some(&b':') {
517 return false;
518 }
519
520 let mut before = name_end;
522 while before > 0
523 && (bytes[before - 1].is_ascii_alphanumeric() || matches!(bytes[before - 1], b'_' | b'-'))
524 {
525 before -= 1;
526 }
527 before = skip_ws_and_block_comments_back(source, before);
532
533 before == 0 || matches!(bytes[before - 1], b'{' | b';')
534}
535
536fn skip_ws_and_block_comments_back(source: &str, mut pos: usize) -> usize {
542 let bytes = source.as_bytes();
543 loop {
544 while pos > 0 && bytes[pos - 1].is_ascii_whitespace() {
545 pos -= 1;
546 }
547 if pos >= 2 && &source[pos - 2..pos] == "*/" {
549 match source[..pos - 2].rfind("/*") {
550 Some(open) => pos = open,
551 None => break,
554 }
555 } else {
556 break;
557 }
558 }
559 pos
560}
561
562fn removable_ranges(comments: &[CssComment]) -> Vec<(usize, usize)> {
563 let mut ranges = Vec::with_capacity(comments.len());
564 for comment in comments {
565 if !comment.preserve {
566 ranges.push((comment.start_byte, comment.end_byte));
567 }
568 }
569 ranges
570}
571
572impl Default for CssParser {
573 fn default() -> Self {
574 Self::new()
575 }
576}
577
578#[cfg(test)]
579#[allow(clippy::disallowed_methods)]
580mod tests {
581 use super::*;
582
583 #[test]
584 fn test_extract_single_var() {
585 let mut parser = CssParser::new();
586 let tokens = parser
587 .extract_tokens(".btn { color: var(--colorPrimary); }")
588 .expect("extract_tokens failed");
589 assert_eq!(tokens, HashSet::from(["colorPrimary".to_string()]));
590 }
591
592 #[test]
593 fn test_extract_multiple_vars() {
594 let css = r#"
595 .btn {
596 color: var(--textColor);
597 background: var(--bgColor);
598 border-radius: var(--radius);
599 }
600 "#;
601 let mut parser = CssParser::new();
602 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
603 assert_eq!(
604 tokens,
605 HashSet::from([
606 "textColor".to_string(),
607 "bgColor".to_string(),
608 "radius".to_string(),
609 ])
610 );
611 }
612
613 #[test]
614 fn test_extract_nested_fallback() {
615 let css = ".x { color: var(--primary, var(--fallback)); }";
616 let mut parser = CssParser::new();
617 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
618 assert_eq!(
619 tokens,
620 HashSet::from(["primary".to_string(), "fallback".to_string()])
621 );
622 }
623
624 #[test]
625 fn test_extract_deeply_nested_fallbacks() {
626 let css = ".x { color: var(--a, var(--b, var(--c))); }";
627 let mut parser = CssParser::new();
628 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
629 assert_eq!(
630 tokens,
631 HashSet::from(["a".to_string(), "b".to_string(), "c".to_string()])
632 );
633 }
634
635 #[test]
636 fn test_extract_nested_fallbacks_excludes_locally_defined_tokens() {
637 let css = r#"
638 :root { --token-a: red; }
639 .x { --foo-bar: var(--token-a, var(--token-b, var(--token-c))); }
640 "#;
641 let mut parser = CssParser::new();
642 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
643 assert_eq!(
644 tokens,
645 HashSet::from(["token-b".to_string(), "token-c".to_string()])
646 );
647 }
648
649 #[test]
650 fn test_literal_fallback_ignored() {
651 let css = ".x { font-size: var(--size, 16px); }";
652 let mut parser = CssParser::new();
653 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
654 assert_eq!(tokens, HashSet::from(["size".to_string()]));
655 }
656
657 #[test]
658 fn test_nested_var_without_literal_keeps_chain_required() {
659 let css = ".x { margin: var(--gap, calc(var(--spacing-m) * 2)); }";
660 let (_tokens, _definitions, _comments, requirements) =
661 scan_css(css, LegalComments::Inline, false).expect("scan failed");
662
663 assert_eq!(requirements.len(), 1);
664 assert_eq!(requirements[0].tokens, vec!["gap", "spacing-m"]);
665 assert!(
666 !requirements[0].has_literal_fallback,
667 "calc(var(--spacing-m) * 2) is not a safe literal fallback"
668 );
669 }
670
671 #[test]
672 fn test_nested_var_with_literal_keeps_chain_optional() {
673 let css = ".x { margin: var(--gap, calc(var(--spacing-m, 1px) * 2)); }";
674 let (_tokens, _definitions, _comments, requirements) =
675 scan_css(css, LegalComments::Inline, false).expect("scan failed");
676
677 assert_eq!(requirements.len(), 1);
678 assert_eq!(requirements[0].tokens, vec!["gap", "spacing-m"]);
679 assert!(
680 requirements[0].has_literal_fallback,
681 "calc(var(--spacing-m, 1px) * 2) has a safe literal fallback"
682 );
683 }
684
685 #[test]
686 fn test_var_fallback_block_comment_ignored() {
687 let css = ".x { color: var(--primary, /* --debug-only */ red); }";
688 let mut parser = CssParser::new();
689 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
690 assert_eq!(tokens, HashSet::from(["primary".to_string()]));
691 }
692
693 #[test]
694 fn test_var_fallback_line_comment_ignored() {
695 let css = ".x { color: var(--primary,\n // --debug-only\n red); }";
696 let mut parser = CssParser::new();
697 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
698 assert_eq!(tokens, HashSet::from(["primary".to_string()]));
699 }
700
701 #[test]
702 fn test_exclude_local_definitions() {
703 let css = r#"
704 :root { --bar: 12px; }
705 .x { width: var(--bar); }
706 "#;
707 let mut parser = CssParser::new();
708 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
709 assert!(
710 tokens.is_empty(),
711 "Locally defined --bar should be excluded: {tokens:?}"
712 );
713 }
714
715 #[test]
716 fn test_exclude_definitions_keep_unrelated_usages() {
717 let css = r#"
718 :host { --local: 5px; }
719 .x { color: var(--external); }
720 "#;
721 let mut parser = CssParser::new();
722 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
723 assert_eq!(tokens, HashSet::from(["external".to_string()]));
724 }
725
726 #[test]
727 fn test_definition_after_block_comment_is_recognized() {
728 let css = ":root {\n /*{{{tokens.light}}}*/\n --foo-bar: 100px;\n}";
731 let mut parser = CssParser::new();
732 let defs = parser
733 .extract_definitions(css)
734 .expect("extract_definitions failed");
735 assert!(
736 defs.contains("foo-bar"),
737 "definition after a comment should be recognized: {defs:?}"
738 );
739 }
740
741 #[test]
742 fn test_definition_after_comment_excludes_matching_usage() {
743 let css = ":root {\n /* note */ --foo-bar: 100px;\n}\n.x { padding: var(--foo-bar); }";
746 let mut parser = CssParser::new();
747 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
748 assert!(
749 tokens.is_empty(),
750 "--foo-bar is defined locally and must not be hoisted: {tokens:?}"
751 );
752 }
753
754 #[test]
755 fn test_empty_css() {
756 let mut parser = CssParser::new();
757 let tokens = parser.extract_tokens("").expect("extract_tokens failed");
758 assert!(tokens.is_empty());
759 }
760
761 #[test]
762 fn test_no_var_calls() {
763 let css = "body { margin: 0; color: red; }";
764 let mut parser = CssParser::new();
765 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
766 assert!(tokens.is_empty());
767 }
768
769 #[test]
770 fn test_deduplicate_same_var() {
771 let css = r#"
772 .a { color: var(--shared); }
773 .b { background: var(--shared); }
774 "#;
775 let mut parser = CssParser::new();
776 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
777 assert_eq!(tokens, HashSet::from(["shared".to_string()]));
778 }
779
780 #[test]
781 fn test_unterminated_var_call_returns_error() {
782 let css = ".bad { color: var(--dangling; } .ok { color: var(--valid); }";
783 let mut parser = CssParser::new();
784 let err = parser
785 .extract_tokens(css)
786 .expect_err("unterminated var() should fail");
787 assert!(matches!(err, ParserError::Css(message) if
788 message.contains("Unterminated CSS var() call")
789 ));
790 }
791
792 #[test]
793 fn test_unmatched_var_fallback_delimiter_returns_error() {
794 let css = ".bad { color: var(--accent, [red); }";
795 let mut parser = CssParser::new();
796 let err = parser
797 .extract_tokens(css)
798 .expect_err("unmatched var() fallback delimiter should fail");
799 assert!(matches!(err, ParserError::Css(message) if
800 message.contains("Unterminated CSS bracket expression inside var()")
801 ));
802 }
803
804 #[test]
805 fn test_balanced_var_fallback_delimiters_extract_tokens() {
806 let css = ".ok { color: var(--accent, color(display-p3 1 0 0 / 1)); }";
807 let mut parser = CssParser::new();
808 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
809 assert_eq!(tokens, HashSet::from(["accent".to_string()]));
810 }
811
812 #[test]
813 fn test_unterminated_comment_returns_error() {
814 let css = ".ok { color: var(--valid); } /* .bad { color: var(--ignored); }";
815 let mut parser = CssParser::new();
816 let err = parser
817 .extract_tokens(css)
818 .expect_err("unterminated comment should fail");
819 assert!(matches!(err, ParserError::Css(message) if
820 message.contains("Unterminated CSS block comment")
821 ));
822 }
823
824 #[test]
825 fn test_unterminated_string_returns_error() {
826 let css = r#".bad { content: "dangling; }"#;
827 let mut parser = CssParser::new();
828 let err = parser
829 .extract_tokens(css)
830 .expect_err("unterminated string should fail");
831 assert!(matches!(err, ParserError::Css(message) if
832 message.contains("Unterminated CSS string literal")
833 ));
834 }
835
836 #[test]
837 fn test_comments_are_collected_and_stripped() {
838 let mut parser = CssParser::new();
839 let (_tokens, _defs, _requirements, stripped) = parser
840 .extract_tokens_definitions_requirements_and_strip_comments(
841 "/* remove */.x{color:var(--a)}/*! keep */",
842 LegalComments::Inline,
843 )
844 .expect("scan failed");
845 assert_eq!(stripped, ".x{color:var(--a)}/*! keep */");
846 }
847}