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 #[allow(clippy::disallowed_methods)]
35 parser
36 .set_language(&LANGUAGE.into())
37 .expect("Error loading CSS grammar");
38
39 Self { parser }
40 }
41
42 pub fn parse(&mut self, css_content: &str) -> Result<WebUIFragmentRecords> {
44 let _tree = self
46 .parser
47 .parse(css_content, None)
48 .ok_or_else(|| ParserError::Css("Failed to parse CSS".to_string()))?;
49
50 let fragments = WebUIFragmentRecords::new();
53
54 Ok(fragments)
55 }
56
57 pub fn process_css(
59 &mut self,
60 css_content: &str,
61 fragments: &mut WebUIFragmentRecords,
62 ) -> Result<()> {
63 let tree = self
65 .parser
66 .parse(css_content, None)
67 .ok_or_else(|| ParserError::Css("Failed to parse CSS".to_string()))?;
68
69 self.process_css_rules(tree, css_content, fragments)?;
71
72 Ok(())
73 }
74
75 fn process_css_rules(
77 &self,
78 _tree: Tree,
79 _source: &str,
80 _fragments: &mut WebUIFragmentRecords,
81 ) -> Result<()> {
82 Ok(())
90 }
91
92 pub fn parse_inline_css(&mut self, style_content: &str) -> Result<String> {
94 Ok(style_content.to_string())
98 }
99
100 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 tokens.retain(|t| !definitions.contains(t));
122 Ok(tokens)
123 }
124
125 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 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 tokens.retain(|t| !definitions.contains(t));
169 Ok((tokens, definitions))
170 }
171
172 #[allow(clippy::cast_possible_truncation)] 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 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 #[allow(clippy::cast_possible_truncation)] 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 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 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 #[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 assert_eq!(tokens, HashSet::from(["external".to_string()]));
443 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 let css = ".x { color: var(-primary); }";
473 let mut parser = CssParser::new();
474 let tokens = parser.extract_tokens(css).expect("failed");
475 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 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}