Skip to main content

webui_parser/
css_parser.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! CSS parser for WebUI components.
5//!
6//! This module uses tree-sitter-css to parse CSS files
7//! and process styles for components.
8
9use crate::{comment_policy, LegalComments, ParserError, Result};
10use std::borrow::Cow;
11use std::collections::HashSet;
12use std::fmt;
13use tree_sitter::{Node, Parser, Tree};
14use webui_protocol::WebUIFragmentRecords;
15
16use tree_sitter_css::LANGUAGE;
17
18/// Parser for CSS files.
19pub struct CssParser {
20    /// Tree-sitter parser for CSS.
21    parser: Parser,
22}
23
24struct CssWalkContext<'a> {
25    tokens: &'a mut HashSet<String>,
26    definitions: &'a mut HashSet<String>,
27    comments: Option<&'a mut Vec<CssComment>>,
28    legal_comments: LegalComments,
29}
30
31#[derive(Debug, Clone, Copy)]
32pub(crate) struct CssComment {
33    pub start_byte: usize,
34    pub end_byte: usize,
35    pub preserve: bool,
36}
37
38impl fmt::Debug for CssParser {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        f.debug_struct("CssParser").finish()
41    }
42}
43
44impl CssParser {
45    /// Create a new CSS parser.
46    pub fn new() -> Self {
47        let mut parser = Parser::new();
48        // Grammar is statically linked — set_language cannot fail at runtime.
49        #[allow(clippy::disallowed_methods)]
50        parser
51            .set_language(&LANGUAGE.into())
52            .expect("Error loading CSS grammar");
53
54        Self { parser }
55    }
56
57    /// Parse CSS content and return fragments.
58    pub fn parse(&mut self, css_content: &str) -> Result<WebUIFragmentRecords> {
59        // Parse CSS with tree-sitter
60        let _tree = self
61            .parser
62            .parse(css_content, None)
63            .ok_or_else(|| ParserError::Css("Failed to parse CSS".to_string()))?;
64
65        // Create empty fragments for now
66        // In a real implementation, would convert CSS to fragments
67        let fragments = WebUIFragmentRecords::new();
68
69        Ok(fragments)
70    }
71
72    /// Process CSS content and merge it into fragments.
73    pub fn process_css(
74        &mut self,
75        css_content: &str,
76        fragments: &mut WebUIFragmentRecords,
77    ) -> Result<()> {
78        // Parse CSS with tree-sitter
79        let tree = self
80            .parser
81            .parse(css_content, None)
82            .ok_or_else(|| ParserError::Css("Failed to parse CSS".to_string()))?;
83
84        // Extract and process CSS rules
85        self.process_css_rules(tree, css_content, fragments)?;
86
87        Ok(())
88    }
89
90    /// Process CSS rules from the parsed tree.
91    fn process_css_rules(
92        &self,
93        _tree: Tree,
94        _source: &str,
95        _fragments: &mut WebUIFragmentRecords,
96    ) -> Result<()> {
97        // This is a placeholder for CSS processing logic
98        // In a real implementation, you would:
99        // 1. Extract selectors and rules from the CSS
100        // 2. Associate styles with components
101        // 3. Update component fragments with the appropriate styles
102
103        // For now, we'll just return Ok as a placeholder
104        Ok(())
105    }
106
107    /// Parse inline CSS from style tags in HTML.
108    pub fn parse_inline_css(&mut self, style_content: &str) -> Result<String> {
109        // For now, returning content as-is — future transforms can hook in here.
110        // Validation parse is deferred to extract_tokens_and_definitions when
111        // called from the HtmlParser, avoiding a redundant tree-sitter parse.
112        Ok(style_content.to_string())
113    }
114
115    /// Extract CSS custom property token names used via `var()` in the given CSS.
116    ///
117    /// Returns a set of variable names (without the `--` prefix) that are
118    /// **referenced** through `var(--name)` calls. Variables that are only
119    /// **defined** (e.g., `--bar: 12px`) in the same CSS are excluded.
120    ///
121    /// Handles nested fallbacks: `var(--a, var(--b, var(--c)))` yields
122    /// `{"a", "b", "c"}` because each nested `var()` is a separate
123    /// `call_expression` in the tree-sitter CSS AST.
124    pub fn extract_tokens(&mut self, css_content: &str) -> Result<HashSet<String>> {
125        let tree = self
126            .parser
127            .parse(css_content, None)
128            .ok_or_else(|| ParserError::Css("Failed to parse CSS for token extraction".into()))?;
129
130        let mut tokens = HashSet::new();
131        let mut definitions = HashSet::new();
132
133        let mut context = CssWalkContext {
134            tokens: &mut tokens,
135            definitions: &mut definitions,
136            comments: None,
137            legal_comments: LegalComments::Inline,
138        };
139        Self::walk_css_tree(tree.root_node(), css_content, &mut context);
140
141        // Exclude locally-defined custom properties
142        tokens.retain(|t| !definitions.contains(t));
143        Ok(tokens)
144    }
145
146    /// Extract CSS custom property **definitions** from the given CSS.
147    ///
148    /// Returns a set of variable names (without `--` prefix) that are
149    /// **defined** via `--name: value` declarations. This is used to
150    /// exclude application-level token definitions (e.g., from `<style>`
151    /// in the entry HTML) from the hoisted token set.
152    pub fn extract_definitions(&mut self, css_content: &str) -> Result<HashSet<String>> {
153        let tree = self
154            .parser
155            .parse(css_content, None)
156            .ok_or_else(|| ParserError::Css("Failed to parse CSS for definitions".into()))?;
157
158        let mut tokens = HashSet::new();
159        let mut definitions = HashSet::new();
160
161        let mut context = CssWalkContext {
162            tokens: &mut tokens,
163            definitions: &mut definitions,
164            comments: None,
165            legal_comments: LegalComments::Inline,
166        };
167        Self::walk_css_tree(tree.root_node(), css_content, &mut context);
168
169        Ok(definitions)
170    }
171
172    /// Extract both token **usages** and **definitions** in a single parse.
173    ///
174    /// Returns `(tokens, definitions)` where:
175    /// - `tokens`: var() usages with locally-defined properties already excluded
176    /// - `definitions`: all custom property definitions (for global filtering)
177    ///
178    /// Prefer this over calling `extract_tokens` + `extract_definitions`
179    /// separately on the same CSS content to avoid redundant tree-sitter parses.
180    pub fn extract_tokens_and_definitions(
181        &mut self,
182        css_content: &str,
183    ) -> Result<(HashSet<String>, HashSet<String>)> {
184        let tree = self
185            .parser
186            .parse(css_content, None)
187            .ok_or_else(|| ParserError::Css("Failed to parse CSS".into()))?;
188
189        let mut tokens = HashSet::new();
190        let mut definitions = HashSet::new();
191
192        let mut context = CssWalkContext {
193            tokens: &mut tokens,
194            definitions: &mut definitions,
195            comments: None,
196            legal_comments: LegalComments::Inline,
197        };
198        Self::walk_css_tree(tree.root_node(), css_content, &mut context);
199
200        // Exclude locally-defined custom properties from tokens
201        tokens.retain(|t| !definitions.contains(t));
202        Ok((tokens, definitions))
203    }
204
205    /// Extract tokens, definitions, and CSS with removable comments stripped in one parse.
206    pub(crate) fn extract_tokens_definitions_and_strip_comments<'a>(
207        &mut self,
208        css_content: &'a str,
209        legal_comments: LegalComments,
210    ) -> Result<(HashSet<String>, HashSet<String>, Cow<'a, str>)> {
211        let tree = self
212            .parser
213            .parse(css_content, None)
214            .ok_or_else(|| ParserError::Css("Failed to parse CSS".into()))?;
215
216        let mut tokens = HashSet::new();
217        let mut definitions = HashSet::new();
218        let mut comments = Vec::new();
219
220        let mut context = CssWalkContext {
221            tokens: &mut tokens,
222            definitions: &mut definitions,
223            comments: Some(&mut comments),
224            legal_comments,
225        };
226        Self::walk_css_tree(tree.root_node(), css_content, &mut context);
227
228        tokens.retain(|t| !definitions.contains(t));
229        let mut comment_ranges = removable_ranges(&comments);
230        let stripped = comment_policy::strip_ranges(css_content, comment_ranges.as_mut_slice());
231        Ok((tokens, definitions, stripped))
232    }
233
234    /// Extract tokens, definitions, and CSS comments in one parse.
235    pub(crate) fn extract_tokens_definitions_and_comments(
236        &mut self,
237        css_content: &str,
238        legal_comments: LegalComments,
239    ) -> Result<(HashSet<String>, HashSet<String>, Vec<CssComment>)> {
240        let tree = self
241            .parser
242            .parse(css_content, None)
243            .ok_or_else(|| ParserError::Css("Failed to parse CSS".into()))?;
244
245        let mut tokens = HashSet::new();
246        let mut definitions = HashSet::new();
247        let mut comments = Vec::new();
248
249        let mut context = CssWalkContext {
250            tokens: &mut tokens,
251            definitions: &mut definitions,
252            comments: Some(&mut comments),
253            legal_comments,
254        };
255        Self::walk_css_tree(tree.root_node(), css_content, &mut context);
256
257        tokens.retain(|t| !definitions.contains(t));
258        comments.sort_unstable_by_key(|comment| comment.start_byte);
259        Ok((tokens, definitions, comments))
260    }
261
262    /// Iteratively walk the CSS tree to collect var() usages and custom
263    /// property definitions. Uses an explicit stack instead of recursion.
264    #[allow(clippy::cast_possible_truncation)] // tree-sitter child indices are u32
265    fn walk_css_tree(root: Node<'_>, source: &str, context: &mut CssWalkContext<'_>) {
266        let mut stack = vec![root];
267
268        while let Some(node) = stack.pop() {
269            match node.kind() {
270                "call_expression" => {
271                    Self::extract_var_tokens(node, source, context.tokens);
272                }
273                "declaration" => {
274                    Self::collect_custom_property_definition(node, source, context.definitions);
275                }
276                kind if comment_policy::is_css_comment_node(kind) => {
277                    if let Some(comments) = context.comments.as_deref_mut() {
278                        Self::push_comment(source, node, context.legal_comments, comments);
279                    }
280                }
281                _ => {}
282            }
283
284            // Push children in reverse order for left-to-right traversal
285            let count = node.child_count();
286            for i in (0..count).rev() {
287                if let Some(child) = node.child(i as u32) {
288                    stack.push(child);
289                }
290            }
291        }
292    }
293
294    fn push_comment(
295        source: &str,
296        node: Node<'_>,
297        legal_comments: LegalComments,
298        comments: &mut Vec<CssComment>,
299    ) {
300        let comment = &source[node.start_byte()..node.end_byte()];
301        comments.push(CssComment {
302            start_byte: node.start_byte(),
303            end_byte: node.end_byte(),
304            preserve: comment_policy::should_preserve_css_comment(comment, legal_comments),
305        });
306    }
307
308    /// If `node` is a `var()` call expression, extract its `plain_value`
309    /// arguments as token names (stripping the `--` prefix).
310    #[allow(clippy::cast_possible_truncation)] // tree-sitter child indices are u32
311    fn extract_var_tokens(node: Node<'_>, source: &str, tokens: &mut HashSet<String>) {
312        let count = node.child_count();
313        let is_var = (0..count).any(|i| {
314            node.child(i as u32).is_some_and(|c| {
315                c.kind() == "function_name" && &source[c.start_byte()..c.end_byte()] == "var"
316            })
317        });
318
319        if !is_var {
320            return;
321        }
322
323        // Extract plain_value children — the CSS variable references
324        let arguments =
325            (0..count).find_map(|i| node.child(i as u32).filter(|c| c.kind() == "arguments"));
326
327        if let Some(args) = arguments {
328            let arg_count = args.child_count();
329            for i in 0..arg_count {
330                if let Some(child) = args.child(i as u32) {
331                    if child.kind() == "plain_value" {
332                        let name = &source[child.start_byte()..child.end_byte()];
333                        if let Some(stripped) = name.strip_prefix("--") {
334                            tokens.insert(stripped.to_string());
335                        }
336                    }
337                }
338            }
339        }
340    }
341
342    /// If `node` is a declaration with a custom property name (starting
343    /// with `--`), record it in the definitions set.
344    fn collect_custom_property_definition(
345        node: Node<'_>,
346        source: &str,
347        definitions: &mut HashSet<String>,
348    ) {
349        if let Some(prop_node) = node.child(0) {
350            if prop_node.kind() == "property_name" {
351                let prop = &source[prop_node.start_byte()..prop_node.end_byte()];
352                if let Some(stripped) = prop.strip_prefix("--") {
353                    definitions.insert(stripped.to_string());
354                }
355            }
356        }
357    }
358}
359
360fn removable_ranges(comments: &[CssComment]) -> Vec<(usize, usize)> {
361    let mut ranges = Vec::with_capacity(comments.len());
362    for comment in comments {
363        if !comment.preserve {
364            ranges.push((comment.start_byte, comment.end_byte));
365        }
366    }
367    ranges
368}
369
370impl Default for CssParser {
371    fn default() -> Self {
372        Self::new()
373    }
374}
375
376#[cfg(test)]
377#[allow(clippy::disallowed_methods)]
378mod tests {
379    use super::*;
380    use std::collections::HashMap;
381
382    #[test]
383    fn test_parse_simple_css() {
384        let css = r#"
385            body {
386                margin: 0;
387                padding: 0;
388            }
389            
390            .container {
391                width: 100%;
392                max-width: 1200px;
393                margin: 0 auto;
394            }
395        "#;
396
397        let mut parser = CssParser::new();
398        let result = parser.process_css(css, &mut HashMap::new());
399        assert!(result.is_ok());
400    }
401
402    // ── extract_tokens tests ────────────────────────────────────────
403
404    #[test]
405    fn test_extract_single_var() {
406        let mut parser = CssParser::new();
407        let tokens = parser
408            .extract_tokens(".btn { color: var(--colorPrimary); }")
409            .expect("extract_tokens failed");
410        assert_eq!(tokens, HashSet::from(["colorPrimary".to_string()]));
411    }
412
413    #[test]
414    fn test_extract_multiple_vars() {
415        let css = r#"
416            .btn {
417                color: var(--textColor);
418                background: var(--bgColor);
419                border-radius: var(--radius);
420            }
421        "#;
422        let mut parser = CssParser::new();
423        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
424        assert_eq!(
425            tokens,
426            HashSet::from([
427                "textColor".to_string(),
428                "bgColor".to_string(),
429                "radius".to_string(),
430            ])
431        );
432    }
433
434    #[test]
435    fn test_extract_nested_fallback() {
436        let css = ".x { color: var(--primary, var(--fallback)); }";
437        let mut parser = CssParser::new();
438        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
439        assert_eq!(
440            tokens,
441            HashSet::from(["primary".to_string(), "fallback".to_string()])
442        );
443    }
444
445    #[test]
446    fn test_extract_deeply_nested_fallbacks() {
447        let css = ".x { color: var(--a, var(--b, var(--c))); }";
448        let mut parser = CssParser::new();
449        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
450        assert_eq!(
451            tokens,
452            HashSet::from(["a".to_string(), "b".to_string(), "c".to_string()])
453        );
454    }
455
456    #[test]
457    fn test_literal_fallback_ignored() {
458        let css = ".x { font-size: var(--size, 16px); }";
459        let mut parser = CssParser::new();
460        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
461        assert_eq!(tokens, HashSet::from(["size".to_string()]));
462    }
463
464    #[test]
465    fn test_exclude_local_definitions() {
466        let css = r#"
467            :root { --bar: 12px; }
468            .x { width: var(--bar); }
469        "#;
470        let mut parser = CssParser::new();
471        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
472        assert!(
473            tokens.is_empty(),
474            "Locally defined --bar should be excluded: {tokens:?}"
475        );
476    }
477
478    #[test]
479    fn test_exclude_definitions_keep_unrelated_usages() {
480        let css = r#"
481            :host { --local: 5px; }
482            .x { color: var(--external); }
483        "#;
484        let mut parser = CssParser::new();
485        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
486        assert_eq!(tokens, HashSet::from(["external".to_string()]));
487    }
488
489    #[test]
490    fn test_empty_css() {
491        let mut parser = CssParser::new();
492        let tokens = parser.extract_tokens("").expect("extract_tokens failed");
493        assert!(tokens.is_empty());
494    }
495
496    #[test]
497    fn test_no_var_calls() {
498        let css = "body { margin: 0; color: red; }";
499        let mut parser = CssParser::new();
500        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
501        assert!(tokens.is_empty());
502    }
503
504    #[test]
505    fn test_deduplicate_same_var() {
506        let css = r#"
507            .a { color: var(--shared); }
508            .b { background: var(--shared); }
509        "#;
510        let mut parser = CssParser::new();
511        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
512        assert_eq!(tokens, HashSet::from(["shared".to_string()]));
513    }
514
515    #[test]
516    fn test_real_world_component_css() {
517        let css = r#"
518            :host {
519                display: inline-flex;
520                background-color: var(--colorBrandBackground);
521                border-radius: var(--borderRadiusSmall);
522                padding: var(--spacingHorizontalM) var(--spacingVerticalS);
523                font-family: var(--fontFamilyBase);
524                line-height: var(--lineHeightBase400);
525            }
526            :host(:hover) {
527                background-color: var(--colorBrandBackgroundHover);
528            }
529        "#;
530        let mut parser = CssParser::new();
531        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
532        assert_eq!(
533            tokens,
534            HashSet::from([
535                "colorBrandBackground".to_string(),
536                "borderRadiusSmall".to_string(),
537                "spacingHorizontalM".to_string(),
538                "spacingVerticalS".to_string(),
539                "fontFamilyBase".to_string(),
540                "lineHeightBase400".to_string(),
541                "colorBrandBackgroundHover".to_string(),
542            ])
543        );
544    }
545
546    #[test]
547    fn test_extract_tokens_and_definitions() {
548        let css = r#"
549            :root { --brand: #0078d4; --radius: 6px; }
550            .btn { color: var(--brand); margin: var(--external); }
551        "#;
552        let mut parser = CssParser::new();
553        let (tokens, defs) = parser.extract_tokens_and_definitions(css).expect("failed");
554
555        // tokens should exclude locally-defined --brand but keep --external
556        assert_eq!(tokens, HashSet::from(["external".to_string()]));
557        // definitions should include both defined properties
558        assert_eq!(
559            defs,
560            HashSet::from(["brand".to_string(), "radius".to_string()])
561        );
562    }
563
564    #[test]
565    fn test_strip_line_css_comments() {
566        let css = "// var(--ignored)\n.btn { color: var(--textColor); }";
567        let mut parser = CssParser::new();
568        let (tokens, _defs, stripped) = parser
569            .extract_tokens_definitions_and_strip_comments(css, LegalComments::Inline)
570            .expect("failed");
571
572        assert_eq!(tokens, HashSet::from(["textColor".to_string()]));
573        assert_eq!(stripped.as_ref(), "\n.btn { color: var(--textColor); }");
574    }
575
576    #[test]
577    fn test_preserve_legal_line_css_comments_by_default() {
578        let css = "//! @license MIT\n.btn { color: red; }\n// remove";
579        let mut parser = CssParser::new();
580        let (_tokens, _defs, stripped) = parser
581            .extract_tokens_definitions_and_strip_comments(css, LegalComments::Inline)
582            .expect("failed");
583
584        assert_eq!(
585            stripped.as_ref(),
586            "//! @license MIT\n.btn { color: red; }\n"
587        );
588    }
589
590    #[test]
591    fn test_strip_legal_line_css_comments_when_disabled() {
592        let css = "//! @license MIT\n.btn { color: red; }";
593        let mut parser = CssParser::new();
594        let (_tokens, _defs, stripped) = parser
595            .extract_tokens_definitions_and_strip_comments(css, LegalComments::None)
596            .expect("failed");
597
598        assert_eq!(stripped.as_ref(), "\n.btn { color: red; }");
599    }
600
601    #[test]
602    fn test_extract_definitions_only() {
603        let css = ":root { --color-primary: #0078d4; --spacing-m: 12px; }";
604        let mut parser = CssParser::new();
605        let defs = parser.extract_definitions(css).expect("failed");
606        assert_eq!(
607            defs,
608            HashSet::from(["color-primary".to_string(), "spacing-m".to_string()])
609        );
610    }
611
612    #[test]
613    fn test_extract_definitions_none_when_no_custom_props() {
614        let css = "body { margin: 0; color: red; }";
615        let mut parser = CssParser::new();
616        let defs = parser.extract_definitions(css).expect("failed");
617        assert!(defs.is_empty());
618    }
619
620    #[test]
621    fn test_extract_tokens_malformed_var_missing_dashes() {
622        // var(-primary) is not a valid custom property reference (needs --)
623        let css = ".x { color: var(-primary); }";
624        let mut parser = CssParser::new();
625        let tokens = parser.extract_tokens(css).expect("failed");
626        // Should not extract since it doesn't start with --
627        assert!(
628            tokens.is_empty(),
629            "Single-dash var should not be extracted: {tokens:?}"
630        );
631    }
632
633    #[test]
634    fn test_extract_tokens_empty_var() {
635        let css = ".x { color: var(); }";
636        let mut parser = CssParser::new();
637        let tokens = parser.extract_tokens(css).expect("failed");
638        assert!(tokens.is_empty());
639    }
640
641    #[test]
642    fn test_extract_tokens_definitions_only_css() {
643        // CSS that only defines custom properties, no var() usage
644        let css = ":root { --a: 1px; --b: 2px; --c: red; }";
645        let mut parser = CssParser::new();
646        let tokens = parser.extract_tokens(css).expect("failed");
647        assert!(
648            tokens.is_empty(),
649            "Definitions-only CSS should yield no tokens"
650        );
651    }
652}