1use 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
18pub struct CssParser {
20 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 pub fn new() -> Self {
47 let mut parser = Parser::new();
48 #[allow(clippy::disallowed_methods)]
50 parser
51 .set_language(&LANGUAGE.into())
52 .expect("Error loading CSS grammar");
53
54 Self { parser }
55 }
56
57 pub fn parse(&mut self, css_content: &str) -> Result<WebUIFragmentRecords> {
59 let _tree = self
61 .parser
62 .parse(css_content, None)
63 .ok_or_else(|| ParserError::Css("Failed to parse CSS".to_string()))?;
64
65 let fragments = WebUIFragmentRecords::new();
68
69 Ok(fragments)
70 }
71
72 pub fn process_css(
74 &mut self,
75 css_content: &str,
76 fragments: &mut WebUIFragmentRecords,
77 ) -> Result<()> {
78 let tree = self
80 .parser
81 .parse(css_content, None)
82 .ok_or_else(|| ParserError::Css("Failed to parse CSS".to_string()))?;
83
84 self.process_css_rules(tree, css_content, fragments)?;
86
87 Ok(())
88 }
89
90 fn process_css_rules(
92 &self,
93 _tree: Tree,
94 _source: &str,
95 _fragments: &mut WebUIFragmentRecords,
96 ) -> Result<()> {
97 Ok(())
105 }
106
107 pub fn parse_inline_css(&mut self, style_content: &str) -> Result<String> {
109 Ok(style_content.to_string())
113 }
114
115 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 tokens.retain(|t| !definitions.contains(t));
143 Ok(tokens)
144 }
145
146 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 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 tokens.retain(|t| !definitions.contains(t));
202 Ok((tokens, definitions))
203 }
204
205 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 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 #[allow(clippy::cast_possible_truncation)] 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 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 #[allow(clippy::cast_possible_truncation)] 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 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 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 #[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 assert_eq!(tokens, HashSet::from(["external".to_string()]));
557 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 let css = ".x { color: var(-primary); }";
624 let mut parser = CssParser::new();
625 let tokens = parser.extract_tokens(css).expect("failed");
626 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 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}