webui_parser/
css_parser.rs1use 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
17pub struct CssParser {
19 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 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 pub fn parse(&mut self, css_content: &str) -> Result<WebUIFragmentRecords> {
42 let _tree = self
44 .parser
45 .parse(css_content, None)
46 .ok_or_else(|| ParserError::Css("Failed to parse CSS".to_string()))?;
47
48 let fragments = WebUIFragmentRecords::new();
51
52 Ok(fragments)
53 }
54
55 pub fn process_css(
57 &mut self,
58 css_content: &str,
59 fragments: &mut WebUIFragmentRecords,
60 ) -> Result<()> {
61 let tree = self
63 .parser
64 .parse(css_content, None)
65 .ok_or_else(|| ParserError::Css("Failed to parse CSS".to_string()))?;
66
67 self.process_css_rules(tree, css_content, fragments)?;
69
70 Ok(())
71 }
72
73 fn process_css_rules(
75 &self,
76 _tree: Tree,
77 _source: &str,
78 _fragments: &mut WebUIFragmentRecords,
79 ) -> Result<()> {
80 Ok(())
88 }
89
90 pub fn parse_inline_css(&mut self, style_content: &str) -> Result<String> {
92 Ok(style_content.to_string())
96 }
97
98 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 tokens.retain(|t| !definitions.contains(t));
120 Ok(tokens)
121 }
122
123 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 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 tokens.retain(|t| !definitions.contains(t));
167 Ok((tokens, definitions))
168 }
169
170 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 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 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 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 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 #[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 assert_eq!(tokens, HashSet::from(["external".to_string()]));
438 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 let css = ".x { color: var(-primary); }";
468 let mut parser = CssParser::new();
469 let tokens = parser.extract_tokens(css).expect("failed");
470 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 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}