verter_core 0.0.1-beta.1

Vue 3 SFC compiler - transforms Vue Single File Components to render functions with TypeScript support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
//! Shared CSS selector-walking utility for normalized CSS.
//!
//! Walks through CSS that has been normalized by lightningcss (no nested rules,
//! well-formed comments/strings), finds selectors before `{`, and applies a
//! caller-provided transformation function to each selector list.

/// Walk normalized CSS, transforming selectors before each `{`.
///
/// For every selector list found before an opening brace, calls `transform_fn`
/// with the trimmed selector text and expects the transformed selector back.
/// Skips `@`-rule headers (e.g. `@media (...)`, `@supports (...)`), but
/// transforms selectors INSIDE those blocks. Also skips `@keyframes` selectors
/// (`from`, `to`, `0%`, etc.), comments, and strings.
///
/// **Precondition:** The input CSS must be normalized by lightningcss first
/// (via [`super::normalize_css`]). The normalization flattens nested rules and
/// ensures well-formed comments/strings, which this walker relies on.
pub fn walk_and_transform_selectors(
    css: &str,
    mut transform_fn: impl FnMut(&str) -> String,
) -> String {
    let mut output = String::with_capacity(css.len() + 256);
    let mut chars = css.char_indices().peekable();
    let mut in_string = false;
    let mut string_char = '"';
    let mut in_comment = false;
    let mut last_block_end: usize = 0;
    // Track nesting depth inside @keyframes blocks so selectors within
    // (from, to, 0%, 50%, etc.) are not transformed.
    let mut keyframes_depth: usize = 0;
    let mut brace_depth: usize = 0;
    // The brace depth at which a @keyframes block was entered. We track
    // multiple levels in case of (unlikely) nested at-rules.
    let mut keyframes_entry_depths: Vec<usize> = Vec::new();

    while let Some((_i, c)) = chars.next() {
        match c {
            // Track comments
            '/' if !in_string && !in_comment => {
                if let Some(&(_, '*')) = chars.peek() {
                    in_comment = true;
                    output.push('/');
                    if let Some((_, c2)) = chars.next() {
                        output.push(c2);
                    }
                    continue;
                }
                output.push(c);
                continue;
            }
            '*' if in_comment => {
                output.push(c);
                if let Some(&(_, '/')) = chars.peek() {
                    in_comment = false;
                    if let Some((_, c2)) = chars.next() {
                        output.push(c2);
                    }
                }
                continue;
            }
            _ if in_comment => {
                output.push(c);
                continue;
            }
            // Track strings (with escape handling)
            '\\' if in_string => {
                output.push(c);
                if let Some((_, next)) = chars.next() {
                    output.push(next);
                }
            }
            '"' | '\'' if !in_string => {
                in_string = true;
                string_char = c;
                output.push(c);
            }
            c if in_string && c == string_char => {
                in_string = false;
                output.push(c);
            }
            // Track block boundaries
            '}' if !in_string => {
                output.push(c);
                brace_depth = brace_depth.saturating_sub(1);
                // Check if we're closing a @keyframes block
                if keyframes_entry_depths.last() == Some(&brace_depth) {
                    keyframes_entry_depths.pop();
                    keyframes_depth -= 1;
                }
                last_block_end = output.len();
            }
            // Track semicolons inside blocks for CSS nesting support.
            // Declarations end with ';', so updating last_block_end here
            // ensures nested selectors (e.g. `& .child`) are correctly
            // isolated from preceding declarations.
            ';' if !in_string && !in_comment && brace_depth > 0 => {
                output.push(c);
                last_block_end = output.len();
            }
            // Handle rule blocks
            '{' if !in_string => {
                let selector_end = output.len();
                let selector_start = last_block_end;

                if selector_start < selector_end {
                    let raw_text = output[selector_start..selector_end].to_string();
                    let trimmed = raw_text.trim();

                    // Detect @keyframes entry
                    if trimmed.starts_with("@keyframes")
                        || trimmed.starts_with("@-webkit-keyframes")
                    {
                        keyframes_depth += 1;
                        keyframes_entry_depths.push(brace_depth);
                    }

                    // Skip @-rules and selectors inside @keyframes blocks
                    if !trimmed.starts_with('@') && !trimmed.is_empty() && keyframes_depth == 0 {
                        let transformed = transform_fn(trimmed);
                        output.truncate(selector_start);
                        // Preserve leading whitespace
                        let leading_ws = &raw_text[..raw_text.len() - raw_text.trim_start().len()];
                        output.push_str(leading_ws);
                        output.push_str(&transformed);
                    }
                }

                output.push('{');
                brace_depth += 1;
                // Update last_block_end after pushing '{' so that the first
                // selector inside an @-rule block starts AFTER the '{', not
                // from the previous '}'. Without this, the first inner selector's
                // raw_text includes the @-rule prefix and gets incorrectly skipped.
                last_block_end = output.len();
            }
            _ => output.push(c),
        }
    }

    output
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Helper: collect selectors without transforming the CSS.
    fn collect_selectors(css: &str) -> Vec<String> {
        let mut selectors = Vec::new();
        walk_and_transform_selectors(css, |sel| {
            selectors.push(sel.to_string());
            sel.to_string()
        });
        selectors
    }

    // --- Basic selector extraction ---

    #[test]
    fn test_single_class_selector() {
        let selectors = collect_selectors(".box { color: red; }");
        assert_eq!(selectors, vec![".box"]);
    }

    #[test]
    fn test_multiple_rules() {
        let selectors = collect_selectors(".a { color: red; } .b { color: blue; }");
        assert_eq!(selectors, vec![".a", ".b"]);
    }

    #[test]
    fn test_comma_separated_selectors() {
        let selectors = collect_selectors(".a, .b { color: red; }");
        assert_eq!(selectors, vec![".a, .b"]);
    }

    #[test]
    fn test_descendant_selector() {
        let selectors = collect_selectors(".parent .child { color: red; }");
        assert_eq!(selectors, vec![".parent .child"]);
    }

    // --- @-rules are skipped ---

    #[test]
    fn test_at_rule_prefix_not_collected() {
        // @-rules themselves should not appear as selectors, but inner selectors
        // inside @media, @supports, etc. MUST be collected and transformed.
        let selectors = collect_selectors("@media (min-width: 600px) { .box { color: red; } }");
        assert_eq!(selectors, vec![".box"]);
    }

    // --- Comments ---

    #[test]
    fn test_comment_preserved_in_output() {
        let result = walk_and_transform_selectors("/* comment */ .box { color: red; }", |sel| {
            sel.to_string()
        });
        assert!(
            result.contains("/* comment */"),
            "Comment should be preserved. Got: {}",
            result
        );
        assert!(
            result.contains(".box"),
            "Selector should be present. Got: {}",
            result
        );
    }

    // --- Strings are not treated as block boundaries ---

    #[test]
    fn test_string_with_braces() {
        let selectors = collect_selectors(".box { content: '{ not a block }'; }");
        assert_eq!(selectors, vec![".box"]);
    }

    #[test]
    fn test_double_quoted_string() {
        let selectors = collect_selectors(".box { content: \"hello\"; }");
        assert_eq!(selectors, vec![".box"]);
    }

    #[test]
    fn test_escaped_quote_in_string() {
        let selectors = collect_selectors(r#".box { content: 'it\'s'; }"#);
        assert_eq!(selectors, vec![".box"]);
    }

    // --- Transformation works ---

    #[test]
    fn test_transform_adds_suffix() {
        let result =
            walk_and_transform_selectors(".box { color: red; }", |sel| format!("{}[scoped]", sel));
        assert!(result.contains(".box[scoped]"), "Got: {}", result);
        assert!(result.contains("{ color: red; }"), "Got: {}", result);
    }

    #[test]
    fn test_transform_multiple_rules() {
        let result =
            walk_and_transform_selectors(".a { color: red; } .b { color: blue; }", |sel| {
                format!("{}[x]", sel)
            });
        assert!(result.contains(".a[x]"), "Got: {}", result);
        assert!(result.contains(".b[x]"), "Got: {}", result);
    }

    // --- Whitespace preservation ---

    #[test]
    fn test_leading_whitespace_preserved() {
        let result = walk_and_transform_selectors("\n  .box { color: red; }", |sel| {
            format!("{}[scoped]", sel)
        });
        assert!(result.contains(".box[scoped]"), "Got: {}", result);
    }

    #[test]
    fn test_newline_between_rules() {
        let result =
            walk_and_transform_selectors(".a { color: red; }\n.b { color: blue; }", |sel| {
                format!("{}[x]", sel)
            });
        assert!(result.contains(".a[x]"), "Got: {}", result);
        assert!(result.contains(".b[x]"), "Got: {}", result);
    }

    // --- Edge cases ---

    #[test]
    fn test_empty_input() {
        let selectors = collect_selectors("");
        assert!(selectors.is_empty());
    }

    #[test]
    fn test_only_comment() {
        let selectors = collect_selectors("/* just a comment */");
        assert!(selectors.is_empty());
    }

    #[test]
    fn test_nested_at_rule_with_selector() {
        // Selectors inside @-rules must be found and transformable.
        let selectors = collect_selectors("@media screen { .inner { color: red; } }");
        assert_eq!(selectors, vec![".inner"]);
    }

    // --- Keyframe selectors should NOT be transformed ---

    /// @ai-generated - keyframe selectors (from/to) should not be transformed
    #[test]
    fn keyframe_selectors_not_transformed() {
        let css = "@keyframes fade { from { opacity: 1; } to { opacity: 0; } }";
        let result = walk_and_transform_selectors(css, |sel| format!("{}[scoped]", sel));
        assert!(
            !result.contains("from[scoped]"),
            "from should not be transformed. Got: {}",
            result
        );
        assert!(
            !result.contains("to[scoped]"),
            "to should not be transformed. Got: {}",
            result
        );
        assert!(result.contains("from"), "from should still be present");
        assert!(result.contains("to"), "to should still be present");
    }

    /// @ai-generated - keyframe percentage selectors should not be transformed
    #[test]
    fn keyframe_percentage_selectors_not_transformed() {
        let css = "@keyframes x { 0% { opacity: 0; } 50%, 100% { opacity: 1; } }";
        let result = walk_and_transform_selectors(css, |sel| format!("{}[scoped]", sel));
        assert!(
            !result.contains("0%[scoped]"),
            "0% should not be transformed. Got: {}",
            result
        );
        assert!(
            !result.contains("50%[scoped]"),
            "50% should not be transformed. Got: {}",
            result
        );
        assert!(
            !result.contains("100%[scoped]"),
            "100% should not be transformed. Got: {}",
            result
        );
    }

    /// @ai-generated - selectors after @keyframes block should still be transformed
    #[test]
    fn normal_selectors_after_keyframes_still_transformed() {
        let css =
            "@keyframes fade { from { opacity: 1; } to { opacity: 0; } } .box { color: red; }";
        let result = walk_and_transform_selectors(css, |sel| format!("{}[scoped]", sel));
        assert!(
            result.contains(".box[scoped]"),
            ".box after @keyframes should be transformed. Got: {}",
            result
        );
        assert!(
            !result.contains("from[scoped]"),
            "from should not be transformed. Got: {}",
            result
        );
    }

    // ===================================================================
    // @ai-generated - CSS nesting tests
    // ===================================================================

    /// Nested selectors with `&` must be found by the walker.
    #[test]
    fn css_nesting_nested_selectors_found() {
        let css = ".parent { color: red; & .child { color: blue; } }";
        let selectors = collect_selectors(css);
        assert_eq!(selectors, vec![".parent", "& .child"]);
    }

    /// Multiple nested selectors in one block.
    #[test]
    fn css_nesting_multiple_nested_selectors() {
        let css = ".parent { color: red; & .a { } & .b { } }";
        let selectors = collect_selectors(css);
        assert_eq!(selectors, vec![".parent", "& .a", "& .b"]);
    }

    /// Nested selector with `&:hover` pseudo-class.
    #[test]
    fn css_nesting_pseudo_class() {
        let css = ".btn { color: red; &:hover { color: blue; } }";
        let selectors = collect_selectors(css);
        assert_eq!(selectors, vec![".btn", "&:hover"]);
    }

    /// Nested selector with `&.modifier` (no space).
    #[test]
    fn css_nesting_modifier() {
        let css = ".card { padding: 1rem; &.active { border: 1px solid; } }";
        let selectors = collect_selectors(css);
        assert_eq!(selectors, vec![".card", "&.active"]);
    }

    /// Deeply nested CSS (nesting within nesting).
    #[test]
    fn css_nesting_deep() {
        let css = ".a { color: red; & .b { font-size: 14px; & .c { margin: 0; } } }";
        let selectors = collect_selectors(css);
        assert_eq!(selectors, vec![".a", "& .b", "& .c"]);
    }

    /// Nested selectors inside @media.
    #[test]
    fn css_nesting_inside_media() {
        let css =
            "@media (max-width: 768px) { .parent { color: red; & .child { display: none; } } }";
        let selectors = collect_selectors(css);
        assert_eq!(selectors, vec![".parent", "& .child"]);
    }

    /// Declarations should be preserved in output, not treated as selectors.
    #[test]
    fn css_nesting_declarations_preserved() {
        let css = ".parent { color: red; font-size: 14px; & .child { color: blue; } }";
        let result = walk_and_transform_selectors(css, |sel| format!("{}[s]", sel));
        assert!(
            result.contains("color: red"),
            "Declaration must be preserved. Got: {}",
            result
        );
        assert!(
            result.contains("font-size: 14px"),
            "Declaration must be preserved. Got: {}",
            result
        );
        assert!(
            result.contains("& .child[s]"),
            "Nested selector must be transformed. Got: {}",
            result
        );
    }

    /// @ai-generated - webkit keyframes should also skip selectors
    #[test]
    fn webkit_keyframes_selectors_not_transformed() {
        let css = "@-webkit-keyframes slide { 0% { left: 0; } 100% { left: 100%; } }";
        let result = walk_and_transform_selectors(css, |sel| format!("{}[scoped]", sel));
        assert!(
            !result.contains("0%[scoped]"),
            "0% in webkit keyframes should not be transformed. Got: {}",
            result
        );
    }

    #[test]
    fn test_attribute_selector() {
        let selectors = collect_selectors("input[type=\"text\"] { color: red; }");
        assert_eq!(selectors, vec!["input[type=\"text\"]"]);
    }

    #[test]
    fn test_pseudo_class_in_selector() {
        let selectors = collect_selectors(".btn:hover { color: red; }");
        assert_eq!(selectors, vec![".btn:hover"]);
    }

    // ===================================================================
    // @ai-generated - @-rule inner selector extraction tests
    // ===================================================================

    /// Selectors inside @media must be collected.
    #[test]
    fn media_inner_selectors_collected() {
        let selectors = collect_selectors(
            "@media (max-width: 768px) { .a { color: red; } .b { color: blue; } }",
        );
        assert_eq!(selectors, vec![".a", ".b"]);
    }

    /// Selectors inside @supports must be collected.
    #[test]
    fn supports_inner_selectors_collected() {
        let selectors = collect_selectors("@supports (display: grid) { .grid { display: grid; } }");
        assert_eq!(selectors, vec![".grid"]);
    }

    /// Selectors inside @layer must be collected.
    #[test]
    fn layer_inner_selectors_collected() {
        let selectors = collect_selectors("@layer base { .box { color: red; } }");
        assert_eq!(selectors, vec![".box"]);
    }

    /// Multiple @media blocks, each with multiple selectors.
    #[test]
    fn multiple_media_blocks_selectors() {
        let selectors = collect_selectors(
            ".top { color: red; } \
             @media (max-width: 768px) { .a { } .b { } } \
             @media (min-width: 1200px) { .c { } } \
             .bottom { color: blue; }",
        );
        assert_eq!(selectors, vec![".top", ".a", ".b", ".c", ".bottom"]);
    }

    /// Nested @media and @supports — deeply nested selector must be collected.
    #[test]
    fn nested_at_rules_inner_selectors() {
        let selectors = collect_selectors(
            "@media (min-width: 768px) { @supports (display: grid) { .nested { } } }",
        );
        assert_eq!(selectors, vec![".nested"]);
    }

    /// @font-face has no selectors — nothing should be collected.
    #[test]
    fn font_face_no_selectors() {
        let selectors = collect_selectors(
            "@font-face { font-family: MyFont; src: url('f.woff'); } .text { color: red; }",
        );
        assert_eq!(selectors, vec![".text"]);
    }

    /// Transform inside @media actually applies the transformation.
    #[test]
    fn media_inner_selectors_transformed() {
        let result = walk_and_transform_selectors(
            "@media (max-width: 768px) { .box { color: red; } }",
            |sel| format!("{}[scoped]", sel),
        );
        assert!(
            result.contains(".box[scoped]"),
            ".box inside @media should be transformed. Got: {}",
            result
        );
        assert!(
            result.contains("@media"),
            "@media rule must be preserved. Got: {}",
            result
        );
    }

    /// @charset is removed by lightningcss normalization before the walker
    /// runs, so we don't test it directly. The walker requires normalized CSS.
    /// This test verifies that @charset followed by a selector works after
    /// lightningcss normalization (which removes @charset).
    #[test]
    fn after_charset_removal_selector_works() {
        // After lightningcss normalization, @charset is removed, leaving just:
        let selectors = collect_selectors(".box { color: red; }");
        assert_eq!(selectors, vec![".box"]);
    }
}