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;
10
11/// Parser for CSS files.
12pub struct CssParser;
13
14/// Format a byte `offset` into CSS `source` as `at line L, column C`.
15///
16/// CSS is parsed as an extracted chunk (a component `.css` file or inline
17/// `<style>` body) with no enclosing-file context, so the position is relative
18/// to that chunk — still far more actionable than a raw byte offset.
19fn css_loc(source: &str, offset: usize) -> String {
20    let (line, column) = crate::diagnostic::line_column(source, offset);
21    format!("at line {line}, column {column}")
22}
23
24#[derive(Debug, Clone, Copy)]
25pub(crate) struct CssComment {
26    pub start_byte: usize,
27    pub end_byte: usize,
28    pub preserve: bool,
29}
30
31impl fmt::Debug for CssParser {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        f.debug_struct("CssParser").finish()
34    }
35}
36
37impl CssParser {
38    /// Create a new CSS parser.
39    pub fn new() -> Self {
40        Self
41    }
42
43    /// Extract CSS custom property token names used via `var()` in the given CSS.
44    pub fn extract_tokens(&mut self, css_content: &str) -> Result<HashSet<String>> {
45        let (mut tokens, definitions, _comments) =
46            scan_css(css_content, LegalComments::Inline, false)?;
47        tokens.retain(|t| !definitions.contains(t));
48        Ok(tokens)
49    }
50
51    /// Extract CSS custom property definitions from the given CSS.
52    pub fn extract_definitions(&mut self, css_content: &str) -> Result<HashSet<String>> {
53        let (_tokens, definitions, _comments) =
54            scan_css(css_content, LegalComments::Inline, false)?;
55        Ok(definitions)
56    }
57
58    /// Extract both token usages and definitions in a single scan.
59    pub fn extract_tokens_and_definitions(
60        &mut self,
61        css_content: &str,
62    ) -> Result<(HashSet<String>, HashSet<String>)> {
63        let (mut tokens, definitions, _comments) =
64            scan_css(css_content, LegalComments::Inline, false)?;
65        tokens.retain(|t| !definitions.contains(t));
66        Ok((tokens, definitions))
67    }
68
69    /// Extract tokens, definitions, and CSS with removable comments stripped in one scan.
70    pub(crate) fn extract_tokens_definitions_and_strip_comments<'a>(
71        &mut self,
72        css_content: &'a str,
73        legal_comments: LegalComments,
74    ) -> Result<(HashSet<String>, HashSet<String>, Cow<'a, str>)> {
75        let (mut tokens, definitions, comments) = scan_css(css_content, legal_comments, true)?;
76        tokens.retain(|t| !definitions.contains(t));
77        let mut comment_ranges = removable_ranges(&comments);
78        let stripped = comment_policy::strip_ranges(css_content, comment_ranges.as_mut_slice());
79        Ok((tokens, definitions, stripped))
80    }
81
82    /// Extract tokens, definitions, and CSS comments in one scan.
83    pub(crate) fn extract_tokens_definitions_and_comments(
84        &mut self,
85        css_content: &str,
86        legal_comments: LegalComments,
87    ) -> Result<(HashSet<String>, HashSet<String>, Vec<CssComment>)> {
88        let (mut tokens, definitions, mut comments) = scan_css(css_content, legal_comments, true)?;
89        tokens.retain(|t| !definitions.contains(t));
90        comments.sort_unstable_by_key(|comment| comment.start_byte);
91        Ok((tokens, definitions, comments))
92    }
93}
94
95fn scan_css(
96    source: &str,
97    legal_comments: LegalComments,
98    collect_comments: bool,
99) -> Result<(HashSet<String>, HashSet<String>, Vec<CssComment>)> {
100    let bytes = source.as_bytes();
101    let mut tokens = HashSet::new();
102    let mut definitions = HashSet::new();
103    let mut comments = Vec::new();
104    let mut index = 0usize;
105    let mut quote: u8 = 0;
106    let mut brace_depth = 0usize;
107    let mut paren_depth = 0usize;
108    let mut bracket_depth = 0usize;
109
110    while index < bytes.len() {
111        if quote != 0 {
112            if bytes[index] == b'\\' {
113                index = (index + 2).min(bytes.len());
114                continue;
115            }
116            if bytes[index] == quote {
117                quote = 0;
118            }
119            index += 1;
120            continue;
121        }
122
123        match bytes[index] {
124            b'"' | b'\'' => {
125                quote = bytes[index];
126                index += 1;
127            }
128            b'/' if index + 1 < bytes.len() && bytes[index + 1] == b'*' => {
129                let Some(offset) = source[index + 2..].find("*/") else {
130                    return Err(ParserError::Css(format!(
131                        "Unterminated CSS block comment {}. Close the comment with `*/` before building.",
132                        css_loc(source, index)
133                    )));
134                };
135                let end = index + 2 + offset + 2;
136                if collect_comments {
137                    let comment = &source[index..end];
138                    comments.push(CssComment {
139                        start_byte: index,
140                        end_byte: end,
141                        preserve: comment_policy::should_preserve_css_comment(
142                            comment,
143                            legal_comments,
144                        ),
145                    });
146                }
147                index = end;
148            }
149            b'/' if comment_policy::is_css_line_comment_start(source, index) => {
150                let end = comment_policy::find_css_line_comment_end(source, index + 2);
151                if collect_comments {
152                    let comment = &source[index..end];
153                    comments.push(CssComment {
154                        start_byte: index,
155                        end_byte: end,
156                        preserve: comment_policy::should_preserve_css_comment(
157                            comment,
158                            legal_comments,
159                        ),
160                    });
161                }
162                index = end;
163            }
164            b'-' if bytes.get(index + 1) == Some(&b'-') => {
165                if let Some((name, end)) = parse_custom_property_name(source, index) {
166                    if is_custom_property_definition(source, end) {
167                        definitions.insert(name.to_string());
168                    }
169                    index = end;
170                } else {
171                    index += 1;
172                }
173            }
174            b'v' if source[index..].starts_with("var(") => {
175                index = scan_var_call(source, index, &mut tokens)?;
176            }
177            b'{' => {
178                brace_depth += 1;
179                index += 1;
180            }
181            b'}' => {
182                if brace_depth == 0 {
183                    return Err(ParserError::Css(format!(
184                        "Unexpected CSS closing brace {}. Remove the extra `}}` or add a matching opening `{{`.",
185                        css_loc(source, index)
186                    )));
187                }
188                brace_depth -= 1;
189                index += 1;
190            }
191            b'(' => {
192                paren_depth += 1;
193                index += 1;
194            }
195            b')' => {
196                if paren_depth == 0 {
197                    return Err(ParserError::Css(format!(
198                        "Unexpected CSS closing parenthesis {}. Remove the extra `)` or add a matching opening `(`.",
199                        css_loc(source, index)
200                    )));
201                }
202                paren_depth -= 1;
203                index += 1;
204            }
205            b'[' => {
206                bracket_depth += 1;
207                index += 1;
208            }
209            b']' => {
210                if bracket_depth == 0 {
211                    return Err(ParserError::Css(format!(
212                        "Unexpected CSS closing bracket {}. Remove the extra `]` or add a matching opening `[`.",
213                        css_loc(source, index)
214                    )));
215                }
216                bracket_depth -= 1;
217                index += 1;
218            }
219            _ => index += 1,
220        }
221    }
222
223    if quote != 0 {
224        return Err(ParserError::Css(format!(
225            "Unterminated CSS string literal. Close the `{}` quote before building.",
226            char::from(quote)
227        )));
228    }
229    if brace_depth != 0 {
230        return Err(ParserError::Css(
231            "Unterminated CSS rule block. Add the missing `}` before building.".to_string(),
232        ));
233    }
234    if paren_depth != 0 {
235        return Err(ParserError::Css(
236            "Unterminated CSS parenthesized expression. Add the missing `)` before building."
237                .to_string(),
238        ));
239    }
240    if bracket_depth != 0 {
241        return Err(ParserError::Css(
242            "Unterminated CSS bracket expression. Add the missing `]` before building.".to_string(),
243        ));
244    }
245
246    Ok((tokens, definitions, comments))
247}
248
249fn scan_var_call(source: &str, start: usize, tokens: &mut HashSet<String>) -> Result<usize> {
250    let bytes = source.as_bytes();
251    let mut index = start + 4;
252    let mut depth = 1usize;
253    let mut brace_depth = 0usize;
254    let mut bracket_depth = 0usize;
255    let mut quote: u8 = 0;
256    let mut pending_tokens: Vec<&str> = Vec::new();
257
258    while index < bytes.len() {
259        if quote != 0 {
260            if bytes[index] == b'\\' {
261                index = (index + 2).min(bytes.len());
262                continue;
263            }
264            if bytes[index] == quote {
265                quote = 0;
266            }
267            index += 1;
268            continue;
269        }
270
271        match bytes[index] {
272            b'"' | b'\'' => {
273                quote = bytes[index];
274                index += 1;
275            }
276            b'/' if index + 1 < bytes.len() && bytes[index + 1] == b'*' => {
277                let Some(offset) = source[index + 2..].find("*/") else {
278                    return Err(ParserError::Css(format!(
279                        "Unterminated CSS block comment inside var() {}. Close the comment with `*/` before building.",
280                        css_loc(source, index)
281                    )));
282                };
283                index += 2 + offset + 2;
284            }
285            b'/' if comment_policy::is_css_line_comment_start(source, index) => {
286                index = comment_policy::find_css_line_comment_end(source, index + 2);
287            }
288            b'(' => {
289                depth += 1;
290                index += 1;
291            }
292            b')' => {
293                depth = depth.saturating_sub(1);
294                index += 1;
295                if depth == 0 {
296                    if brace_depth != 0 {
297                        return Err(ParserError::Css(format!(
298                            "Unterminated CSS brace expression inside var() {}. Add the missing `}}` before building.",
299                            css_loc(source, start)
300                        )));
301                    }
302                    if bracket_depth != 0 {
303                        return Err(ParserError::Css(format!(
304                            "Unterminated CSS bracket expression inside var() {}. Add the missing `]` before building.",
305                            css_loc(source, start)
306                        )));
307                    }
308                    for token in pending_tokens {
309                        tokens.insert(token.to_string());
310                    }
311                    return Ok(index);
312                }
313            }
314            b'{' => {
315                brace_depth += 1;
316                index += 1;
317            }
318            b'}' => {
319                if brace_depth == 0 {
320                    return Err(ParserError::Css(format!(
321                        "Unterminated CSS var() call {}. Add the missing `)` before the closing `}}`.",
322                        css_loc(source, start)
323                    )));
324                }
325                brace_depth -= 1;
326                index += 1;
327            }
328            b'[' => {
329                bracket_depth += 1;
330                index += 1;
331            }
332            b']' => {
333                if bracket_depth == 0 {
334                    return Err(ParserError::Css(format!(
335                        "Unexpected CSS closing bracket inside var() {}. Remove the extra `]` or add a matching opening `[`.",
336                        css_loc(source, index)
337                    )));
338                }
339                bracket_depth -= 1;
340                index += 1;
341            }
342            b'-' if bytes.get(index + 1) == Some(&b'-') => {
343                if let Some((name, end)) = parse_custom_property_name(source, index) {
344                    pending_tokens.push(name);
345                    index = end;
346                } else {
347                    index += 1;
348                }
349            }
350            _ => index += 1,
351        }
352    }
353
354    if quote != 0 {
355        return Err(ParserError::Css(format!(
356            "Unterminated CSS string literal inside var() {}. Close the `{}` quote before building.",
357            css_loc(source, start),
358            char::from(quote)
359        )));
360    }
361
362    Err(ParserError::Css(format!(
363        "Unterminated CSS var() call {}. Add the missing `)` before building.",
364        css_loc(source, start)
365    )))
366}
367
368fn parse_custom_property_name(source: &str, start: usize) -> Option<(&str, usize)> {
369    let bytes = source.as_bytes();
370    if bytes.get(start..start + 2) != Some(b"--") {
371        return None;
372    }
373
374    let mut end = start + 2;
375    while end < bytes.len()
376        && (bytes[end].is_ascii_alphanumeric() || matches!(bytes[end], b'_' | b'-'))
377    {
378        end += 1;
379    }
380
381    (end > start + 2).then(|| (&source[start + 2..end], end))
382}
383
384fn is_custom_property_definition(source: &str, name_end: usize) -> bool {
385    let bytes = source.as_bytes();
386    let mut cursor = name_end;
387    while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
388        cursor += 1;
389    }
390    if bytes.get(cursor) != Some(&b':') {
391        return false;
392    }
393
394    let mut before = name_end.saturating_sub(1);
395    while before > 0 && bytes[before - 1].is_ascii_whitespace() {
396        before -= 1;
397    }
398    while before > 0
399        && (bytes[before - 1].is_ascii_alphanumeric() || matches!(bytes[before - 1], b'_' | b'-'))
400    {
401        before -= 1;
402    }
403    while before > 0 && bytes[before - 1].is_ascii_whitespace() {
404        before -= 1;
405    }
406
407    before == 0 || matches!(bytes[before - 1], b'{' | b';')
408}
409
410fn removable_ranges(comments: &[CssComment]) -> Vec<(usize, usize)> {
411    let mut ranges = Vec::with_capacity(comments.len());
412    for comment in comments {
413        if !comment.preserve {
414            ranges.push((comment.start_byte, comment.end_byte));
415        }
416    }
417    ranges
418}
419
420impl Default for CssParser {
421    fn default() -> Self {
422        Self::new()
423    }
424}
425
426#[cfg(test)]
427#[allow(clippy::disallowed_methods)]
428mod tests {
429    use super::*;
430
431    #[test]
432    fn test_extract_single_var() {
433        let mut parser = CssParser::new();
434        let tokens = parser
435            .extract_tokens(".btn { color: var(--colorPrimary); }")
436            .expect("extract_tokens failed");
437        assert_eq!(tokens, HashSet::from(["colorPrimary".to_string()]));
438    }
439
440    #[test]
441    fn test_extract_multiple_vars() {
442        let css = r#"
443            .btn {
444                color: var(--textColor);
445                background: var(--bgColor);
446                border-radius: var(--radius);
447            }
448        "#;
449        let mut parser = CssParser::new();
450        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
451        assert_eq!(
452            tokens,
453            HashSet::from([
454                "textColor".to_string(),
455                "bgColor".to_string(),
456                "radius".to_string(),
457            ])
458        );
459    }
460
461    #[test]
462    fn test_extract_nested_fallback() {
463        let css = ".x { color: var(--primary, var(--fallback)); }";
464        let mut parser = CssParser::new();
465        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
466        assert_eq!(
467            tokens,
468            HashSet::from(["primary".to_string(), "fallback".to_string()])
469        );
470    }
471
472    #[test]
473    fn test_extract_deeply_nested_fallbacks() {
474        let css = ".x { color: var(--a, var(--b, var(--c))); }";
475        let mut parser = CssParser::new();
476        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
477        assert_eq!(
478            tokens,
479            HashSet::from(["a".to_string(), "b".to_string(), "c".to_string()])
480        );
481    }
482
483    #[test]
484    fn test_literal_fallback_ignored() {
485        let css = ".x { font-size: var(--size, 16px); }";
486        let mut parser = CssParser::new();
487        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
488        assert_eq!(tokens, HashSet::from(["size".to_string()]));
489    }
490
491    #[test]
492    fn test_var_fallback_block_comment_ignored() {
493        let css = ".x { color: var(--primary, /* --debug-only */ red); }";
494        let mut parser = CssParser::new();
495        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
496        assert_eq!(tokens, HashSet::from(["primary".to_string()]));
497    }
498
499    #[test]
500    fn test_var_fallback_line_comment_ignored() {
501        let css = ".x { color: var(--primary,\n // --debug-only\n red); }";
502        let mut parser = CssParser::new();
503        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
504        assert_eq!(tokens, HashSet::from(["primary".to_string()]));
505    }
506
507    #[test]
508    fn test_exclude_local_definitions() {
509        let css = r#"
510            :root { --bar: 12px; }
511            .x { width: var(--bar); }
512        "#;
513        let mut parser = CssParser::new();
514        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
515        assert!(
516            tokens.is_empty(),
517            "Locally defined --bar should be excluded: {tokens:?}"
518        );
519    }
520
521    #[test]
522    fn test_exclude_definitions_keep_unrelated_usages() {
523        let css = r#"
524            :host { --local: 5px; }
525            .x { color: var(--external); }
526        "#;
527        let mut parser = CssParser::new();
528        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
529        assert_eq!(tokens, HashSet::from(["external".to_string()]));
530    }
531
532    #[test]
533    fn test_empty_css() {
534        let mut parser = CssParser::new();
535        let tokens = parser.extract_tokens("").expect("extract_tokens failed");
536        assert!(tokens.is_empty());
537    }
538
539    #[test]
540    fn test_no_var_calls() {
541        let css = "body { margin: 0; color: red; }";
542        let mut parser = CssParser::new();
543        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
544        assert!(tokens.is_empty());
545    }
546
547    #[test]
548    fn test_deduplicate_same_var() {
549        let css = r#"
550            .a { color: var(--shared); }
551            .b { background: var(--shared); }
552        "#;
553        let mut parser = CssParser::new();
554        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
555        assert_eq!(tokens, HashSet::from(["shared".to_string()]));
556    }
557
558    #[test]
559    fn test_unterminated_var_call_returns_error() {
560        let css = ".bad { color: var(--dangling; } .ok { color: var(--valid); }";
561        let mut parser = CssParser::new();
562        let err = parser
563            .extract_tokens(css)
564            .expect_err("unterminated var() should fail");
565        assert!(matches!(err, ParserError::Css(message) if
566            message.contains("Unterminated CSS var() call")
567        ));
568    }
569
570    #[test]
571    fn test_unmatched_var_fallback_delimiter_returns_error() {
572        let css = ".bad { color: var(--accent, [red); }";
573        let mut parser = CssParser::new();
574        let err = parser
575            .extract_tokens(css)
576            .expect_err("unmatched var() fallback delimiter should fail");
577        assert!(matches!(err, ParserError::Css(message) if
578            message.contains("Unterminated CSS bracket expression inside var()")
579        ));
580    }
581
582    #[test]
583    fn test_balanced_var_fallback_delimiters_extract_tokens() {
584        let css = ".ok { color: var(--accent, color(display-p3 1 0 0 / 1)); }";
585        let mut parser = CssParser::new();
586        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
587        assert_eq!(tokens, HashSet::from(["accent".to_string()]));
588    }
589
590    #[test]
591    fn test_unterminated_comment_returns_error() {
592        let css = ".ok { color: var(--valid); } /* .bad { color: var(--ignored); }";
593        let mut parser = CssParser::new();
594        let err = parser
595            .extract_tokens(css)
596            .expect_err("unterminated comment should fail");
597        assert!(matches!(err, ParserError::Css(message) if
598            message.contains("Unterminated CSS block comment")
599        ));
600    }
601
602    #[test]
603    fn test_unterminated_string_returns_error() {
604        let css = r#".bad { content: "dangling; }"#;
605        let mut parser = CssParser::new();
606        let err = parser
607            .extract_tokens(css)
608            .expect_err("unterminated string should fail");
609        assert!(matches!(err, ParserError::Css(message) if
610            message.contains("Unterminated CSS string literal")
611        ));
612    }
613
614    #[test]
615    fn test_comments_are_collected_and_stripped() {
616        let mut parser = CssParser::new();
617        let (_tokens, _defs, stripped) = parser
618            .extract_tokens_definitions_and_strip_comments(
619                "/* remove */.x{color:var(--a)}/*! keep */",
620                LegalComments::Inline,
621            )
622            .expect("scan failed");
623        assert_eq!(stripped, ".x{color:var(--a)}/*! keep */");
624    }
625}