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::{ParserError, Result};
10use std::collections::HashSet;
11use std::fmt;
12use tree_sitter::{Node, Parser, Tree};
13use webui_protocol::WebUIFragmentRecords;
14
15use tree_sitter_css::LANGUAGE;
16
17/// Parser for CSS files.
18pub struct CssParser {
19    /// Tree-sitter parser for CSS.
20    parser: Parser,
21}
22
23impl fmt::Debug for CssParser {
24    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25        f.debug_struct("CssParser").finish()
26    }
27}
28
29impl CssParser {
30    /// Create a new CSS parser.
31    pub fn new() -> Self {
32        let mut parser = Parser::new();
33        parser
34            .set_language(&LANGUAGE.into())
35            .expect("Error loading CSS grammar");
36
37        Self { parser }
38    }
39
40    /// Parse CSS content and return fragments.
41    pub fn parse(&mut self, css_content: &str) -> Result<WebUIFragmentRecords> {
42        // Parse CSS with tree-sitter
43        let _tree = self
44            .parser
45            .parse(css_content, None)
46            .ok_or_else(|| ParserError::Css("Failed to parse CSS".to_string()))?;
47
48        // Create empty fragments for now
49        // In a real implementation, would convert CSS to fragments
50        let fragments = WebUIFragmentRecords::new();
51
52        Ok(fragments)
53    }
54
55    /// Process CSS content and merge it into fragments.
56    pub fn process_css(
57        &mut self,
58        css_content: &str,
59        fragments: &mut WebUIFragmentRecords,
60    ) -> Result<()> {
61        // Parse CSS with tree-sitter
62        let tree = self
63            .parser
64            .parse(css_content, None)
65            .ok_or_else(|| ParserError::Css("Failed to parse CSS".to_string()))?;
66
67        // Extract and process CSS rules
68        self.process_css_rules(tree, css_content, fragments)?;
69
70        Ok(())
71    }
72
73    /// Process CSS rules from the parsed tree.
74    fn process_css_rules(
75        &self,
76        _tree: Tree,
77        _source: &str,
78        _fragments: &mut WebUIFragmentRecords,
79    ) -> Result<()> {
80        // This is a placeholder for CSS processing logic
81        // In a real implementation, you would:
82        // 1. Extract selectors and rules from the CSS
83        // 2. Associate styles with components
84        // 3. Update component fragments with the appropriate styles
85
86        // For now, we'll just return Ok as a placeholder
87        Ok(())
88    }
89
90    /// Parse inline CSS from style tags in HTML.
91    pub fn parse_inline_css(&mut self, style_content: &str) -> Result<String> {
92        // For now, returning content as-is — future transforms can hook in here.
93        // Validation parse is deferred to extract_tokens_and_definitions when
94        // called from the HtmlParser, avoiding a redundant tree-sitter parse.
95        Ok(style_content.to_string())
96    }
97
98    /// Extract CSS custom property token names used via `var()` in the given CSS.
99    ///
100    /// Returns a set of variable names (without the `--` prefix) that are
101    /// **referenced** through `var(--name)` calls. Variables that are only
102    /// **defined** (e.g., `--bar: 12px`) in the same CSS are excluded.
103    ///
104    /// Handles nested fallbacks: `var(--a, var(--b, var(--c)))` yields
105    /// `{"a", "b", "c"}` because each nested `var()` is a separate
106    /// `call_expression` in the tree-sitter CSS AST.
107    pub fn extract_tokens(&mut self, css_content: &str) -> Result<HashSet<String>> {
108        let tree = self
109            .parser
110            .parse(css_content, None)
111            .ok_or_else(|| ParserError::Css("Failed to parse CSS for token extraction".into()))?;
112
113        let mut tokens = HashSet::new();
114        let mut definitions = HashSet::new();
115
116        Self::walk_css_tree(tree.root_node(), css_content, &mut tokens, &mut definitions);
117
118        // Exclude locally-defined custom properties
119        tokens.retain(|t| !definitions.contains(t));
120        Ok(tokens)
121    }
122
123    /// Extract CSS custom property **definitions** from the given CSS.
124    ///
125    /// Returns a set of variable names (without `--` prefix) that are
126    /// **defined** via `--name: value` declarations. This is used to
127    /// exclude application-level token definitions (e.g., from `<style>`
128    /// in the entry HTML) from the hoisted token set.
129    pub fn extract_definitions(&mut self, css_content: &str) -> Result<HashSet<String>> {
130        let tree = self
131            .parser
132            .parse(css_content, None)
133            .ok_or_else(|| ParserError::Css("Failed to parse CSS for definitions".into()))?;
134
135        let mut tokens = HashSet::new();
136        let mut definitions = HashSet::new();
137
138        Self::walk_css_tree(tree.root_node(), css_content, &mut tokens, &mut definitions);
139
140        Ok(definitions)
141    }
142
143    /// Extract both token **usages** and **definitions** in a single parse.
144    ///
145    /// Returns `(tokens, definitions)` where:
146    /// - `tokens`: var() usages with locally-defined properties already excluded
147    /// - `definitions`: all custom property definitions (for global filtering)
148    ///
149    /// Prefer this over calling `extract_tokens` + `extract_definitions`
150    /// separately on the same CSS content to avoid redundant tree-sitter parses.
151    pub fn extract_tokens_and_definitions(
152        &mut self,
153        css_content: &str,
154    ) -> Result<(HashSet<String>, HashSet<String>)> {
155        let tree = self
156            .parser
157            .parse(css_content, None)
158            .ok_or_else(|| ParserError::Css("Failed to parse CSS".into()))?;
159
160        let mut tokens = HashSet::new();
161        let mut definitions = HashSet::new();
162
163        Self::walk_css_tree(tree.root_node(), css_content, &mut tokens, &mut definitions);
164
165        // Exclude locally-defined custom properties from tokens
166        tokens.retain(|t| !definitions.contains(t));
167        Ok((tokens, definitions))
168    }
169
170    /// Iteratively walk the CSS tree to collect var() usages and custom
171    /// property definitions. Uses an explicit stack instead of recursion.
172    fn walk_css_tree(
173        root: Node<'_>,
174        source: &str,
175        tokens: &mut HashSet<String>,
176        definitions: &mut HashSet<String>,
177    ) {
178        let mut stack = vec![root];
179
180        while let Some(node) = stack.pop() {
181            match node.kind() {
182                "call_expression" => {
183                    Self::extract_var_tokens(node, source, tokens);
184                }
185                "declaration" => {
186                    Self::collect_custom_property_definition(node, source, definitions);
187                }
188                _ => {}
189            }
190
191            // Push children in reverse order for left-to-right traversal
192            let count = node.child_count();
193            for i in (0..count).rev() {
194                if let Some(child) = node.child(i as u32) {
195                    stack.push(child);
196                }
197            }
198        }
199    }
200
201    /// If `node` is a `var()` call expression, extract its `plain_value`
202    /// arguments as token names (stripping the `--` prefix).
203    fn extract_var_tokens(node: Node<'_>, source: &str, tokens: &mut HashSet<String>) {
204        let count = node.child_count();
205        let is_var = (0..count).any(|i| {
206            node.child(i as u32).is_some_and(|c| {
207                c.kind() == "function_name" && &source[c.start_byte()..c.end_byte()] == "var"
208            })
209        });
210
211        if !is_var {
212            return;
213        }
214
215        // Extract plain_value children — the CSS variable references
216        let arguments =
217            (0..count).find_map(|i| node.child(i as u32).filter(|c| c.kind() == "arguments"));
218
219        if let Some(args) = arguments {
220            let arg_count = args.child_count();
221            for i in 0..arg_count {
222                if let Some(child) = args.child(i as u32) {
223                    if child.kind() == "plain_value" {
224                        let name = &source[child.start_byte()..child.end_byte()];
225                        if let Some(stripped) = name.strip_prefix("--") {
226                            tokens.insert(stripped.to_string());
227                        }
228                    }
229                }
230            }
231        }
232    }
233
234    /// If `node` is a declaration with a custom property name (starting
235    /// with `--`), record it in the definitions set.
236    fn collect_custom_property_definition(
237        node: Node<'_>,
238        source: &str,
239        definitions: &mut HashSet<String>,
240    ) {
241        if let Some(prop_node) = node.child(0) {
242            if prop_node.kind() == "property_name" {
243                let prop = &source[prop_node.start_byte()..prop_node.end_byte()];
244                if let Some(stripped) = prop.strip_prefix("--") {
245                    definitions.insert(stripped.to_string());
246                }
247            }
248        }
249    }
250}
251
252impl Default for CssParser {
253    fn default() -> Self {
254        Self::new()
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use std::collections::HashMap;
262
263    #[test]
264    fn test_parse_simple_css() {
265        let css = r#"
266            body {
267                margin: 0;
268                padding: 0;
269            }
270            
271            .container {
272                width: 100%;
273                max-width: 1200px;
274                margin: 0 auto;
275            }
276        "#;
277
278        let mut parser = CssParser::new();
279        let result = parser.process_css(css, &mut HashMap::new());
280        assert!(result.is_ok());
281    }
282
283    // ── extract_tokens tests ────────────────────────────────────────
284
285    #[test]
286    fn test_extract_single_var() {
287        let mut parser = CssParser::new();
288        let tokens = parser
289            .extract_tokens(".btn { color: var(--colorPrimary); }")
290            .expect("extract_tokens failed");
291        assert_eq!(tokens, HashSet::from(["colorPrimary".to_string()]));
292    }
293
294    #[test]
295    fn test_extract_multiple_vars() {
296        let css = r#"
297            .btn {
298                color: var(--textColor);
299                background: var(--bgColor);
300                border-radius: var(--radius);
301            }
302        "#;
303        let mut parser = CssParser::new();
304        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
305        assert_eq!(
306            tokens,
307            HashSet::from([
308                "textColor".to_string(),
309                "bgColor".to_string(),
310                "radius".to_string(),
311            ])
312        );
313    }
314
315    #[test]
316    fn test_extract_nested_fallback() {
317        let css = ".x { color: var(--primary, var(--fallback)); }";
318        let mut parser = CssParser::new();
319        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
320        assert_eq!(
321            tokens,
322            HashSet::from(["primary".to_string(), "fallback".to_string()])
323        );
324    }
325
326    #[test]
327    fn test_extract_deeply_nested_fallbacks() {
328        let css = ".x { color: var(--a, var(--b, var(--c))); }";
329        let mut parser = CssParser::new();
330        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
331        assert_eq!(
332            tokens,
333            HashSet::from(["a".to_string(), "b".to_string(), "c".to_string()])
334        );
335    }
336
337    #[test]
338    fn test_literal_fallback_ignored() {
339        let css = ".x { font-size: var(--size, 16px); }";
340        let mut parser = CssParser::new();
341        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
342        assert_eq!(tokens, HashSet::from(["size".to_string()]));
343    }
344
345    #[test]
346    fn test_exclude_local_definitions() {
347        let css = r#"
348            :root { --bar: 12px; }
349            .x { width: var(--bar); }
350        "#;
351        let mut parser = CssParser::new();
352        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
353        assert!(
354            tokens.is_empty(),
355            "Locally defined --bar should be excluded: {tokens:?}"
356        );
357    }
358
359    #[test]
360    fn test_exclude_definitions_keep_unrelated_usages() {
361        let css = r#"
362            :host { --local: 5px; }
363            .x { color: var(--external); }
364        "#;
365        let mut parser = CssParser::new();
366        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
367        assert_eq!(tokens, HashSet::from(["external".to_string()]));
368    }
369
370    #[test]
371    fn test_empty_css() {
372        let mut parser = CssParser::new();
373        let tokens = parser.extract_tokens("").expect("extract_tokens failed");
374        assert!(tokens.is_empty());
375    }
376
377    #[test]
378    fn test_no_var_calls() {
379        let css = "body { margin: 0; color: red; }";
380        let mut parser = CssParser::new();
381        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
382        assert!(tokens.is_empty());
383    }
384
385    #[test]
386    fn test_deduplicate_same_var() {
387        let css = r#"
388            .a { color: var(--shared); }
389            .b { background: var(--shared); }
390        "#;
391        let mut parser = CssParser::new();
392        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
393        assert_eq!(tokens, HashSet::from(["shared".to_string()]));
394    }
395
396    #[test]
397    fn test_real_world_component_css() {
398        let css = r#"
399            :host {
400                display: inline-flex;
401                background-color: var(--colorBrandBackground);
402                border-radius: var(--borderRadiusSmall);
403                padding: var(--spacingHorizontalM) var(--spacingVerticalS);
404                font-family: var(--fontFamilyBase);
405                line-height: var(--lineHeightBase400);
406            }
407            :host(:hover) {
408                background-color: var(--colorBrandBackgroundHover);
409            }
410        "#;
411        let mut parser = CssParser::new();
412        let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
413        assert_eq!(
414            tokens,
415            HashSet::from([
416                "colorBrandBackground".to_string(),
417                "borderRadiusSmall".to_string(),
418                "spacingHorizontalM".to_string(),
419                "spacingVerticalS".to_string(),
420                "fontFamilyBase".to_string(),
421                "lineHeightBase400".to_string(),
422                "colorBrandBackgroundHover".to_string(),
423            ])
424        );
425    }
426
427    #[test]
428    fn test_extract_tokens_and_definitions() {
429        let css = r#"
430            :root { --brand: #0078d4; --radius: 6px; }
431            .btn { color: var(--brand); margin: var(--external); }
432        "#;
433        let mut parser = CssParser::new();
434        let (tokens, defs) = parser.extract_tokens_and_definitions(css).expect("failed");
435
436        // tokens should exclude locally-defined --brand but keep --external
437        assert_eq!(tokens, HashSet::from(["external".to_string()]));
438        // definitions should include both defined properties
439        assert_eq!(
440            defs,
441            HashSet::from(["brand".to_string(), "radius".to_string()])
442        );
443    }
444
445    #[test]
446    fn test_extract_definitions_only() {
447        let css = ":root { --color-primary: #0078d4; --spacing-m: 12px; }";
448        let mut parser = CssParser::new();
449        let defs = parser.extract_definitions(css).expect("failed");
450        assert_eq!(
451            defs,
452            HashSet::from(["color-primary".to_string(), "spacing-m".to_string()])
453        );
454    }
455
456    #[test]
457    fn test_extract_definitions_none_when_no_custom_props() {
458        let css = "body { margin: 0; color: red; }";
459        let mut parser = CssParser::new();
460        let defs = parser.extract_definitions(css).expect("failed");
461        assert!(defs.is_empty());
462    }
463
464    #[test]
465    fn test_extract_tokens_malformed_var_missing_dashes() {
466        // var(-primary) is not a valid custom property reference (needs --)
467        let css = ".x { color: var(-primary); }";
468        let mut parser = CssParser::new();
469        let tokens = parser.extract_tokens(css).expect("failed");
470        // Should not extract since it doesn't start with --
471        assert!(
472            tokens.is_empty(),
473            "Single-dash var should not be extracted: {tokens:?}"
474        );
475    }
476
477    #[test]
478    fn test_extract_tokens_empty_var() {
479        let css = ".x { color: var(); }";
480        let mut parser = CssParser::new();
481        let tokens = parser.extract_tokens(css).expect("failed");
482        assert!(tokens.is_empty());
483    }
484
485    #[test]
486    fn test_extract_tokens_definitions_only_css() {
487        // CSS that only defines custom properties, no var() usage
488        let css = ":root { --a: 1px; --b: 2px; --c: red; }";
489        let mut parser = CssParser::new();
490        let tokens = parser.extract_tokens(css).expect("failed");
491        assert!(
492            tokens.is_empty(),
493            "Definitions-only CSS should yield no tokens"
494        );
495    }
496}