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
15struct CssScanResult<'a> {
16    // Only unconditional root/host defaults can satisfy another rule or component.
17    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
54/// Format a byte `offset` into CSS `source` as `at line L, column C`.
55///
56/// CSS is parsed as an extracted chunk (a component `.css` file or inline
57/// `<style>` body) with no enclosing-file context, so the position is relative
58/// to that chunk — still far more actionable than a raw byte offset.
59fn 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    /// Create a new CSS parser.
79    pub fn new() -> Self {
80        Self
81    }
82
83    /// Extract CSS custom property token names used via `var()` in the given CSS.
84    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    /// Extract CSS custom property definitions from the given CSS.
89    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    /// Extract both token usages and definitions in a single scan.
94    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    // Extract inherited defaults and rule-local-filtered requirements in one scan.
104    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    // Extract inherited defaults, requirements, and comments in one scan.
123    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                // Top-level rules stay inline; only nested rules save a parent.
273                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    // Walk back over the property name itself (`--name`, where `-` is allowed).
660    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    // Skip whitespace and any preceding block comments so a property declared
667    // after a comment — e.g. a signal comment between `{` and the property,
668    // `:root { /*{{{tokens.light}}}*/ --foo: 1px; }` — is still recognized as a
669    // definition at the declaration boundary rather than a usage.
670    before = skip_ws_and_block_comments_back(source, before);
671
672    before == 0 || matches!(bytes[before - 1], b'{' | b';')
673}
674
675/// Skip whitespace and preceding CSS block comments (`/* … */`) backward from
676/// `pos`, returning the position of the first non-whitespace, non-comment byte.
677///
678/// Iterative; no regex, no recursion. Used by [`is_custom_property_definition`]
679/// to reach the real declaration boundary when a comment precedes a property.
680fn 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        // A preceding block comment ends with `*/` immediately before `pos`.
687        if pos >= 2 && &source[pos - 2..pos] == "*/" {
688            match source[..pos - 2].rfind("/*") {
689                Some(open) => pos = open,
690                // Unbalanced `*/` without a matching `/*`: stop here rather than
691                // scanning past the start of the input.
692                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        // A signal/comment between `{` and the property must not hide the
920        // definition: `--foo-bar` is declared here, not used.
921        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        // When the ancestor defines `--foo-bar` (after a comment), a later
935        // `var(--foo-bar)` usage in the same input is treated as resolved.
936        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}