turbopack_css/
process.rs

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