1use crate::{comment_policy, LegalComments, ParserError, Result};
7use std::borrow::Cow;
8use std::collections::HashSet;
9use std::fmt;
10
11pub struct CssParser;
13
14fn css_loc(source: &str, offset: usize) -> String {
20 let (line, column) = crate::diagnostic::line_column(source, offset);
21 format!("at line {line}, column {column}")
22}
23
24#[derive(Debug, Clone, Copy)]
25pub(crate) struct CssComment {
26 pub start_byte: usize,
27 pub end_byte: usize,
28 pub preserve: bool,
29}
30
31impl fmt::Debug for CssParser {
32 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33 f.debug_struct("CssParser").finish()
34 }
35}
36
37impl CssParser {
38 pub fn new() -> Self {
40 Self
41 }
42
43 pub fn extract_tokens(&mut self, css_content: &str) -> Result<HashSet<String>> {
45 let (mut tokens, definitions, _comments) =
46 scan_css(css_content, LegalComments::Inline, false)?;
47 tokens.retain(|t| !definitions.contains(t));
48 Ok(tokens)
49 }
50
51 pub fn extract_definitions(&mut self, css_content: &str) -> Result<HashSet<String>> {
53 let (_tokens, definitions, _comments) =
54 scan_css(css_content, LegalComments::Inline, false)?;
55 Ok(definitions)
56 }
57
58 pub fn extract_tokens_and_definitions(
60 &mut self,
61 css_content: &str,
62 ) -> Result<(HashSet<String>, HashSet<String>)> {
63 let (mut tokens, definitions, _comments) =
64 scan_css(css_content, LegalComments::Inline, false)?;
65 tokens.retain(|t| !definitions.contains(t));
66 Ok((tokens, definitions))
67 }
68
69 pub(crate) fn extract_tokens_definitions_and_strip_comments<'a>(
71 &mut self,
72 css_content: &'a str,
73 legal_comments: LegalComments,
74 ) -> Result<(HashSet<String>, HashSet<String>, Cow<'a, str>)> {
75 let (mut tokens, definitions, comments) = scan_css(css_content, legal_comments, true)?;
76 tokens.retain(|t| !definitions.contains(t));
77 let mut comment_ranges = removable_ranges(&comments);
78 let stripped = comment_policy::strip_ranges(css_content, comment_ranges.as_mut_slice());
79 Ok((tokens, definitions, stripped))
80 }
81
82 pub(crate) fn extract_tokens_definitions_and_comments(
84 &mut self,
85 css_content: &str,
86 legal_comments: LegalComments,
87 ) -> Result<(HashSet<String>, HashSet<String>, Vec<CssComment>)> {
88 let (mut tokens, definitions, mut comments) = scan_css(css_content, legal_comments, true)?;
89 tokens.retain(|t| !definitions.contains(t));
90 comments.sort_unstable_by_key(|comment| comment.start_byte);
91 Ok((tokens, definitions, comments))
92 }
93}
94
95fn scan_css(
96 source: &str,
97 legal_comments: LegalComments,
98 collect_comments: bool,
99) -> Result<(HashSet<String>, HashSet<String>, Vec<CssComment>)> {
100 let bytes = source.as_bytes();
101 let mut tokens = HashSet::new();
102 let mut definitions = HashSet::new();
103 let mut comments = Vec::new();
104 let mut index = 0usize;
105 let mut quote: u8 = 0;
106 let mut brace_depth = 0usize;
107 let mut paren_depth = 0usize;
108 let mut bracket_depth = 0usize;
109
110 while index < bytes.len() {
111 if quote != 0 {
112 if bytes[index] == b'\\' {
113 index = (index + 2).min(bytes.len());
114 continue;
115 }
116 if bytes[index] == quote {
117 quote = 0;
118 }
119 index += 1;
120 continue;
121 }
122
123 match bytes[index] {
124 b'"' | b'\'' => {
125 quote = bytes[index];
126 index += 1;
127 }
128 b'/' if index + 1 < bytes.len() && bytes[index + 1] == b'*' => {
129 let Some(offset) = source[index + 2..].find("*/") else {
130 return Err(ParserError::Css(format!(
131 "Unterminated CSS block comment {}. Close the comment with `*/` before building.",
132 css_loc(source, index)
133 )));
134 };
135 let end = index + 2 + offset + 2;
136 if collect_comments {
137 let comment = &source[index..end];
138 comments.push(CssComment {
139 start_byte: index,
140 end_byte: end,
141 preserve: comment_policy::should_preserve_css_comment(
142 comment,
143 legal_comments,
144 ),
145 });
146 }
147 index = end;
148 }
149 b'/' if comment_policy::is_css_line_comment_start(source, index) => {
150 let end = comment_policy::find_css_line_comment_end(source, index + 2);
151 if collect_comments {
152 let comment = &source[index..end];
153 comments.push(CssComment {
154 start_byte: index,
155 end_byte: end,
156 preserve: comment_policy::should_preserve_css_comment(
157 comment,
158 legal_comments,
159 ),
160 });
161 }
162 index = end;
163 }
164 b'-' if bytes.get(index + 1) == Some(&b'-') => {
165 if let Some((name, end)) = parse_custom_property_name(source, index) {
166 if is_custom_property_definition(source, end) {
167 definitions.insert(name.to_string());
168 }
169 index = end;
170 } else {
171 index += 1;
172 }
173 }
174 b'v' if source[index..].starts_with("var(") => {
175 index = scan_var_call(source, index, &mut tokens)?;
176 }
177 b'{' => {
178 brace_depth += 1;
179 index += 1;
180 }
181 b'}' => {
182 if brace_depth == 0 {
183 return Err(ParserError::Css(format!(
184 "Unexpected CSS closing brace {}. Remove the extra `}}` or add a matching opening `{{`.",
185 css_loc(source, index)
186 )));
187 }
188 brace_depth -= 1;
189 index += 1;
190 }
191 b'(' => {
192 paren_depth += 1;
193 index += 1;
194 }
195 b')' => {
196 if paren_depth == 0 {
197 return Err(ParserError::Css(format!(
198 "Unexpected CSS closing parenthesis {}. Remove the extra `)` or add a matching opening `(`.",
199 css_loc(source, index)
200 )));
201 }
202 paren_depth -= 1;
203 index += 1;
204 }
205 b'[' => {
206 bracket_depth += 1;
207 index += 1;
208 }
209 b']' => {
210 if bracket_depth == 0 {
211 return Err(ParserError::Css(format!(
212 "Unexpected CSS closing bracket {}. Remove the extra `]` or add a matching opening `[`.",
213 css_loc(source, index)
214 )));
215 }
216 bracket_depth -= 1;
217 index += 1;
218 }
219 _ => index += 1,
220 }
221 }
222
223 if quote != 0 {
224 return Err(ParserError::Css(format!(
225 "Unterminated CSS string literal. Close the `{}` quote before building.",
226 char::from(quote)
227 )));
228 }
229 if brace_depth != 0 {
230 return Err(ParserError::Css(
231 "Unterminated CSS rule block. Add the missing `}` before building.".to_string(),
232 ));
233 }
234 if paren_depth != 0 {
235 return Err(ParserError::Css(
236 "Unterminated CSS parenthesized expression. Add the missing `)` before building."
237 .to_string(),
238 ));
239 }
240 if bracket_depth != 0 {
241 return Err(ParserError::Css(
242 "Unterminated CSS bracket expression. Add the missing `]` before building.".to_string(),
243 ));
244 }
245
246 Ok((tokens, definitions, comments))
247}
248
249fn scan_var_call(source: &str, start: usize, tokens: &mut HashSet<String>) -> Result<usize> {
250 let bytes = source.as_bytes();
251 let mut index = start + 4;
252 let mut depth = 1usize;
253 let mut brace_depth = 0usize;
254 let mut bracket_depth = 0usize;
255 let mut quote: u8 = 0;
256 let mut pending_tokens: Vec<&str> = Vec::new();
257
258 while index < bytes.len() {
259 if quote != 0 {
260 if bytes[index] == b'\\' {
261 index = (index + 2).min(bytes.len());
262 continue;
263 }
264 if bytes[index] == quote {
265 quote = 0;
266 }
267 index += 1;
268 continue;
269 }
270
271 match bytes[index] {
272 b'"' | b'\'' => {
273 quote = bytes[index];
274 index += 1;
275 }
276 b'/' if index + 1 < bytes.len() && bytes[index + 1] == b'*' => {
277 let Some(offset) = source[index + 2..].find("*/") else {
278 return Err(ParserError::Css(format!(
279 "Unterminated CSS block comment inside var() {}. Close the comment with `*/` before building.",
280 css_loc(source, index)
281 )));
282 };
283 index += 2 + offset + 2;
284 }
285 b'/' if comment_policy::is_css_line_comment_start(source, index) => {
286 index = comment_policy::find_css_line_comment_end(source, index + 2);
287 }
288 b'(' => {
289 depth += 1;
290 index += 1;
291 }
292 b')' => {
293 depth = depth.saturating_sub(1);
294 index += 1;
295 if depth == 0 {
296 if brace_depth != 0 {
297 return Err(ParserError::Css(format!(
298 "Unterminated CSS brace expression inside var() {}. Add the missing `}}` before building.",
299 css_loc(source, start)
300 )));
301 }
302 if bracket_depth != 0 {
303 return Err(ParserError::Css(format!(
304 "Unterminated CSS bracket expression inside var() {}. Add the missing `]` before building.",
305 css_loc(source, start)
306 )));
307 }
308 for token in pending_tokens {
309 tokens.insert(token.to_string());
310 }
311 return Ok(index);
312 }
313 }
314 b'{' => {
315 brace_depth += 1;
316 index += 1;
317 }
318 b'}' => {
319 if brace_depth == 0 {
320 return Err(ParserError::Css(format!(
321 "Unterminated CSS var() call {}. Add the missing `)` before the closing `}}`.",
322 css_loc(source, start)
323 )));
324 }
325 brace_depth -= 1;
326 index += 1;
327 }
328 b'[' => {
329 bracket_depth += 1;
330 index += 1;
331 }
332 b']' => {
333 if bracket_depth == 0 {
334 return Err(ParserError::Css(format!(
335 "Unexpected CSS closing bracket inside var() {}. Remove the extra `]` or add a matching opening `[`.",
336 css_loc(source, index)
337 )));
338 }
339 bracket_depth -= 1;
340 index += 1;
341 }
342 b'-' if bytes.get(index + 1) == Some(&b'-') => {
343 if let Some((name, end)) = parse_custom_property_name(source, index) {
344 pending_tokens.push(name);
345 index = end;
346 } else {
347 index += 1;
348 }
349 }
350 _ => index += 1,
351 }
352 }
353
354 if quote != 0 {
355 return Err(ParserError::Css(format!(
356 "Unterminated CSS string literal inside var() {}. Close the `{}` quote before building.",
357 css_loc(source, start),
358 char::from(quote)
359 )));
360 }
361
362 Err(ParserError::Css(format!(
363 "Unterminated CSS var() call {}. Add the missing `)` before building.",
364 css_loc(source, start)
365 )))
366}
367
368fn parse_custom_property_name(source: &str, start: usize) -> Option<(&str, usize)> {
369 let bytes = source.as_bytes();
370 if bytes.get(start..start + 2) != Some(b"--") {
371 return None;
372 }
373
374 let mut end = start + 2;
375 while end < bytes.len()
376 && (bytes[end].is_ascii_alphanumeric() || matches!(bytes[end], b'_' | b'-'))
377 {
378 end += 1;
379 }
380
381 (end > start + 2).then(|| (&source[start + 2..end], end))
382}
383
384fn is_custom_property_definition(source: &str, name_end: usize) -> bool {
385 let bytes = source.as_bytes();
386 let mut cursor = name_end;
387 while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
388 cursor += 1;
389 }
390 if bytes.get(cursor) != Some(&b':') {
391 return false;
392 }
393
394 let mut before = name_end.saturating_sub(1);
395 while before > 0 && bytes[before - 1].is_ascii_whitespace() {
396 before -= 1;
397 }
398 while before > 0
399 && (bytes[before - 1].is_ascii_alphanumeric() || matches!(bytes[before - 1], b'_' | b'-'))
400 {
401 before -= 1;
402 }
403 while before > 0 && bytes[before - 1].is_ascii_whitespace() {
404 before -= 1;
405 }
406
407 before == 0 || matches!(bytes[before - 1], b'{' | b';')
408}
409
410fn removable_ranges(comments: &[CssComment]) -> Vec<(usize, usize)> {
411 let mut ranges = Vec::with_capacity(comments.len());
412 for comment in comments {
413 if !comment.preserve {
414 ranges.push((comment.start_byte, comment.end_byte));
415 }
416 }
417 ranges
418}
419
420impl Default for CssParser {
421 fn default() -> Self {
422 Self::new()
423 }
424}
425
426#[cfg(test)]
427#[allow(clippy::disallowed_methods)]
428mod tests {
429 use super::*;
430
431 #[test]
432 fn test_extract_single_var() {
433 let mut parser = CssParser::new();
434 let tokens = parser
435 .extract_tokens(".btn { color: var(--colorPrimary); }")
436 .expect("extract_tokens failed");
437 assert_eq!(tokens, HashSet::from(["colorPrimary".to_string()]));
438 }
439
440 #[test]
441 fn test_extract_multiple_vars() {
442 let css = r#"
443 .btn {
444 color: var(--textColor);
445 background: var(--bgColor);
446 border-radius: var(--radius);
447 }
448 "#;
449 let mut parser = CssParser::new();
450 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
451 assert_eq!(
452 tokens,
453 HashSet::from([
454 "textColor".to_string(),
455 "bgColor".to_string(),
456 "radius".to_string(),
457 ])
458 );
459 }
460
461 #[test]
462 fn test_extract_nested_fallback() {
463 let css = ".x { color: var(--primary, var(--fallback)); }";
464 let mut parser = CssParser::new();
465 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
466 assert_eq!(
467 tokens,
468 HashSet::from(["primary".to_string(), "fallback".to_string()])
469 );
470 }
471
472 #[test]
473 fn test_extract_deeply_nested_fallbacks() {
474 let css = ".x { color: var(--a, var(--b, var(--c))); }";
475 let mut parser = CssParser::new();
476 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
477 assert_eq!(
478 tokens,
479 HashSet::from(["a".to_string(), "b".to_string(), "c".to_string()])
480 );
481 }
482
483 #[test]
484 fn test_literal_fallback_ignored() {
485 let css = ".x { font-size: var(--size, 16px); }";
486 let mut parser = CssParser::new();
487 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
488 assert_eq!(tokens, HashSet::from(["size".to_string()]));
489 }
490
491 #[test]
492 fn test_var_fallback_block_comment_ignored() {
493 let css = ".x { color: var(--primary, /* --debug-only */ red); }";
494 let mut parser = CssParser::new();
495 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
496 assert_eq!(tokens, HashSet::from(["primary".to_string()]));
497 }
498
499 #[test]
500 fn test_var_fallback_line_comment_ignored() {
501 let css = ".x { color: var(--primary,\n // --debug-only\n red); }";
502 let mut parser = CssParser::new();
503 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
504 assert_eq!(tokens, HashSet::from(["primary".to_string()]));
505 }
506
507 #[test]
508 fn test_exclude_local_definitions() {
509 let css = r#"
510 :root { --bar: 12px; }
511 .x { width: var(--bar); }
512 "#;
513 let mut parser = CssParser::new();
514 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
515 assert!(
516 tokens.is_empty(),
517 "Locally defined --bar should be excluded: {tokens:?}"
518 );
519 }
520
521 #[test]
522 fn test_exclude_definitions_keep_unrelated_usages() {
523 let css = r#"
524 :host { --local: 5px; }
525 .x { color: var(--external); }
526 "#;
527 let mut parser = CssParser::new();
528 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
529 assert_eq!(tokens, HashSet::from(["external".to_string()]));
530 }
531
532 #[test]
533 fn test_empty_css() {
534 let mut parser = CssParser::new();
535 let tokens = parser.extract_tokens("").expect("extract_tokens failed");
536 assert!(tokens.is_empty());
537 }
538
539 #[test]
540 fn test_no_var_calls() {
541 let css = "body { margin: 0; color: red; }";
542 let mut parser = CssParser::new();
543 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
544 assert!(tokens.is_empty());
545 }
546
547 #[test]
548 fn test_deduplicate_same_var() {
549 let css = r#"
550 .a { color: var(--shared); }
551 .b { background: var(--shared); }
552 "#;
553 let mut parser = CssParser::new();
554 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
555 assert_eq!(tokens, HashSet::from(["shared".to_string()]));
556 }
557
558 #[test]
559 fn test_unterminated_var_call_returns_error() {
560 let css = ".bad { color: var(--dangling; } .ok { color: var(--valid); }";
561 let mut parser = CssParser::new();
562 let err = parser
563 .extract_tokens(css)
564 .expect_err("unterminated var() should fail");
565 assert!(matches!(err, ParserError::Css(message) if
566 message.contains("Unterminated CSS var() call")
567 ));
568 }
569
570 #[test]
571 fn test_unmatched_var_fallback_delimiter_returns_error() {
572 let css = ".bad { color: var(--accent, [red); }";
573 let mut parser = CssParser::new();
574 let err = parser
575 .extract_tokens(css)
576 .expect_err("unmatched var() fallback delimiter should fail");
577 assert!(matches!(err, ParserError::Css(message) if
578 message.contains("Unterminated CSS bracket expression inside var()")
579 ));
580 }
581
582 #[test]
583 fn test_balanced_var_fallback_delimiters_extract_tokens() {
584 let css = ".ok { color: var(--accent, color(display-p3 1 0 0 / 1)); }";
585 let mut parser = CssParser::new();
586 let tokens = parser.extract_tokens(css).expect("extract_tokens failed");
587 assert_eq!(tokens, HashSet::from(["accent".to_string()]));
588 }
589
590 #[test]
591 fn test_unterminated_comment_returns_error() {
592 let css = ".ok { color: var(--valid); } /* .bad { color: var(--ignored); }";
593 let mut parser = CssParser::new();
594 let err = parser
595 .extract_tokens(css)
596 .expect_err("unterminated comment should fail");
597 assert!(matches!(err, ParserError::Css(message) if
598 message.contains("Unterminated CSS block comment")
599 ));
600 }
601
602 #[test]
603 fn test_unterminated_string_returns_error() {
604 let css = r#".bad { content: "dangling; }"#;
605 let mut parser = CssParser::new();
606 let err = parser
607 .extract_tokens(css)
608 .expect_err("unterminated string should fail");
609 assert!(matches!(err, ParserError::Css(message) if
610 message.contains("Unterminated CSS string literal")
611 ));
612 }
613
614 #[test]
615 fn test_comments_are_collected_and_stripped() {
616 let mut parser = CssParser::new();
617 let (_tokens, _defs, stripped) = parser
618 .extract_tokens_definitions_and_strip_comments(
619 "/* remove */.x{color:var(--a)}/*! keep */",
620 LegalComments::Inline,
621 )
622 .expect("scan failed");
623 assert_eq!(stripped, ".x{color:var(--a)}/*! keep */");
624 }
625}