Skip to main content

fallow_extract/
dom_classes.rs

1//! CSS-class references in plain JS/TS DOM code.
2//!
3//! A plain `.js` / `.ts` module applies classes through DOM class APIs, not
4//! through a markup `class` attribute. [`scan_dom_class_values`] finds the
5//! values that these APIs receive:
6//!
7//! - `el.className = '...'` and `el.className += '...'`
8//! - `el.classList.add / remove / toggle / replace / contains(...)`
9//! - `el.setAttribute('class', ...)`
10//! - a `class:` or `className:` object property (`{ class: 'x' }`)
11//!
12//! Only these call sites count. Every other string in the file stays
13//! uncredited, so a generic string never credits a class by coincidence.
14//!
15//! The scanner is byte-based and conservative. It never evaluates an
16//! expression. A value that is not fully static goes back as a dynamic value,
17//! so the caller can credit its prefixes and interpolants.
18
19/// The class values that one plain JS/TS source passes to DOM class APIs.
20#[derive(Debug, Clone, Default, PartialEq, Eq)]
21pub struct DomClassScan {
22    /// Class tokens from fully static string literals.
23    pub static_tokens: Vec<String>,
24    /// The source text of each value that is not fully static, for example
25    /// `` `panel-${kind}` `` or `open ? 'is-open' : base`.
26    pub dynamic_values: Vec<String>,
27}
28
29const CLASS_LIST_METHODS: &[&str] = &["add", "remove", "toggle", "replace", "contains"];
30
31/// Scan a plain JS/TS source for the values that DOM class APIs receive.
32#[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
72/// The value of a `class` / `className` key that starts at `key_start`: an
73/// assignment (`=` or `+=`) or an object property (`:`).
74fn 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(&quote) {
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
101/// True when the nearest non-whitespace byte before `pos` opens an object
102/// property: `{` or `,`.
103fn 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
114/// Collect the arguments of a `classList.<method>(...)` call. `list_end` is
115/// the byte after `classList`.
116fn 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    // The second `toggle` argument is the force flag, not a class.
139    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
162/// The text between the parentheses of a call whose `(` follows `cursor`
163/// after optional whitespace.
164fn 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
192/// Split call arguments at top-level commas and trim each argument.
193fn 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
222/// The end of the expression that starts at `start`. The expression stops at
223/// a top-level `;`, `,`, line break, `>`, `//`, an unmatched closing bracket,
224/// or a new JSX attribute (an identifier after whitespace that follows an
225/// operand).
226fn 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
276/// The byte after the string literal that opens at `open`. A backslash escapes
277/// the next byte. An unclosed literal runs to the end of the source.
278fn 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
292/// Sort one value into static tokens or a dynamic value. A value without any
293/// string literal carries no class name and is dropped.
294fn 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}