Skip to main content

rspack_loader_lightningcss/
lib.rs

1use std::{
2  borrow::Cow,
3  sync::{Arc, RwLock},
4};
5
6use config::Config;
7use cow_utils::CowUtils;
8use derive_more::Debug;
9pub use lightningcss;
10use lightningcss::{
11  printer::{PrinterOptions, PseudoClasses},
12  stylesheet::{MinifyOptions, ParserFlags, ParserOptions, StyleSheet},
13  targets::{Features, Targets},
14  traits::IntoOwned,
15};
16use rspack_cacheable::{cacheable, cacheable_dyn, with::Skip};
17use rspack_core::{
18  Loader, LoaderContext, RunnerContext,
19  rspack_sources::{
20    MapOptions, Mapping, ObjectPool, OriginalLocation, Source, SourceMap, SourceMapSource,
21    SourceMapSourceOptions, encode_mappings,
22  },
23};
24use rspack_error::{Result, ToStringResultToRspackResultExt};
25use rspack_loader_runner::Identifier;
26use tokio::sync::Mutex;
27
28pub mod config;
29mod plugin;
30
31pub use plugin::LightningcssLoaderPlugin;
32
33pub const LIGHTNINGCSS_LOADER_IDENTIFIER: &str = "builtin:lightningcss-loader";
34
35pub type LightningcssLoaderVisitor = Box<dyn Send + Fn(&mut StyleSheet<'static, 'static>)>;
36
37#[cacheable]
38#[derive(Debug)]
39pub struct LightningCssLoader {
40  id: Identifier,
41  #[debug(skip)]
42  #[cacheable(with=Skip)]
43  visitors: Option<Mutex<Vec<LightningcssLoaderVisitor>>>,
44  config: Config,
45}
46
47impl LightningCssLoader {
48  pub fn new(
49    visitors: Option<Vec<LightningcssLoaderVisitor>>,
50    config: Config,
51    ident: &str,
52  ) -> Self {
53    Self {
54      id: ident.into(),
55      visitors: visitors.map(|v| Mutex::new(v)),
56      config,
57    }
58  }
59
60  async fn loader_impl(&self, loader_context: &mut LoaderContext<RunnerContext>) -> Result<()> {
61    let Some(resource_path) = loader_context.resource_path() else {
62      return Ok(());
63    };
64
65    let filename = resource_path.as_str().to_string();
66
67    let Some(content) = loader_context.take_content() else {
68      return Ok(());
69    };
70
71    let content_str = match &content {
72      rspack_core::Content::String(s) => Cow::Borrowed(s.as_str()),
73      rspack_core::Content::Buffer(buf) => String::from_utf8_lossy(buf),
74    };
75
76    let mut parser_flags = ParserFlags::empty();
77    parser_flags.set(
78      ParserFlags::CUSTOM_MEDIA,
79      matches!(&self.config.drafts, Some(drafts) if drafts.custom_media),
80    );
81    parser_flags.set(
82      ParserFlags::DEEP_SELECTOR_COMBINATOR,
83      matches!(&self.config.non_standard, Some(non_standard) if non_standard.deep_selector_combinator),
84    );
85
86    let error_recovery = self.config.error_recovery.unwrap_or(true);
87    let warnings = if error_recovery {
88      Some(Arc::new(RwLock::new(Vec::new())))
89    } else {
90      None
91    };
92
93    let option = ParserOptions {
94      filename: filename.clone(),
95      css_modules: None,
96      source_index: 0,
97      error_recovery,
98      warnings: warnings.clone(),
99      flags: parser_flags,
100    };
101    let stylesheet = StyleSheet::parse(&content_str, option.clone()).to_rspack_result()?;
102    // FIXME: Disable the warnings for now, cause it cause too much positive-negative warnings,
103    // enable when we have a better way to handle it.
104
105    // if let Some(warnings) = warnings {
106    //   #[allow(clippy::unwrap_used)]
107    //   let warnings = warnings.read().unwrap();
108    //   for warning in warnings.iter() {
109    //     if matches!(
110    //       warning.kind,
111    //       lightningcss::error::ParserError::SelectorError(
112    //         lightningcss::error::SelectorError::UnsupportedPseudoClass(_)
113    //       ) | lightningcss::error::ParserError::SelectorError(
114    //         lightningcss::error::SelectorError::UnsupportedPseudoElement(_)
115    //       )
116    //     ) {
117    //       // ignore parsing errors on pseudo class from lightningcss-loader
118    //       // to allow pseudo class in CSS modules and Vue.
119    //       continue;
120    //     }
121    //     loader_context.emit_diagnostic(Diagnostic::warn(
122    //       "builtin:lightningcss-loader".to_string(),
123    //       format!("LightningCSS parse warning: {}", warning),
124    //     ));
125    //   }
126    // }
127
128    let mut stylesheet = to_static(
129      stylesheet,
130      ParserOptions {
131        filename: filename.clone(),
132        css_modules: None,
133        source_index: 0,
134        error_recovery: true,
135        warnings: None,
136        flags: ParserFlags::empty(),
137      },
138    );
139
140    if let Some(visitors) = &self.visitors {
141      let visitors = visitors.lock().await;
142      for v in visitors.iter() {
143        v(&mut stylesheet);
144      }
145    }
146
147    let targets = Targets {
148      browsers: self.config.targets,
149      include: self
150        .config
151        .include
152        .as_ref()
153        .map_or(Features::empty(), |include| {
154          Features::from_bits_truncate(*include)
155        }),
156      exclude: self
157        .config
158        .exclude
159        .as_ref()
160        .map_or(Features::empty(), |exclude| {
161          Features::from_bits_truncate(*exclude)
162        }),
163    };
164
165    let unused_symbols = self
166      .config
167      .unused_symbols
168      .clone()
169      .map(|unused_symbols| unused_symbols.into_iter().collect())
170      .unwrap_or_default();
171
172    stylesheet
173      .minify(MinifyOptions {
174        targets,
175        unused_symbols,
176      })
177      .to_rspack_result()?;
178
179    let mut parcel_source_map = if loader_context.context.source_map_kind.enabled() {
180      let mut sm = parcel_sourcemap::SourceMap::new(&loader_context.context.options.context);
181      sm.add_source(&filename);
182      sm.set_source_content(0, &content_str).to_rspack_result()?;
183      Some(sm)
184    } else {
185      None
186    };
187
188    let content = stylesheet
189      .to_css(PrinterOptions {
190        minify: self.config.minify.unwrap_or(false),
191        source_map: parcel_source_map.as_mut(),
192        project_root: None,
193        targets,
194        analyze_dependencies: None,
195        pseudo_classes: self
196          .config
197          .pseudo_classes
198          .as_ref()
199          .map(|pseudo_classes| PseudoClasses {
200            hover: pseudo_classes.hover.as_deref(),
201            active: pseudo_classes.active.as_deref(),
202            focus: pseudo_classes.focus.as_deref(),
203            focus_visible: pseudo_classes.focus_visible.as_deref(),
204            focus_within: pseudo_classes.focus_within.as_deref(),
205          }),
206      })
207      .to_rspack_result_with_message(|e| format!("failed to generate css: {e}"))?;
208
209    if let Some(parcel_source_map) = parcel_source_map {
210      let mappings = encode_mappings(parcel_source_map.get_mappings().iter().map(|mapping| {
211        // Parcel source map uses 0-based line numbers, while Rspack source map uses 1-based
212        Mapping {
213          generated_line: mapping.generated_line + 1,
214          generated_column: mapping.generated_column,
215          original: mapping.original.map(|original| OriginalLocation {
216            source_index: original.source,
217            original_line: original.original_line + 1,
218            original_column: original.original_column,
219            name_index: original.name,
220          }),
221        }
222      }));
223
224      let mut posix_context = loader_context
225        .context
226        .options
227        .context
228        .cow_replace("\\", "/");
229      if !posix_context.ends_with('/') {
230        posix_context.to_mut().push('/');
231      }
232      let posix_context = posix_context.into_owned();
233
234      let rspack_source_map = SourceMap::new(
235        mappings,
236        // Parcel stores sources relative to project_root, while Rspack source maps
237        // use absolute module paths for downstream loader/plugin handling.
238        parcel_source_map
239          .get_sources()
240          .iter()
241          .map(|source| {
242            if source.starts_with('/') || source.contains(':') {
243              source.clone()
244            } else {
245              let mut absolute_source = String::with_capacity(posix_context.len() + source.len());
246              absolute_source.push_str(&posix_context);
247              absolute_source.push_str(source);
248              absolute_source
249            }
250          })
251          .collect::<Vec<_>>(),
252        parcel_source_map
253          .get_sources_content()
254          .iter()
255          .map(|source_content| Arc::from(source_content.clone()))
256          .collect::<Vec<_>>(),
257        parcel_source_map
258          .get_names()
259          .iter()
260          .map(ToString::to_string)
261          .collect::<Vec<_>>(),
262      );
263
264      let posix_name = filename.cow_replace("\\", "/");
265      let source_map_source = SourceMapSource::new(SourceMapSourceOptions {
266        value: content.code.clone(),
267        name: posix_name,
268        source_map: rspack_source_map,
269        original_source: None,
270        inner_source_map: loader_context.take_source_map(),
271        remove_original_source: false,
272      });
273      let source_map = source_map_source.map(&ObjectPool::default(), &MapOptions::default());
274      loader_context.finish_with((content.code, source_map));
275    } else {
276      loader_context.finish_with(content.code);
277    }
278
279    Ok(())
280  }
281}
282
283#[cacheable_dyn]
284#[async_trait::async_trait]
285impl Loader<RunnerContext> for LightningCssLoader {
286  fn identifier(&self) -> rspack_loader_runner::Identifier {
287    self.id
288  }
289
290  #[tracing::instrument("loader:lightningcss", skip_all, fields(
291    perfetto.track_name = "loader:lightningcss",
292    perfetto.process_name = "Loader Analysis",
293    resource =loader_context.resource(),
294  ))]
295  async fn run(&self, loader_context: &mut LoaderContext<RunnerContext>) -> Result<()> {
296    // for better diagnostic, as async_trait macro don't show beautiful error message
297    self.loader_impl(loader_context).await
298  }
299}
300
301pub fn to_static(
302  stylesheet: StyleSheet,
303  options: ParserOptions<'static, 'static>,
304) -> StyleSheet<'static, 'static> {
305  let sources = stylesheet.sources.clone();
306  let rules = stylesheet.rules.clone().into_owned();
307
308  StyleSheet::new(sources, rules, options)
309}