manganis_core/
css_module_parser.rs

1use std::borrow::Cow;
2
3use winnow::{
4    combinator::{alt, cut_err, delimited, opt, peek, preceded, repeat, terminated},
5    error::{ContextError, ErrMode, ParseError},
6    prelude::*,
7    stream::{AsChar, ContainsToken, Range},
8    token::{none_of, one_of, take_till, take_until, take_while},
9};
10
11/// ```text
12///         v----v inner span
13/// :global(.class)
14/// ^-------------^ outer span
15/// ```
16#[derive(Debug, PartialEq)]
17pub struct Global<'s> {
18    pub inner: &'s str,
19    pub outer: &'s str,
20}
21
22#[derive(Debug, PartialEq)]
23pub enum CssFragment<'s> {
24    Class(&'s str),
25    Global(Global<'s>),
26}
27
28//************************************************************************//
29
30/// Parses and rewrites CSS class selectors with the hash applied.
31/// Does not modify `:global(...)` selectors.
32pub fn transform_css<'a>(
33    css: &'a str,
34    hash: &str,
35) -> Result<String, ParseError<&'a str, ContextError>> {
36    let fragments = parse_css(css)?;
37
38    let mut new_css = String::with_capacity(css.len() * 2);
39    let mut cursor = css;
40
41    for fragment in fragments {
42        let (span, replace) = match fragment {
43            CssFragment::Class(class) => (class, Cow::Owned(apply_hash(class, hash))),
44            CssFragment::Global(Global { inner, outer }) => (outer, Cow::Borrowed(inner)),
45        };
46
47        let (before, after) = cursor.split_at(span.as_ptr() as usize - cursor.as_ptr() as usize);
48        cursor = &after[span.len()..];
49        new_css.push_str(before);
50        new_css.push_str(&replace);
51    }
52
53    new_css.push_str(cursor);
54    Ok(new_css)
55}
56
57/// Gets all the classes in the css files and their rewritten names.
58/// Includes `:global(...)` classes where the name is not changed.
59#[allow(clippy::type_complexity)]
60pub fn get_class_mappings<'a>(
61    css: &'a str,
62    hash: &str,
63) -> Result<Vec<(&'a str, Cow<'a, str>)>, ParseError<&'a str, ContextError>> {
64    let fragments = parse_css(css)?;
65    let mut result = Vec::new();
66
67    for c in fragments {
68        match c {
69            CssFragment::Class(class) => {
70                result.push((class, Cow::Owned(apply_hash(class, hash))));
71            }
72            CssFragment::Global(global) => {
73                let global_classes = resolve_global_inner_classes(global)?;
74                result.extend(
75                    global_classes
76                        .into_iter()
77                        .map(|class| (class, Cow::Borrowed(class))),
78                );
79            }
80        }
81    }
82    result.sort_by_key(|e| e.0);
83    result.dedup_by_key(|e| e.0);
84    Ok(result)
85}
86
87fn resolve_global_inner_classes<'a>(
88    global: Global<'a>,
89) -> Result<Vec<&'a str>, ParseError<&'a str, ContextError>> {
90    let input = global.inner;
91    let fragments = selector.parse(input)?;
92    let mut result = Vec::new();
93    for c in fragments {
94        match c {
95            CssFragment::Class(class) => result.push(class),
96            CssFragment::Global(_) => {
97                unreachable!("Top level parser should have already errored if globals are nested")
98            }
99        }
100    }
101    Ok(result)
102}
103
104fn apply_hash(class: &str, hash: &str) -> String {
105    format!("{}-{}", class, hash)
106}
107
108//************************************************************************//
109
110pub fn parse_css(input: &str) -> Result<Vec<CssFragment<'_>>, ParseError<&str, ContextError>> {
111    style_rule_block_contents.parse(input)
112}
113
114fn recognize_repeat<'s, O>(
115    range: impl Into<Range>,
116    f: impl Parser<&'s str, O, ErrMode<ContextError>>,
117) -> impl Parser<&'s str, &'s str, ErrMode<ContextError>> {
118    repeat(range, f).fold(|| (), |_, _| ()).take()
119}
120
121fn ws<'s>(input: &mut &'s str) -> ModalResult<&'s str> {
122    recognize_repeat(
123        0..,
124        alt((
125            line_comment,
126            block_comment,
127            take_while(1.., (AsChar::is_space, '\n', '\r')),
128        )),
129    )
130    .parse_next(input)
131}
132
133fn line_comment<'s>(input: &mut &'s str) -> ModalResult<&'s str> {
134    ("//", take_while(0.., |c| c != '\n'))
135        .take()
136        .parse_next(input)
137}
138
139fn block_comment<'s>(input: &mut &'s str) -> ModalResult<&'s str> {
140    ("/*", cut_err(terminated(take_until(0.., "*/"), "*/")))
141        .take()
142        .parse_next(input)
143}
144
145// matches a sass interpolation of the form #{...}
146fn sass_interpolation<'s>(input: &mut &'s str) -> ModalResult<&'s str> {
147    (
148        "#{",
149        cut_err(terminated(take_till(1.., ('{', '}', '\n')), '}')),
150    )
151        .take()
152        .parse_next(input)
153}
154
155fn identifier<'s>(input: &mut &'s str) -> ModalResult<&'s str> {
156    (
157        one_of(('_', '-', AsChar::is_alpha)),
158        take_while(0.., ('_', '-', AsChar::is_alphanum)),
159    )
160        .take()
161        .parse_next(input)
162}
163
164fn class<'s>(input: &mut &'s str) -> ModalResult<&'s str> {
165    preceded('.', identifier).parse_next(input)
166}
167
168fn global<'s>(input: &mut &'s str) -> ModalResult<Global<'s>> {
169    let (inner, outer) = preceded(
170        ":global(",
171        cut_err(terminated(
172            stuff_till(0.., (')', '(', '{')), // inner
173            ')',
174        )),
175    )
176    .with_taken() // outer
177    .parse_next(input)?;
178    Ok(Global { inner, outer })
179}
180
181fn string_dq<'s>(input: &mut &'s str) -> ModalResult<&'s str> {
182    let str_char = alt((none_of(['"']).void(), "\\\"".void()));
183    let str_chars = recognize_repeat(0.., str_char);
184
185    preceded('"', cut_err(terminated(str_chars, '"'))).parse_next(input)
186}
187
188fn string_sq<'s>(input: &mut &'s str) -> ModalResult<&'s str> {
189    let str_char = alt((none_of(['\'']).void(), "\\'".void()));
190    let str_chars = recognize_repeat(0.., str_char);
191
192    preceded('\'', cut_err(terminated(str_chars, '\''))).parse_next(input)
193}
194
195fn string<'s>(input: &mut &'s str) -> ModalResult<&'s str> {
196    alt((string_dq, string_sq)).parse_next(input)
197}
198
199/// Behaves like take_till except it finds and parses strings and
200/// comments (allowing those to contain the end condition characters).
201fn stuff_till<'s>(
202    range: impl Into<Range>,
203    list: impl ContainsToken<char>,
204) -> impl Parser<&'s str, &'s str, ErrMode<ContextError>> {
205    recognize_repeat(
206        range,
207        alt((
208            string.void(),
209            block_comment.void(),
210            line_comment.void(),
211            sass_interpolation.void(),
212            '/'.void(),
213            '#'.void(),
214            take_till(1.., ('\'', '"', '/', '#', list)).void(),
215        )),
216    )
217}
218
219fn selector<'s>(input: &mut &'s str) -> ModalResult<Vec<CssFragment<'s>>> {
220    repeat(
221        1..,
222        alt((
223            class.map(|c| Some(CssFragment::Class(c))),
224            global.map(|g| Some(CssFragment::Global(g))),
225            ':'.map(|_| None),
226            stuff_till(1.., ('.', ';', '{', '}', ':')).map(|_| None),
227        )),
228    )
229    .fold(Vec::new, |mut acc, item| {
230        if let Some(item) = item {
231            acc.push(item);
232        }
233        acc
234    })
235    .parse_next(input)
236}
237
238fn declaration<'s>(input: &mut &'s str) -> ModalResult<&'s str> {
239    (
240        (opt('$'), identifier),
241        ws,
242        ':',
243        terminated(
244            stuff_till(1.., (';', '{', '}')),
245            alt((';', peek('}'))), // semicolon is optional if it's the last element in a rule block
246        ),
247    )
248        .take()
249        .parse_next(input)
250}
251
252fn style_rule_block_statement<'s>(input: &mut &'s str) -> ModalResult<Vec<CssFragment<'s>>> {
253    let content = alt((
254        declaration.map(|_| Vec::new()), //
255        at_rule,
256        style_rule,
257    ));
258    delimited(ws, content, ws).parse_next(input)
259}
260
261fn style_rule_block_contents<'s>(input: &mut &'s str) -> ModalResult<Vec<CssFragment<'s>>> {
262    repeat(0.., style_rule_block_statement)
263        .fold(Vec::new, |mut acc, mut item| {
264            acc.append(&mut item);
265            acc
266        })
267        .parse_next(input)
268}
269
270fn style_rule_block<'s>(input: &mut &'s str) -> ModalResult<Vec<CssFragment<'s>>> {
271    preceded(
272        '{',
273        cut_err(terminated(style_rule_block_contents, (ws, '}'))),
274    )
275    .parse_next(input)
276}
277
278fn style_rule<'s>(input: &mut &'s str) -> ModalResult<Vec<CssFragment<'s>>> {
279    let (mut classes, mut nested_classes) = (selector, style_rule_block).parse_next(input)?;
280    classes.append(&mut nested_classes);
281    Ok(classes)
282}
283
284fn at_rule<'s>(input: &mut &'s str) -> ModalResult<Vec<CssFragment<'s>>> {
285    let (identifier, char) = preceded(
286        '@',
287        cut_err((
288            terminated(identifier, stuff_till(0.., ('{', '}', ';'))),
289            alt(('{', ';', peek('}'))),
290        )),
291    )
292    .parse_next(input)?;
293
294    if char != '{' {
295        return Ok(vec![]);
296    }
297
298    match identifier {
299        "media" | "layer" | "container" | "include" => {
300            cut_err(terminated(style_rule_block_contents, '}')).parse_next(input)
301        }
302        _ => {
303            cut_err(terminated(unknown_block_contents, '}')).parse_next(input)?;
304            Ok(vec![])
305        }
306    }
307}
308
309fn unknown_block_contents<'s>(input: &mut &'s str) -> ModalResult<&'s str> {
310    recognize_repeat(
311        0..,
312        alt((
313            stuff_till(1.., ('{', '}')).void(),
314            ('{', cut_err((unknown_block_contents, '}'))).void(),
315        )),
316    )
317    .parse_next(input)
318}
319
320//************************************************************************//
321
322#[test]
323fn test_class() {
324    let mut input = "._x1a2b Hello";
325
326    let r = class.parse_next(&mut input);
327    assert_eq!(r, Ok("_x1a2b"));
328}
329
330#[test]
331fn test_selector() {
332    let mut input = ".foo.bar [value=\"fa.sdasd\"] /* .banana */ // .apple \n \t .cry {";
333
334    let r = selector.parse_next(&mut input);
335    assert_eq!(
336        r,
337        Ok(vec![
338            CssFragment::Class("foo"),
339            CssFragment::Class("bar"),
340            CssFragment::Class("cry")
341        ])
342    );
343
344    let mut input = "{";
345
346    let r = selector.take().parse_next(&mut input);
347    assert!(r.is_err());
348}
349
350#[test]
351fn test_declaration() {
352    let mut input = "background-color \t : red;";
353
354    let r = declaration.parse_next(&mut input);
355    assert_eq!(r, Ok("background-color \t : red;"));
356
357    let r = declaration.parse_next(&mut input);
358    assert!(r.is_err());
359}
360
361#[test]
362fn test_style_rule() {
363    let mut input = ".foo.bar {
364        background-color: red;
365        .baz {
366            color: blue;
367        }
368        $some-scss-var: 10px;
369        @some-at-rule blah blah;
370        @media blah .blah {
371            .moo {
372                color: red;
373            }
374        }
375        @container (width > 700px) {
376            .zoo {
377                color: blue;
378            }
379        }
380    }END";
381
382    let r = style_rule.parse_next(&mut input);
383    assert_eq!(
384        r,
385        Ok(vec![
386            CssFragment::Class("foo"),
387            CssFragment::Class("bar"),
388            CssFragment::Class("baz"),
389            CssFragment::Class("moo"),
390            CssFragment::Class("zoo")
391        ])
392    );
393
394    assert_eq!(input, "END");
395}
396
397#[test]
398fn test_at_rule_simple() {
399    let mut input = "@simple-rule blah \"asd;asd\" blah;";
400
401    let r = at_rule.parse_next(&mut input);
402    assert_eq!(r, Ok(vec![]));
403
404    assert!(input.is_empty());
405}
406
407#[test]
408fn test_at_rule_unknown() {
409    let mut input = "@unknown blah \"asdasd\" blah {
410        bunch of stuff {
411            // things inside {
412            blah
413            ' { '
414        }
415
416        .bar {
417            color: blue;
418
419            .baz {
420                color: green;
421            }
422        }
423    }";
424
425    let r = at_rule.parse_next(&mut input);
426    assert_eq!(r, Ok(vec![]));
427
428    assert!(input.is_empty());
429}
430
431#[test]
432fn test_at_rule_media() {
433    let mut input = "@media blah \"asdasd\" blah {
434        .foo {
435            background-color: red;
436        }
437
438        .bar {
439            color: blue;
440
441            .baz {
442                color: green;
443            }
444        }
445    }";
446
447    let r = at_rule.parse_next(&mut input);
448    assert_eq!(
449        r,
450        Ok(vec![
451            CssFragment::Class("foo"),
452            CssFragment::Class("bar"),
453            CssFragment::Class("baz")
454        ])
455    );
456
457    assert!(input.is_empty());
458}
459
460#[test]
461fn test_at_rule_layer() {
462    let mut input = "@layer test {
463        .foo {
464            background-color: red;
465        }
466
467        .bar {
468            color: blue;
469
470            .baz {
471                color: green;
472            }
473        }
474    }";
475
476    let r = at_rule.parse_next(&mut input);
477    assert_eq!(
478        r,
479        Ok(vec![
480            CssFragment::Class("foo"),
481            CssFragment::Class("bar"),
482            CssFragment::Class("baz")
483        ])
484    );
485
486    assert!(input.is_empty());
487}
488
489#[test]
490fn test_top_level() {
491    let mut input = "// tool.module.scss
492
493        .default_border {
494          border-color: lch(100% 10 10);
495          border-style: dashed double;
496          border-radius: 30px;
497
498        }
499
500        @media testing {
501            .media-foo {
502                color: red;
503            }
504        }
505
506        @layer {
507            .layer-foo {
508                color: blue;
509            }
510        }
511
512        @include mixin {
513            border: none;
514
515            .include-foo {
516                color: green;
517            }
518        }
519
520        @layer foo;
521
522        @debug 1+2 * 3==1+(2 * 3); // true
523
524        .container {
525          padding: 1em;
526          border: 2px solid;
527          border-color: lch(100% 10 10);
528          border-style: dashed double;
529          border-radius: 30px;
530          margin: 1em;
531          background-color: lch(45% 9.5 140.4);
532
533          .bar {
534            color: red;
535          }
536        }
537
538        @debug 1+2 * 3==1+(2 * 3); // true
539        ";
540
541    let r = style_rule_block_contents.parse_next(&mut input);
542    assert_eq!(
543        r,
544        Ok(vec![
545            CssFragment::Class("default_border"),
546            CssFragment::Class("media-foo"),
547            CssFragment::Class("layer-foo"),
548            CssFragment::Class("include-foo"),
549            CssFragment::Class("container"),
550            CssFragment::Class("bar"),
551        ])
552    );
553
554    println!("{input}");
555    assert!(input.is_empty());
556}
557
558#[test]
559fn test_sass_interpolation() {
560    let mut input = "#{$test-test}END";
561
562    let r = sass_interpolation.parse_next(&mut input);
563    assert_eq!(r, Ok("#{$test-test}"));
564
565    assert_eq!(input, "END");
566
567    let mut input = "#{$test-test
568        }END";
569    let r = sass_interpolation.parse_next(&mut input);
570    assert!(r.is_err());
571
572    let mut input = "#{$test-test";
573    let r = sass_interpolation.parse_next(&mut input);
574    assert!(r.is_err());
575
576    let mut input = "#{$test-te{st}";
577    let r = sass_interpolation.parse_next(&mut input);
578    assert!(r.is_err());
579}
580
581#[test]
582fn test_get_class_mappings() {
583    let css = r#".foo.bar {
584        background-color: red;
585        :global(.baz) {
586            color: blue;
587        }
588        :global(.bag .biz) {
589            color: blue;
590        }
591        .zig {
592
593        }
594        .bong {}
595        .zig {
596            color: blue;
597        }
598    }"#;
599    let hash = "abc1234";
600    let mappings = get_class_mappings(css, hash).unwrap();
601    let expected = [
602        ("bag", "bag"),
603        ("bar", "bar-abc1234"),
604        ("baz", "baz"),
605        ("biz", "biz"),
606        ("bong", "bong-abc1234"),
607        ("foo", "foo-abc1234"),
608        ("zig", "zig-abc1234"),
609    ];
610    if mappings.len() != expected.len() {
611        panic!(
612            "Expected {} mappings, got {}",
613            expected.len(),
614            mappings.len()
615        );
616    }
617    for (i, (original, hashed)) in mappings.iter().enumerate() {
618        assert_eq!(expected[i].0, *original);
619        assert_eq!(expected[i].1, *hashed);
620    }
621}
622
623#[test]
624fn test_parser_error_on_nested_globals() {
625    let css = r#".foo :global(.bar .baz) {
626        color: blue;
627    }"#;
628    let result = parse_css(css);
629    assert!(result.is_ok());
630    let css = r#".foo :global(.bar :global(.baz)) {
631        color: blue;
632    }"#;
633    let result = parse_css(css);
634    assert!(result.is_err());
635}
636
637#[test]
638#[should_panic]
639fn test_resolve_global_inner_classes_nested() {
640    let global = Global {
641        inner: ".foo :global(.bar)",
642        outer: ":global(.foo :global(.bar))",
643    };
644    let _ = resolve_global_inner_classes(global);
645}