1#[derive(Debug, Clone, Default, PartialEq, Eq)]
21pub struct DomClassScan {
22 pub static_tokens: Vec<String>,
24 pub dynamic_values: Vec<String>,
27}
28
29const CLASS_LIST_METHODS: &[&str] = &["add", "remove", "toggle", "replace", "contains"];
30
31#[must_use]
33pub fn scan_dom_class_values(source: &str) -> DomClassScan {
34 let mut values = Vec::new();
35 collect_class_keyword_values(source, &mut values);
36 collect_set_attribute_values(source, &mut values);
37 let mut scan = DomClassScan::default();
38 for value in values {
39 classify_value(value, &mut scan);
40 }
41 scan
42}
43
44fn collect_class_keyword_values<'a>(source: &'a str, out: &mut Vec<&'a str>) {
45 let bytes = source.as_bytes();
46 for (start, _) in source.match_indices("class") {
47 if start > 0 && is_ident_byte(bytes[start - 1]) {
48 continue;
49 }
50 let after_class = start + "class".len();
51 if source[after_class..].starts_with("List") {
52 let end = after_class + "List".len();
53 if !bytes.get(end).copied().is_some_and(is_ident_byte) {
54 collect_class_list_args(source, end, out);
55 }
56 continue;
57 }
58 let key_end = if source[after_class..].starts_with("Name") {
59 after_class + "Name".len()
60 } else {
61 after_class
62 };
63 if bytes.get(key_end).copied().is_some_and(is_ident_byte) {
64 continue;
65 }
66 if let Some(value) = class_key_value(source, start, key_end) {
67 out.push(value);
68 }
69 }
70}
71
72fn class_key_value(source: &str, key_start: usize, key_end: usize) -> Option<&str> {
75 let bytes = source.as_bytes();
76 let quote_before = key_start
77 .checked_sub(1)
78 .map(|i| bytes[i])
79 .filter(|b| matches!(b, b'"' | b'\''));
80 let mut cursor = key_end;
81 let mut key_open = key_start;
82 if let Some(quote) = quote_before {
83 if bytes.get(key_end) != Some("e) {
84 return None;
85 }
86 cursor += 1;
87 key_open -= 1;
88 }
89 cursor = skip_whitespace(bytes, cursor);
90 let next = bytes.get(cursor + 1).copied();
91 let value_start = match bytes.get(cursor) {
92 Some(b':') if follows_object_open(bytes, key_open) => cursor + 1,
93 Some(b'=') if quote_before.is_none() && !matches!(next, Some(b'=' | b'>')) => cursor + 1,
94 Some(b'+') if quote_before.is_none() && next == Some(b'=') => cursor + 2,
95 _ => return None,
96 };
97 let value_end = expression_end(bytes, value_start);
98 Some(source[value_start..value_end].trim())
99}
100
101fn follows_object_open(bytes: &[u8], pos: usize) -> bool {
104 let mut i = pos;
105 while i > 0 {
106 i -= 1;
107 if !bytes[i].is_ascii_whitespace() {
108 return matches!(bytes[i], b'{' | b',');
109 }
110 }
111 false
112}
113
114fn collect_class_list_args<'a>(source: &'a str, list_end: usize, out: &mut Vec<&'a str>) {
117 let bytes = source.as_bytes();
118 let mut cursor = skip_whitespace(bytes, list_end);
119 if bytes.get(cursor) == Some(&b'?') {
120 cursor += 1;
121 }
122 if bytes.get(cursor) != Some(&b'.') {
123 return;
124 }
125 cursor = skip_whitespace(bytes, cursor + 1);
126 let method_start = cursor;
127 while bytes.get(cursor).copied().is_some_and(is_ident_byte) {
128 cursor += 1;
129 }
130 let method = &source[method_start..cursor];
131 if !CLASS_LIST_METHODS.contains(&method) {
132 return;
133 }
134 let Some(args) = call_args(source, cursor) else {
135 return;
136 };
137 let args = split_top_level_args(args);
138 let take = if method == "toggle" { 1 } else { args.len() };
140 out.extend(args.into_iter().take(take));
141}
142
143fn collect_set_attribute_values<'a>(source: &'a str, out: &mut Vec<&'a str>) {
144 let bytes = source.as_bytes();
145 for (start, name) in source.match_indices("setAttribute") {
146 if start > 0 && is_ident_byte(bytes[start - 1]) {
147 continue;
148 }
149 let Some(args) = call_args(source, start + name.len()) else {
150 continue;
151 };
152 let mut args = split_top_level_args(args).into_iter();
153 let is_class_attribute = args
154 .next()
155 .is_some_and(|name| matches!(name, "'class'" | "\"class\"" | "`class`"));
156 if is_class_attribute && let Some(value) = args.next() {
157 out.push(value);
158 }
159 }
160}
161
162fn call_args(source: &str, cursor: usize) -> Option<&str> {
165 let bytes = source.as_bytes();
166 let open = skip_whitespace(bytes, cursor);
167 if bytes.get(open) != Some(&b'(') {
168 return None;
169 }
170 let mut depth = 0usize;
171 let mut i = open;
172 while i < bytes.len() {
173 match bytes[i] {
174 b'"' | b'\'' | b'`' => {
175 i = string_end(bytes, i);
176 continue;
177 }
178 b'(' | b'[' | b'{' => depth += 1,
179 b')' | b']' | b'}' => {
180 depth -= 1;
181 if depth == 0 {
182 return Some(&source[open + 1..i]);
183 }
184 }
185 _ => {}
186 }
187 i += 1;
188 }
189 None
190}
191
192fn split_top_level_args(args: &str) -> Vec<&str> {
194 let bytes = args.as_bytes();
195 let mut out = Vec::new();
196 let mut depth = 0usize;
197 let mut start = 0usize;
198 let mut i = 0usize;
199 while i < bytes.len() {
200 match bytes[i] {
201 b'"' | b'\'' | b'`' => {
202 i = string_end(bytes, i);
203 continue;
204 }
205 b'(' | b'[' | b'{' => depth += 1,
206 b')' | b']' | b'}' => depth = depth.saturating_sub(1),
207 b',' if depth == 0 => {
208 out.push(args[start..i].trim());
209 start = i + 1;
210 }
211 _ => {}
212 }
213 i += 1;
214 }
215 let last = args[start..].trim();
216 if !last.is_empty() {
217 out.push(last);
218 }
219 out
220}
221
222fn expression_end(bytes: &[u8], start: usize) -> usize {
227 let mut depth = 0usize;
228 let mut last_significant: Option<u8> = None;
229 let mut i = skip_whitespace(bytes, start);
230 while i < bytes.len() {
231 let byte = bytes[i];
232 match byte {
233 b'"' | b'\'' | b'`' => {
234 i = string_end(bytes, i);
235 last_significant = Some(byte);
236 continue;
237 }
238 b'(' | b'[' | b'{' => depth += 1,
239 b')' | b']' | b'}' => {
240 if depth == 0 {
241 return i;
242 }
243 depth -= 1;
244 }
245 b';' | b',' | b'\n' | b'>' if depth == 0 => return i,
246 b'/' if depth == 0 && bytes.get(i + 1) == Some(&b'/') => return i,
247 b' ' | b'\t' | b'\r' if depth == 0 => {
248 let next = skip_whitespace(bytes, i);
249 let follows_operand = last_significant.is_some_and(|b| {
250 is_ident_byte(b) || matches!(b, b'"' | b'\'' | b'`' | b')' | b']' | b'}')
251 });
252 if follows_operand
253 && bytes.get(next).copied().is_some_and(is_ident_start)
254 && !starts_with_keyword_operator(&bytes[next..])
255 {
256 return i;
257 }
258 i = next;
259 continue;
260 }
261 _ => {}
262 }
263 last_significant = Some(byte);
264 i += 1;
265 }
266 bytes.len()
267}
268
269fn starts_with_keyword_operator(bytes: &[u8]) -> bool {
270 ["in", "instanceof", "as", "satisfies"].iter().any(|word| {
271 bytes.starts_with(word.as_bytes())
272 && !bytes.get(word.len()).copied().is_some_and(is_ident_byte)
273 })
274}
275
276fn string_end(bytes: &[u8], open: usize) -> usize {
279 let quote = bytes[open];
280 let mut i = open + 1;
281 while i < bytes.len() {
282 match bytes[i] {
283 b'\\' => i += 2,
284 b if b == quote => return i + 1,
285 b'\n' if quote != b'`' => return i,
286 _ => i += 1,
287 }
288 }
289 bytes.len()
290}
291
292fn classify_value(value: &str, scan: &mut DomClassScan) {
295 let bytes = value.as_bytes();
296 if !bytes.iter().any(|b| matches!(b, b'"' | b'\'' | b'`')) {
297 return;
298 }
299 let mut dynamic = false;
300 let mut i = 0usize;
301 while i < bytes.len() {
302 let byte = bytes[i];
303 if matches!(byte, b'"' | b'\'' | b'`') {
304 let end = string_end(bytes, i);
305 let content_end = if end > i + 1 && bytes[end - 1] == byte {
306 end - 1
307 } else {
308 end
309 };
310 let content = &value[i + 1..content_end];
311 if byte == b'`' && content.contains("${") {
312 dynamic = true;
313 } else {
314 scan.static_tokens.extend(
315 content
316 .split_whitespace()
317 .filter(|token| is_plausible_class_token(token))
318 .map(str::to_owned),
319 );
320 }
321 i = end;
322 continue;
323 }
324 if !byte.is_ascii_whitespace() && byte != b',' {
325 dynamic = true;
326 }
327 i += 1;
328 }
329 if dynamic {
330 scan.dynamic_values.push(value.to_owned());
331 }
332}
333
334fn is_plausible_class_token(token: &str) -> bool {
335 !token.contains(['{', '}', '$', '`', '"', '\'', '(', ')', '<', '>', '=', '\\'])
336}
337
338fn skip_whitespace(bytes: &[u8], mut i: usize) -> usize {
339 while bytes.get(i).is_some_and(u8::is_ascii_whitespace) {
340 i += 1;
341 }
342 i
343}
344
345fn is_ident_start(byte: u8) -> bool {
346 byte.is_ascii_alphabetic() || byte == b'_' || byte == b'$'
347}
348
349fn is_ident_byte(byte: u8) -> bool {
350 is_ident_start(byte) || byte.is_ascii_digit()
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 fn static_tokens(source: &str) -> Vec<String> {
358 scan_dom_class_values(source).static_tokens
359 }
360
361 #[test]
362 fn credits_class_name_assignment() {
363 assert_eq!(
364 static_tokens("el.className = 'panel-a panel-b';"),
365 ["panel-a", "panel-b"]
366 );
367 assert_eq!(static_tokens("el.className += \" is-open\";"), ["is-open"]);
368 }
369
370 #[test]
371 fn credits_class_list_methods() {
372 assert_eq!(
373 static_tokens(
374 "el.classList.add('a-one', \"a-two\");\nel.classList?.remove('a-three');"
375 ),
376 ["a-one", "a-two", "a-three"]
377 );
378 assert_eq!(
379 static_tokens("el.classList.replace('old-x', 'new-x'); el.classList.contains('has-x')"),
380 ["old-x", "new-x", "has-x"]
381 );
382 }
383
384 #[test]
385 fn toggle_force_argument_is_not_a_class() {
386 assert_eq!(
387 static_tokens("el.classList.toggle('is-active', mode === 'other-mode');"),
388 ["is-active"]
389 );
390 }
391
392 #[test]
393 fn credits_set_attribute_class() {
394 assert_eq!(
395 static_tokens("el.setAttribute('class', 'attr-a attr-b');"),
396 ["attr-a", "attr-b"]
397 );
398 assert!(static_tokens("el.setAttribute('title', 'not-a-class');").is_empty());
399 }
400
401 #[test]
402 fn credits_class_object_properties() {
403 assert_eq!(
404 static_tokens("const attrs = { class: 'obj-a', id: 'not-a-class' };"),
405 ["obj-a"]
406 );
407 assert_eq!(
408 static_tokens("return ['div', { \"class\": 'obj-b' }, 0];"),
409 ["obj-b"]
410 );
411 assert_eq!(
412 static_tokens("const props = {\n role: 'note',\n className: 'obj-c',\n};"),
413 ["obj-c"]
414 );
415 }
416
417 #[test]
418 fn ignores_strings_outside_class_apis() {
419 for source in [
420 "export const helper = 'panel-helper';",
421 "if (el.className === 'cmp-a') {}",
422 "if (el.className == 'cmp-b') {}",
423 "const x = cond ? 'tern-a' : 'tern-b';",
424 "class Foo { name = 'field-a' }",
425 "const classes = 'plural-a';",
426 "el.classList.item('item-a');",
427 "type P = { className: string };",
428 ] {
429 let scan = scan_dom_class_values(source);
430 assert!(scan.static_tokens.is_empty(), "{source:?} -> {scan:?}");
431 assert!(scan.dynamic_values.is_empty(), "{source:?} -> {scan:?}");
432 }
433 }
434
435 #[test]
436 fn reports_dynamic_values() {
437 let scan = scan_dom_class_values(
438 "el.className = `panel-${kind}`;\nel.classList.add(open ? 'is-open' : 'is-closed');",
439 );
440 assert_eq!(scan.static_tokens, ["is-open", "is-closed"]);
441 assert_eq!(
442 scan.dynamic_values,
443 ["`panel-${kind}`", "open ? 'is-open' : 'is-closed'"]
444 );
445 }
446
447 #[test]
448 fn jsx_attribute_value_stops_at_next_attribute() {
449 assert_eq!(
450 static_tokens("<div className=\"jsx-a\" id=\"not-a-class\">x</div>"),
451 ["jsx-a"]
452 );
453 assert_eq!(
454 static_tokens("const html = '<div class=\"html-a\">x</div>';"),
455 ["html-a"]
456 );
457 }
458}