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