Skip to main content

turbopack_css/
process.rs

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