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