Skip to main content

webui_parser/
css_parser.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! CSS scanner for WebUI components.
5
6use crate::{comment_policy, LegalComments, ParserError, Result};
7use std::borrow::Cow;
8use std::collections::HashSet;
9use std::fmt;
10use webui_tokens::CssFallbackChain;
11
12/// Parser for CSS files.
13pub 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
34/// Format a byte `offset` into CSS `source` as `at line L, column C`.
35///
36/// CSS is parsed as an extracted chunk (a component `.css` file or inline
37/// `<style>` body) with no enclosing-file context, so the position is relative
38/// to that chunk — still far more actionable than a raw byte offset.
39fn 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    /// Create a new CSS parser.
59    pub fn new() -> Self {
60        Self
61    }
62
63    /// Extract CSS custom property token names used via `var()` in the given CSS.
64    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    /// Extract CSS custom property definitions from the given CSS.
72    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    /// Extract both token usages and definitions in a single scan.
79    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    /// Extract tokens, definitions, and CSS with removable comments stripped in one scan.
90    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    /// Extract tokens, definitions, and CSS comments in one scan.
104    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    // Walk back over the property name itself (`--name`, where `-` is allowed).
521    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    // Skip whitespace and any preceding block comments so a property declared
528    // after a comment — e.g. a signal comment between `{` and the property,
529    // `:root { /*{{{tokens.light}}}*/ --foo: 1px; }` — is still recognized as a
530    // definition at the declaration boundary rather than a usage.
531    before = skip_ws_and_block_comments_back(source, before);
532
533    before == 0 || matches!(bytes[before - 1], b'{' | b';')
534}
535
536/// Skip whitespace and preceding CSS block comments (`/* … */`) backward from
537/// `pos`, returning the position of the first non-whitespace, non-comment byte.
538///
539/// Iterative; no regex, no recursion. Used by [`is_custom_property_definition`]
540/// to reach the real declaration boundary when a comment precedes a property.
541fn 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        // A preceding block comment ends with `*/` immediately before `pos`.
548        if pos >= 2 && &source[pos - 2..pos] == "*/" {
549            match source[..pos - 2].rfind("/*") {
550                Some(open) => pos = open,
551                // Unbalanced `*/` without a matching `/*`: stop here rather than
552                // scanning past the start of the input.
553                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        // A signal/comment between `{` and the property must not hide the
729        // definition: `--foo-bar` is declared here, not used.
730        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        // When the ancestor defines `--foo-bar` (after a comment), a later
744        // `var(--foo-bar)` usage in the same input is treated as resolved.
745        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}