turbopack_css/
process.rs

1use std::sync::{Arc, RwLock};
2
3use anyhow::{Result, bail};
4use lightningcss::{
5    css_modules::{CssModuleExport, CssModuleExports, Pattern, Segment},
6    stylesheet::{MinifyOptions, ParserOptions, PrinterOptions, StyleSheet, ToCssResult},
7    targets::{BrowserslistConfig, Features, Targets},
8    traits::ToCss,
9    values::url::Url,
10    visit_types,
11    visitor::Visit,
12};
13use rustc_hash::FxHashMap;
14use smallvec::smallvec;
15use swc_core::base::sourcemap::SourceMapBuilder;
16use tracing::Instrument;
17use turbo_rcstr::{RcStr, rcstr};
18use turbo_tasks::{FxIndexMap, ResolvedVc, ValueToString, Vc};
19use turbo_tasks_fs::{FileContent, FileSystemPath, rope::Rope};
20use turbopack_core::{
21    SOURCE_URL_PROTOCOL,
22    asset::{Asset, AssetContent},
23    chunk::{ChunkingContext, MinifyType},
24    environment::Environment,
25    issue::{
26        Issue, IssueExt, IssueSource, IssueStage, OptionIssueSource, OptionStyledString,
27        StyledString,
28    },
29    reference::ModuleReferences,
30    reference_type::ImportContext,
31    resolve::origin::ResolveOrigin,
32    source::Source,
33    source_map::{OptionStringifiedSourceMap, utils::add_default_ignore_list},
34    source_pos::SourcePos,
35};
36
37use crate::{
38    CssModuleAssetType,
39    lifetime_util::stylesheet_into_static,
40    references::{
41        analyze_references,
42        url::{UrlAssetReference, replace_url_references, resolve_url_reference},
43    },
44};
45
46#[derive(Debug)]
47pub struct StyleSheetLike<'i, 'o>(pub(crate) StyleSheet<'i, 'o>);
48
49impl PartialEq for StyleSheetLike<'_, '_> {
50    fn eq(&self, _: &Self) -> bool {
51        false
52    }
53}
54
55pub type CssOutput = (ToCssResult, Option<Rope>);
56
57#[turbo_tasks::value(transparent)]
58struct LightningCssTargets(#[turbo_tasks(trace_ignore)] pub Targets);
59
60/// Returns the LightningCSS targets for the given browserslist query.
61#[turbo_tasks::function]
62async fn get_lightningcss_browser_targets(
63    environment: Option<ResolvedVc<Environment>>,
64    handle_nesting: bool,
65) -> Result<Vc<LightningCssTargets>> {
66    match environment {
67        Some(environment) => {
68            let browserslist_query = environment.browserslist_query().owned().await?;
69            let browserslist_browsers =
70                lightningcss::targets::Browsers::from_browserslist_with_config(
71                    browserslist_query.split(','),
72                    BrowserslistConfig {
73                        ignore_unknown_versions: true,
74                        ..Default::default()
75                    },
76                )?;
77
78            Ok(if handle_nesting {
79                Vc::cell(Targets {
80                    browsers: browserslist_browsers,
81                    include: Features::Nesting,
82                    ..Default::default()
83                })
84            } else {
85                Vc::cell(Targets {
86                    browsers: browserslist_browsers,
87                    ..Default::default()
88                })
89            })
90        }
91        // Default when empty environment is passed.
92        None => Ok(Vc::cell(Default::default())),
93    }
94}
95
96impl StyleSheetLike<'_, '_> {
97    pub fn to_static(
98        &self,
99        options: ParserOptions<'static, 'static>,
100    ) -> StyleSheetLike<'static, 'static> {
101        StyleSheetLike(stylesheet_into_static(&self.0, options))
102    }
103
104    pub async fn to_css(
105        &self,
106        code: &str,
107        minify_type: MinifyType,
108        enable_srcmap: bool,
109        handle_nesting: bool,
110        mut origin_source_map: Option<parcel_sourcemap::SourceMap>,
111        environment: Option<ResolvedVc<Environment>>,
112    ) -> Result<CssOutput> {
113        let ss = &self.0;
114        let mut srcmap = if enable_srcmap {
115            Some(parcel_sourcemap::SourceMap::new(""))
116        } else {
117            None
118        };
119
120        let targets =
121            *get_lightningcss_browser_targets(environment.as_deref().copied(), handle_nesting)
122                .await?;
123
124        let result = ss.to_css(PrinterOptions {
125            minify: matches!(minify_type, MinifyType::Minify { .. }),
126            source_map: srcmap.as_mut(),
127            targets,
128            analyze_dependencies: None,
129            ..Default::default()
130        })?;
131
132        if let Some(srcmap) = &mut srcmap {
133            debug_assert_eq!(ss.sources.len(), 1);
134
135            if let Some(origin_source_map) = origin_source_map.as_mut() {
136                let _ = srcmap.extends(origin_source_map);
137            } else {
138                srcmap.add_sources(ss.sources.clone());
139                srcmap.set_source_content(0, code)?;
140            }
141        }
142
143        let srcmap = match srcmap {
144            Some(srcmap) => Some(generate_css_source_map(&srcmap)?),
145            None => None,
146        };
147
148        Ok((result, srcmap))
149    }
150}
151
152/// Multiple [ModuleReference]s
153#[turbo_tasks::value(transparent)]
154pub struct UnresolvedUrlReferences(pub Vec<(String, ResolvedVc<UrlAssetReference>)>);
155
156#[turbo_tasks::value(shared, serialization = "none", eq = "manual", cell = "new")]
157#[allow(clippy::large_enum_variant)] // This is a turbo-tasks value
158pub enum ParseCssResult {
159    Ok {
160        code: ResolvedVc<FileContent>,
161
162        #[turbo_tasks(trace_ignore)]
163        stylesheet: StyleSheetLike<'static, 'static>,
164
165        references: ResolvedVc<ModuleReferences>,
166
167        url_references: ResolvedVc<UnresolvedUrlReferences>,
168
169        #[turbo_tasks(trace_ignore)]
170        options: ParserOptions<'static, 'static>,
171    },
172    Unparsable,
173    NotFound,
174}
175
176#[turbo_tasks::value(shared, serialization = "none", eq = "manual", cell = "new")]
177pub enum CssWithPlaceholderResult {
178    Ok {
179        parse_result: ResolvedVc<ParseCssResult>,
180
181        references: ResolvedVc<ModuleReferences>,
182
183        url_references: ResolvedVc<UnresolvedUrlReferences>,
184
185        #[turbo_tasks(trace_ignore)]
186        exports: Option<FxIndexMap<String, CssModuleExport>>,
187
188        #[turbo_tasks(trace_ignore)]
189        placeholders: FxHashMap<String, Url<'static>>,
190    },
191    Unparsable,
192    NotFound,
193}
194
195#[turbo_tasks::value(shared, serialization = "none", eq = "manual")]
196pub enum FinalCssResult {
197    Ok {
198        #[turbo_tasks(trace_ignore)]
199        output_code: String,
200
201        #[turbo_tasks(trace_ignore)]
202        exports: Option<CssModuleExports>,
203
204        source_map: ResolvedVc<OptionStringifiedSourceMap>,
205    },
206    Unparsable,
207    NotFound,
208}
209
210impl PartialEq for FinalCssResult {
211    fn eq(&self, _: &Self) -> bool {
212        false
213    }
214}
215
216#[turbo_tasks::function]
217pub async fn process_css_with_placeholder(
218    parse_result: ResolvedVc<ParseCssResult>,
219    environment: Option<ResolvedVc<Environment>>,
220) -> Result<Vc<CssWithPlaceholderResult>> {
221    let result = parse_result.await?;
222
223    match &*result {
224        ParseCssResult::Ok {
225            stylesheet,
226            references,
227            url_references,
228            code,
229            ..
230        } => {
231            let code = code.await?;
232            let code = match &*code {
233                FileContent::Content(v) => v.content().to_str()?,
234                _ => bail!("this case should be filtered out while parsing"),
235            };
236
237            // We use NoMinify because this is not a final css. We need to replace url references,
238            // and we do final codegen with proper minification.
239            let (result, _) = stylesheet
240                .to_css(&code, MinifyType::NoMinify, false, false, None, environment)
241                .await?;
242
243            let exports = result.exports.map(|exports| {
244                let mut exports = exports.into_iter().collect::<FxIndexMap<_, _>>();
245
246                exports.sort_keys();
247
248                exports
249            });
250
251            Ok(CssWithPlaceholderResult::Ok {
252                parse_result,
253                exports,
254                references: *references,
255                url_references: *url_references,
256                placeholders: FxHashMap::default(),
257            }
258            .cell())
259        }
260        ParseCssResult::Unparsable => Ok(CssWithPlaceholderResult::Unparsable.cell()),
261        ParseCssResult::NotFound => Ok(CssWithPlaceholderResult::NotFound.cell()),
262    }
263}
264
265#[turbo_tasks::function]
266pub async fn finalize_css(
267    result: Vc<CssWithPlaceholderResult>,
268    chunking_context: Vc<Box<dyn ChunkingContext>>,
269    minify_type: MinifyType,
270    origin_source_map: Vc<OptionStringifiedSourceMap>,
271    environment: Option<ResolvedVc<Environment>>,
272) -> Result<Vc<FinalCssResult>> {
273    let result = result.await?;
274    match &*result {
275        CssWithPlaceholderResult::Ok {
276            parse_result,
277            url_references,
278            ..
279        } => {
280            let (mut stylesheet, code) = match &*parse_result.await? {
281                ParseCssResult::Ok {
282                    stylesheet,
283                    options,
284                    code,
285                    ..
286                } => (stylesheet.to_static(options.clone()), *code),
287                ParseCssResult::Unparsable => return Ok(FinalCssResult::Unparsable.into()),
288                ParseCssResult::NotFound => return Ok(FinalCssResult::NotFound.into()),
289            };
290
291            let url_references = *url_references;
292
293            let mut url_map = FxHashMap::default();
294
295            for (src, reference) in (*url_references.await?).iter() {
296                let resolved = resolve_url_reference(**reference, chunking_context).await?;
297                if let Some(v) = resolved.as_ref().cloned() {
298                    url_map.insert(RcStr::from(src.as_str()), v);
299                }
300            }
301
302            replace_url_references(&mut stylesheet, &url_map);
303
304            let code = code.await?;
305            let code = match &*code {
306                FileContent::Content(v) => v.content().to_str()?,
307                _ => bail!("this case should be filtered out while parsing"),
308            };
309
310            let origin_source_map = if let Some(rope) = &*origin_source_map.await? {
311                Some(parcel_sourcemap::SourceMap::from_json("", &rope.to_str()?)?)
312            } else {
313                None
314            };
315
316            let (result, srcmap) = stylesheet
317                .to_css(
318                    &code,
319                    minify_type,
320                    true,
321                    true,
322                    origin_source_map,
323                    environment,
324                )
325                .await?;
326
327            Ok(FinalCssResult::Ok {
328                output_code: result.code,
329                exports: result.exports,
330                source_map: ResolvedVc::cell(srcmap),
331            }
332            .into())
333        }
334        CssWithPlaceholderResult::Unparsable => Ok(FinalCssResult::Unparsable.into()),
335        CssWithPlaceholderResult::NotFound => Ok(FinalCssResult::NotFound.into()),
336    }
337}
338
339#[turbo_tasks::value_trait]
340pub trait ParseCss {
341    #[turbo_tasks::function]
342    async fn parse_css(self: Vc<Self>) -> Result<Vc<ParseCssResult>>;
343}
344
345#[turbo_tasks::value_trait]
346pub trait ProcessCss: ParseCss {
347    #[turbo_tasks::function]
348    async fn get_css_with_placeholder(self: Vc<Self>) -> Result<Vc<CssWithPlaceholderResult>>;
349
350    #[turbo_tasks::function]
351    async fn finalize_css(
352        self: Vc<Self>,
353        chunking_context: Vc<Box<dyn ChunkingContext>>,
354        minify_type: MinifyType,
355    ) -> Result<Vc<FinalCssResult>>;
356}
357
358#[turbo_tasks::function]
359pub async fn parse_css(
360    source: ResolvedVc<Box<dyn Source>>,
361    origin: ResolvedVc<Box<dyn ResolveOrigin>>,
362    import_context: Option<ResolvedVc<ImportContext>>,
363    ty: CssModuleAssetType,
364    environment: Option<ResolvedVc<Environment>>,
365) -> Result<Vc<ParseCssResult>> {
366    let span = {
367        let name = source.ident().to_string().await?.to_string();
368        tracing::info_span!("parse css", name = name)
369    };
370    async move {
371        let content = source.content();
372        let ident_str = &*source.ident().to_string().await?;
373        Ok(match &*content.await? {
374            AssetContent::Redirect { .. } => ParseCssResult::Unparsable.cell(),
375            AssetContent::File(file_content) => match &*file_content.await? {
376                FileContent::NotFound => ParseCssResult::NotFound.cell(),
377                FileContent::Content(file) => match file.content().to_str() {
378                    Err(_err) => ParseCssResult::Unparsable.cell(),
379                    Ok(string) => {
380                        process_content(
381                            *file_content,
382                            string.into_owned(),
383                            ident_str,
384                            source,
385                            origin,
386                            import_context,
387                            ty,
388                            environment,
389                        )
390                        .await?
391                    }
392                },
393            },
394        })
395    }
396    .instrument(span)
397    .await
398}
399
400async fn process_content(
401    content_vc: ResolvedVc<FileContent>,
402    code: String,
403    filename: &str,
404    source: ResolvedVc<Box<dyn Source>>,
405    origin: ResolvedVc<Box<dyn ResolveOrigin>>,
406    import_context: Option<ResolvedVc<ImportContext>>,
407    ty: CssModuleAssetType,
408    environment: Option<ResolvedVc<Environment>>,
409) -> Result<Vc<ParseCssResult>> {
410    #[allow(clippy::needless_lifetimes)]
411    fn without_warnings<'o, 'i>(config: ParserOptions<'o, 'i>) -> ParserOptions<'o, 'static> {
412        ParserOptions {
413            filename: config.filename,
414            css_modules: config.css_modules,
415            source_index: config.source_index,
416            error_recovery: config.error_recovery,
417            warnings: None,
418            flags: config.flags,
419        }
420    }
421
422    let config = ParserOptions {
423        css_modules: match ty {
424            CssModuleAssetType::Module => Some(lightningcss::css_modules::Config {
425                pattern: Pattern {
426                    segments: smallvec![
427                        Segment::Name,
428                        Segment::Literal("__"),
429                        Segment::Hash,
430                        Segment::Literal("__"),
431                        Segment::Local,
432                    ],
433                },
434                dashed_idents: false,
435                grid: false,
436                container: false,
437                ..Default::default()
438            }),
439
440            _ => None,
441        },
442        filename: filename.to_string(),
443        error_recovery: true,
444        ..Default::default()
445    };
446
447    let stylesheet = StyleSheetLike({
448        let warnings: Arc<RwLock<_>> = Default::default();
449
450        match StyleSheet::parse(
451            &code,
452            ParserOptions {
453                warnings: Some(warnings.clone()),
454                ..config.clone()
455            },
456        ) {
457            Ok(mut ss) => {
458                if matches!(ty, CssModuleAssetType::Module) {
459                    let mut validator = CssValidator { errors: Vec::new() };
460                    ss.visit(&mut validator).unwrap();
461
462                    for err in validator.errors {
463                        err.report(source);
464                    }
465                }
466
467                // We need to collect here because we need to avoid holding the lock while calling
468                // `.await` in the loop.
469                let warnings = warnings.read().unwrap().iter().cloned().collect::<Vec<_>>();
470                for err in warnings.iter() {
471                    match err.kind {
472                        lightningcss::error::ParserError::UnexpectedToken(_)
473                        | lightningcss::error::ParserError::UnexpectedImportRule
474                        | lightningcss::error::ParserError::SelectorError(..)
475                        | lightningcss::error::ParserError::EndOfInput => {
476                            let source = match &err.loc {
477                                Some(loc) => {
478                                    let pos = SourcePos {
479                                        line: loc.line as _,
480                                        column: (loc.column - 1) as _,
481                                    };
482                                    IssueSource::from_line_col(source, pos, pos)
483                                }
484                                None => IssueSource::from_source_only(source),
485                            };
486
487                            ParsingIssue {
488                                msg: err.kind.to_string().into(),
489                                stage: IssueStage::Parse,
490                                source,
491                            }
492                            .resolved_cell()
493                            .emit();
494                            return Ok(ParseCssResult::Unparsable.cell());
495                        }
496
497                        _ => {
498                            // Ignore
499                        }
500                    }
501                }
502
503                let targets =
504                    *get_lightningcss_browser_targets(environment.as_deref().copied(), true)
505                        .await?;
506
507                // minify() is actually transform, and it performs operations like CSS modules
508                // handling.
509                //
510                // See: https://github.com/parcel-bundler/lightningcss/issues/935#issuecomment-2739325537
511                if let Err(e) = ss.minify(MinifyOptions {
512                    targets,
513                    ..Default::default()
514                }) {
515                    let source = match &e.loc {
516                        Some(loc) => {
517                            let pos = SourcePos {
518                                line: loc.line as _,
519                                column: (loc.column - 1) as _,
520                            };
521                            IssueSource::from_line_col(source, pos, pos)
522                        }
523                        None => IssueSource::from_source_only(source),
524                    };
525                    ParsingIssue {
526                        msg: e.kind.to_string().into(),
527                        stage: IssueStage::Transform,
528                        source,
529                    }
530                    .resolved_cell()
531                    .emit();
532                    return Ok(ParseCssResult::Unparsable.cell());
533                }
534
535                stylesheet_into_static(&ss, without_warnings(config.clone()))
536            }
537            Err(e) => {
538                let source = match &e.loc {
539                    Some(loc) => {
540                        let pos = SourcePos {
541                            line: loc.line as _,
542                            column: (loc.column - 1) as _,
543                        };
544                        IssueSource::from_line_col(source, pos, pos)
545                    }
546                    None => IssueSource::from_source_only(source),
547                };
548                ParsingIssue {
549                    msg: e.kind.to_string().into(),
550                    stage: IssueStage::Parse,
551                    source,
552                }
553                .resolved_cell()
554                .emit();
555                return Ok(ParseCssResult::Unparsable.cell());
556            }
557        }
558    });
559
560    let config = without_warnings(config);
561    let mut stylesheet = stylesheet.to_static(config.clone());
562
563    let (references, url_references) =
564        analyze_references(&mut stylesheet, source, origin, import_context).await?;
565
566    Ok(ParseCssResult::Ok {
567        code: content_vc,
568        stylesheet,
569        references: ResolvedVc::cell(references),
570        url_references: ResolvedVc::cell(url_references),
571        options: config,
572    }
573    .cell())
574}
575
576/// Visitor that lints wrong css module usage.
577///
578/// ```css
579/// button {
580/// }
581/// ```
582///
583/// is wrong for a css module because it doesn't have a class name.
584struct CssValidator {
585    errors: Vec<CssError>,
586}
587
588#[derive(Debug, PartialEq, Eq)]
589enum CssError {
590    CssSelectorInModuleNotPure { selector: String },
591}
592
593impl CssError {
594    fn report(self, source: ResolvedVc<Box<dyn Source>>) {
595        match self {
596            CssError::CssSelectorInModuleNotPure { selector } => {
597                ParsingIssue {
598                    msg: format!(
599                        "Selector \"{selector}\" is not pure. Pure selectors must contain at \
600                         least one local class or id."
601                    )
602                    .into(),
603                    stage: IssueStage::Transform,
604                    // TODO: This should include the location of the selector in the file.
605                    source: IssueSource::from_source_only(source),
606                }
607                .resolved_cell()
608                .emit();
609            }
610        }
611    }
612}
613
614/// We only visit top-level selectors.
615impl lightningcss::visitor::Visitor<'_> for CssValidator {
616    type Error = ();
617
618    fn visit_types(&self) -> lightningcss::visitor::VisitTypes {
619        visit_types!(SELECTORS)
620    }
621
622    fn visit_selector(
623        &mut self,
624        selector: &mut lightningcss::selector::Selector<'_>,
625    ) -> Result<(), Self::Error> {
626        fn is_selector_problematic(sel: &lightningcss::selector::Selector) -> bool {
627            sel.iter_raw_parse_order_from(0).all(is_problematic)
628        }
629
630        fn is_problematic(c: &lightningcss::selector::Component) -> bool {
631            match c {
632                parcel_selectors::parser::Component::ID(..)
633                | parcel_selectors::parser::Component::Class(..) => false,
634
635                parcel_selectors::parser::Component::Combinator(..)
636                | parcel_selectors::parser::Component::AttributeOther(..)
637                | parcel_selectors::parser::Component::AttributeInNoNamespaceExists { .. }
638                | parcel_selectors::parser::Component::AttributeInNoNamespace { .. }
639                | parcel_selectors::parser::Component::ExplicitUniversalType
640                | parcel_selectors::parser::Component::Negation(..) => true,
641
642                parcel_selectors::parser::Component::Where(sel) => {
643                    sel.iter().all(is_selector_problematic)
644                }
645
646                parcel_selectors::parser::Component::LocalName(local) => {
647                    // Allow html and body. They are not pure selectors but are allowed.
648                    !matches!(&*local.name.0, "html" | "body")
649                }
650                _ => false,
651            }
652        }
653
654        if is_selector_problematic(selector) {
655            let selector_string = selector
656                .to_css_string(PrinterOptions {
657                    minify: false,
658                    ..Default::default()
659                })
660                .expect("selector.to_css_string should not fail");
661            self.errors.push(CssError::CssSelectorInModuleNotPure {
662                selector: selector_string,
663            });
664        }
665
666        Ok(())
667    }
668}
669
670fn generate_css_source_map(source_map: &parcel_sourcemap::SourceMap) -> Result<Rope> {
671    let mut builder = SourceMapBuilder::new(None);
672
673    for src in source_map.get_sources() {
674        builder.add_source(format!("{SOURCE_URL_PROTOCOL}///{src}").into());
675    }
676
677    for (idx, content) in source_map.get_sources_content().iter().enumerate() {
678        builder.set_source_contents(idx as _, Some(content.clone().into()));
679    }
680
681    for m in source_map.get_mappings() {
682        builder.add_raw(
683            m.generated_line,
684            m.generated_column,
685            m.original.map(|v| v.original_line).unwrap_or_default(),
686            m.original.map(|v| v.original_column).unwrap_or_default(),
687            Some(0),
688            None,
689            false,
690        );
691    }
692
693    let mut map = builder.into_sourcemap();
694    add_default_ignore_list(&mut map);
695    let mut result = vec![];
696    map.to_writer(&mut result)?;
697    Ok(Rope::from(result))
698}
699
700#[turbo_tasks::value]
701struct ParsingIssue {
702    msg: RcStr,
703    stage: IssueStage,
704    source: IssueSource,
705}
706
707#[turbo_tasks::value_impl]
708impl Issue for ParsingIssue {
709    #[turbo_tasks::function]
710    fn file_path(&self) -> Vc<FileSystemPath> {
711        self.source.file_path()
712    }
713
714    #[turbo_tasks::function]
715    fn stage(&self) -> Vc<IssueStage> {
716        self.stage.clone().cell()
717    }
718
719    #[turbo_tasks::function]
720    fn title(&self) -> Vc<StyledString> {
721        StyledString::Text(match self.stage {
722            IssueStage::Parse => rcstr!("Parsing CSS source code failed"),
723            IssueStage::Transform => rcstr!("Transforming CSS failed"),
724            _ => rcstr!("CSS processing failed"),
725        })
726        .cell()
727    }
728
729    #[turbo_tasks::function]
730    fn source(&self) -> Vc<OptionIssueSource> {
731        Vc::cell(Some(self.source))
732    }
733
734    #[turbo_tasks::function]
735    fn description(&self) -> Result<Vc<OptionStyledString>> {
736        Ok(Vc::cell(Some(
737            StyledString::Text(self.msg.clone()).resolved_cell(),
738        )))
739    }
740}
741
742#[cfg(test)]
743mod tests {
744    use lightningcss::{
745        css_modules::Pattern,
746        stylesheet::{ParserOptions, StyleSheet},
747        visitor::Visit,
748    };
749
750    use super::{CssError, CssValidator};
751
752    fn lint_lightningcss(code: &str) -> Vec<CssError> {
753        let mut ss = StyleSheet::parse(
754            code,
755            ParserOptions {
756                css_modules: Some(lightningcss::css_modules::Config {
757                    pattern: Pattern::default(),
758                    dashed_idents: false,
759                    grid: false,
760                    container: false,
761                    ..Default::default()
762                }),
763                ..Default::default()
764            },
765        )
766        .unwrap();
767
768        let mut validator = CssValidator { errors: Vec::new() };
769        ss.visit(&mut validator).unwrap();
770
771        validator.errors
772    }
773
774    #[track_caller]
775    fn assert_lint_success(code: &str) {
776        assert_eq!(lint_lightningcss(code), vec![], "lightningcss: {code}");
777    }
778
779    #[track_caller]
780    fn assert_lint_failure(code: &str) {
781        assert_ne!(lint_lightningcss(code), vec![], "lightningcss: {code}");
782    }
783
784    #[test]
785    fn css_module_pure_lint() {
786        assert_lint_success(
787            "html {
788                --foo: 1;
789            }",
790        );
791
792        assert_lint_success(
793            "#id {
794                color: red;
795            }",
796        );
797
798        assert_lint_success(
799            ".class {
800                color: red;
801            }",
802        );
803
804        assert_lint_success(
805            "html.class {
806                color: red;
807            }",
808        );
809
810        assert_lint_success(
811            ".class > * {
812                color: red;
813            }",
814        );
815
816        assert_lint_success(
817            ".class * {
818                color: red;
819            }",
820        );
821
822        assert_lint_success(
823            ":where(.main > *) {
824                color: red;
825            }",
826        );
827
828        assert_lint_success(
829            ":where(.main > *, .root > *) {
830                color: red;
831            }",
832        );
833        assert_lint_success(
834            ".style {
835                background-image: var(--foo);
836            }",
837        );
838
839        assert_lint_failure(
840            "div {
841                color: red;
842            }",
843        );
844
845        assert_lint_failure(
846            "div > span {
847                color: red;
848            }",
849        );
850
851        assert_lint_failure(
852            "div span {
853                color: red;
854            }",
855        );
856
857        assert_lint_failure(
858            "div[data-foo] {
859                color: red;
860            }",
861        );
862
863        assert_lint_failure(
864            "div[data-foo=\"bar\"] {
865                color: red;
866            }",
867        );
868
869        assert_lint_failure(
870            "div[data-foo=\"bar\"] span {
871                color: red;
872            }",
873        );
874
875        assert_lint_failure(
876            "* {
877                --foo: 1;
878            }",
879        );
880
881        assert_lint_failure(
882            "[data-foo] {
883                --foo: 1;
884            }",
885        );
886
887        assert_lint_failure(
888            ":not(.class) {
889                --foo: 1;
890            }",
891        );
892
893        assert_lint_failure(
894            ":not(div) {
895                --foo: 1;
896            }",
897        );
898
899        assert_lint_failure(
900            ":where(div > *) {
901                color: red;
902            }",
903        );
904
905        assert_lint_failure(
906            ":where(div) {
907                color: red;
908            }",
909        );
910    }
911}