diff --git a/Cargo.lock b/Cargo.lock index ca119e8f..b3319900 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1531,6 +1531,7 @@ dependencies = [ "rspack-lightningcss-derive", "rspack-lightningcss-selectors", "rspack-lightningcss-static-self", + "rustc-hash", "schemars", "serde", "serde-content", diff --git a/Cargo.toml b/Cargo.toml index 38d4d136..a60b47ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,7 +50,7 @@ crate-type = ["rlib"] default = ["bundler", "nodejs", "sourcemap"] browserslist = ["browserslist-rs"] bundler = ["dashmap", "custom_sourcemap", "rayon"] -cli = ["clap", "serde_json", "browserslist", "jemallocator"] +cli = ["clap", "serde_json", "browserslist", "jemallocator", "sourcemap"] jsonschema = ["schemars", "serde", "rspack-lightningcss-selectors/jsonschema"] nodejs = ["dep:serde", "dep:serde-content"] serde = [ @@ -62,9 +62,8 @@ serde = [ "rspack-lightningcss-selectors/serde", "into_owned", ] -# Keep the existing feature name as a compatibility alias. -sourcemap = ["custom_sourcemap"] -custom_sourcemap = ["parcel_sourcemap"] +sourcemap = ["custom_sourcemap", "dep:parcel_sourcemap"] +custom_sourcemap = ["dep:rustc-hash"] visitor = [] into_owned = [ "rspack-lightningcss-static-self", @@ -89,6 +88,7 @@ lazy_static = "1.4.0" const-str = "1.1.0" pathdiff = "0.2.1" ahash = "0.8.7" +rustc-hash = { version = "2", optional = true } pastey = "0.1.0" indexmap = { version = "2.2.6", features = ["serde"] } # CLI deps diff --git a/benches/common/mod.rs b/benches/common/mod.rs index 86dfdd97..e1d297eb 100644 --- a/benches/common/mod.rs +++ b/benches/common/mod.rs @@ -1,4 +1,5 @@ use codspeed_criterion_compat::black_box; +use parcel_sourcemap::SourceMap; use rspack_lightningcss::stylesheet::{MinifyOptions, ParserOptions, PrinterOptions, StyleSheet}; use std::fmt::Write; @@ -45,6 +46,29 @@ pub fn transform_stylesheet(css: &str) { black_box(result); } +pub fn transform_stylesheet_with_source_map(css: &str) { + let mut source_map = SourceMap::new("/"); + let source_index = source_map.add_source("bench.css"); + source_map.set_source_content(source_index as usize, css).unwrap(); + + let mut stylesheet = StyleSheet::parse( + css, + ParserOptions { + source_index, + ..ParserOptions::default() + }, + ) + .unwrap(); + stylesheet.minify(MinifyOptions::default()).unwrap(); + let result = stylesheet + .to_css(PrinterOptions { + minify: true, + ..PrinterOptions::default().with_source_map(&mut source_map) + }) + .unwrap(); + black_box((result, source_map)); +} + fn small_fixture() -> String { r#" :root { diff --git a/benches/transform.rs b/benches/transform.rs index 7d105b9e..100ec2b1 100644 --- a/benches/transform.rs +++ b/benches/transform.rs @@ -27,12 +27,19 @@ fn bench_stylesheet_transform(c: &mut Criterion) { for fixture in &fixtures { group.throughput(Throughput::Bytes(fixture.css.len() as u64)); group.bench_with_input( - BenchmarkId::from_parameter(fixture.name), + BenchmarkId::new("no-source-map", fixture.name), fixture.css.as_str(), |b, css| { b.iter(|| common::transform_stylesheet(black_box(css))); }, ); + group.bench_with_input( + BenchmarkId::new("source-map", fixture.name), + fixture.css.as_str(), + |b, css| { + b.iter(|| common::transform_stylesheet_with_source_map(black_box(css))); + }, + ); } group.finish(); diff --git a/src/bundler.rs b/src/bundler.rs index 557200cb..a352067c 100644 --- a/src/bundler.rs +++ b/src/bundler.rs @@ -19,7 +19,7 @@ //! }; //! //! let fs = FileProvider::new(); -//! let mut bundler = Bundler::new(&fs, None, ParserOptions::default()); +//! let mut bundler = Bundler::new(&fs, None::<&mut ()>, ParserOptions::default()); //! let stylesheet = bundler.bundle(Path::new("style.css")).unwrap(); //! ``` @@ -43,6 +43,7 @@ use crate::{ use crate::{ error::{Error, ParserError}, media_query::MediaList, + printer::SourceMap, rules::{ import::ImportRule, media::MediaRule, @@ -52,7 +53,6 @@ use crate::{ stylesheet::{ParserOptions, StyleSheet}, }; use dashmap::DashMap; -use parcel_sourcemap::SourceMap; use rayon::prelude::*; use std::{ collections::HashSet, @@ -63,8 +63,8 @@ use std::{ /// A Bundler combines a CSS file and all imported dependencies together into /// a single merged style sheet. -pub struct Bundler<'a, 's, P, T: AtRuleParser<'a>> { - source_map: Option>, +pub struct Bundler<'a, 's, P, T: AtRuleParser<'a>, S = ()> { + source_map: Option>, fs: &'a P, source_indexes: DashMap, stylesheets: Mutex>>, @@ -236,15 +236,15 @@ impl<'i, T: std::error::Error> BundleErrorKind<'i, T> { } } -impl<'a, 's, P: SourceProvider> Bundler<'a, 's, P, DefaultAtRuleParser> { +impl<'a, 's, P: SourceProvider, S: SourceMap + Send> Bundler<'a, 's, P, DefaultAtRuleParser, S> { /// Creates a new Bundler using the given source provider. /// If a source map is given, the content of each source file included in the bundle will /// be added accordingly. pub fn new( fs: &'a P, - source_map: Option<&'s mut SourceMap>, + source_map: Option<&'s mut S>, options: ParserOptions<'a>, - ) -> Bundler<'a, 's, P, DefaultAtRuleParser> { + ) -> Bundler<'a, 's, P, DefaultAtRuleParser, S> { Bundler { source_map: source_map.map(Mutex::new), fs, @@ -256,7 +256,8 @@ impl<'a, 's, P: SourceProvider> Bundler<'a, 's, P, DefaultAtRuleParser> { } } -impl<'a, 's, P: SourceProvider, T: AtRuleParser<'a> + Clone + Sync + Send> Bundler<'a, 's, P, T> +impl<'a, 's, P: SourceProvider, T: AtRuleParser<'a> + Clone + Sync + Send, S: SourceMap + Send> + Bundler<'a, 's, P, T, S> where T::AtRule: Sync + Send + ToCss + Clone, { @@ -265,7 +266,7 @@ where /// be added accordingly. pub fn new_with_at_rule_parser( fs: &'a P, - source_map: Option<&'s mut SourceMap>, + source_map: Option<&'s mut S>, options: ParserOptions<'a>, at_rule_parser: &'s mut T, ) -> Self { @@ -471,10 +472,10 @@ where if let Some(source_map) = &self.source_map { // Only omit the CSS source when the input source map can actually be read. // Otherwise, the printer needs this index as a fallback for generated mappings. - if stylesheet.source_map(0).is_none() { + if stylesheet.source_map::(0).is_none() { let mut source_map = source_map.lock().unwrap(); let source_index = source_map.add_source(filename); - let _ = source_map.set_source_content(source_index as usize, code); + source_map.set_source_content(source_index, code); source_map_source_index = Some(source_index); } } @@ -969,7 +970,7 @@ mod tests { ); fn bundle(fs: P, entry: &str) -> String { - let mut bundler = Bundler::new(&fs, None, ParserOptions::default()); + let mut bundler = Bundler::<_, _, ()>::new(&fs, None, ParserOptions::default()); let stylesheet = bundler.bundle(Path::new(entry)).unwrap(); stylesheet.to_css(PrinterOptions::default()).unwrap().code } @@ -988,7 +989,7 @@ mod tests { project_root: Option<&str>, pattern: &'static str, ) -> (String, CssModuleExports) { - let mut bundler = Bundler::new( + let mut bundler = Bundler::<_, _, ()>::new( &fs, None, ParserOptions { @@ -1012,7 +1013,7 @@ mod tests { } fn bundle_custom_media(fs: P, entry: &str) -> String { - let mut bundler = Bundler::new( + let mut bundler = Bundler::<_, _, ()>::new( &fs, None, ParserOptions { @@ -1048,7 +1049,7 @@ mod tests { entry: &str, maybe_cb: Option) -> ()>>, ) { - let mut bundler = Bundler::new(&fs, None, ParserOptions::default()); + let mut bundler = Bundler::<_, _, ()>::new(&fs, None, ParserOptions::default()); let res = bundler.bundle(Path::new(entry)); match res { Ok(_) => unreachable!(), @@ -2181,6 +2182,7 @@ mod tests { } #[test] + #[cfg(feature = "sourcemap")] fn test_source_map() { let source = r#".imported { content: "yay, file support!"; @@ -2213,9 +2215,8 @@ mod tests { stylesheet.minify(MinifyOptions::default()).unwrap(); stylesheet .to_css(PrinterOptions { - source_map: Some(&mut sm), minify: true, - ..PrinterOptions::default() + ..PrinterOptions::default().with_source_map(&mut sm) }) .unwrap(); let map = sm.to_json(None).unwrap(); diff --git a/src/lib.rs b/src/lib.rs index 85c465b5..e1a627f3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -56,6 +56,8 @@ mod tests { use crate::dependencies::Dependency; use crate::error::{Error, ErrorLocation, MinifyErrorKind, ParserError, PrinterErrorKind, SelectorError}; use crate::parser::ParserFlags; + #[cfg(feature = "custom_sourcemap")] + use crate::printer::{OriginalLocation, SourceMap}; use crate::properties::custom::Token; use crate::properties::Property; use crate::rules::CssRule; @@ -32661,7 +32663,7 @@ b\0 c'); } #[test] - #[cfg(feature = "custom_sourcemap")] + #[cfg(feature = "sourcemap")] fn test_url_quoting_source_map() { let stylesheet = StyleSheet::parse( r#" @@ -32679,8 +32681,7 @@ b\0 c'); let result = stylesheet .to_css(PrinterOptions { minify: true, - source_map: Some(&mut sm), - ..Default::default() + ..PrinterOptions::default().with_source_map(&mut sm) }) .unwrap(); let column = result.code.find(".bar").unwrap() as u32; @@ -32698,8 +32699,157 @@ b\0 c'); minify_test(".foo { z-index: -9999999 }", ".foo{z-index:-9999999}"); } + #[cfg(feature = "custom_sourcemap")] + #[derive(Default)] + struct TestSourceMap { + sources: Vec, + names: Vec, + sources_content: Vec<(u32, String)>, + mappings: Vec<(u32, u32, Option)>, + } + + #[cfg(feature = "custom_sourcemap")] + impl SourceMap for TestSourceMap { + fn add_source(&mut self, source: &str) -> u32 { + if let Some(index) = self.sources.iter().position(|s| s == source) { + index as u32 + } else { + self.sources.push(source.to_owned()); + (self.sources.len() - 1) as u32 + } + } + + fn add_name(&mut self, name: &str) -> u32 { + if let Some(index) = self.names.iter().position(|s| s == name) { + index as u32 + } else { + self.names.push(name.to_owned()); + (self.names.len() - 1) as u32 + } + } + + fn set_source_content(&mut self, source_index: u32, source_content: &str) { + self.sources_content.push((source_index, source_content.to_owned())); + } + + fn add_mapping(&mut self, generated_line: u32, generated_column: u32, original: Option) { + self.mappings.push((generated_line, generated_column, original)); + } + + fn from_data_url(_source_root: &str, _data_url: &str) -> Option { + None + } + + fn find_closest_mapping(&mut self, line: u32, column: u32) -> Option { + self + .mappings + .iter() + .rev() + .find(|(generated_line, generated_column, _)| (*generated_line, *generated_column) <= (line, column)) + .and_then(|(_, _, original)| *original) + } + + fn get_source(&self, source_index: u32) -> Option<&str> { + self.sources.get(source_index as usize).map(String::as_str) + } + + fn get_name(&self, name_index: u32) -> Option<&str> { + self.names.get(name_index as usize).map(String::as_str) + } + + fn get_source_content(&self, source_index: u32) -> Option<&str> { + self + .sources_content + .iter() + .find(|(index, _)| *index == source_index) + .map(|(_, content)| content.as_str()) + } + } + #[test] #[cfg(feature = "custom_sourcemap")] + fn test_custom_source_map_remapped_content() { + use crate::printer::Printer; + + for content in [None, Some(""), Some(".original { color: red }")] { + let original = OriginalLocation { + original_line: 10, + original_column: 5, + source: 0, + name: Some(0), + }; + let input_map = || TestSourceMap { + sources: vec!["original.scss".into()], + names: vec!["original".into()], + sources_content: content.map(|content| vec![(0, content.into())]).unwrap_or_default(), + mappings: vec![(0, 0, Some(original))], + }; + let mut output = TestSourceMap::default(); + // Ensure the cache uses output indices, which may differ from input indices. + output.add_source("existing.css"); + let mut css = String::new(); + let mut printer = Printer::new(&mut css, PrinterOptions::default().with_source_map(&mut output)); + printer.source_maps = vec![Some(input_map()), Some(input_map())]; + for source_index in [0, 1] { + for line in 0..100 { + printer.add_mapping(Location { + source_index, + line, + column: 1, + }); + } + } + drop(printer); + + assert_eq!(output.sources, vec!["existing.css", "original.scss"]); + assert_eq!(output.names, vec!["original"]); + assert_eq!( + output.sources_content, + content.map(|content| vec![(1, content.into())]).unwrap_or_default() + ); + assert_eq!(output.mappings.len(), 200); + assert!(output + .mappings + .iter() + .all(|(_, _, location)| *location == Some(OriginalLocation { source: 1, ..original }))); + } + } + + #[test] + #[cfg(feature = "custom_sourcemap")] + fn test_custom_source_map() { + let mut stylesheet = StyleSheet::parse(".foo { color: red }", ParserOptions::default()).unwrap(); + stylesheet.minify(MinifyOptions::default()).unwrap(); + + let mut source_map = TestSourceMap::default(); + let result = stylesheet + .to_css( + PrinterOptions { + minify: true, + ..PrinterOptions::default() + } + .with_source_map(&mut source_map), + ) + .unwrap(); + + assert_eq!(result.code, ".foo{color:red}"); + assert_eq!( + source_map.mappings.first(), + Some(&( + 0, + 0, + Some(OriginalLocation { + original_line: 0, + original_column: 0, + source: 0, + name: None, + }) + )) + ); + } + + #[test] + #[cfg(feature = "sourcemap")] fn test_input_source_map() { let source = r#".imported { content: "yay, file support!"; @@ -32721,9 +32871,8 @@ b\0 c'); let mut sm = parcel_sourcemap::SourceMap::new("/"); stylesheet .to_css(PrinterOptions { - source_map: Some(&mut sm), minify: true, - ..PrinterOptions::default() + ..PrinterOptions::default().with_source_map(&mut sm) }) .unwrap(); let map = sm.to_json(None).unwrap(); @@ -32734,7 +32883,7 @@ b\0 c'); } #[test] - #[cfg(feature = "custom_sourcemap")] + #[cfg(feature = "sourcemap")] fn test_source_maps_with_license_comments() { let source = r#"/*! a single line comment */ /*! @@ -32767,9 +32916,8 @@ b\0 c'); stylesheet.minify(MinifyOptions::default()).unwrap(); stylesheet .to_css(PrinterOptions { - source_map: Some(&mut sm), minify: true, - ..PrinterOptions::default() + ..PrinterOptions::default().with_source_map(&mut sm) }) .unwrap(); let map = sm.to_json(None).unwrap(); diff --git a/src/main.rs b/src/main.rs index cbb2ae9b..ee5cfbf0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -211,10 +211,9 @@ pub fn main() -> Result<(), std::io::Error> { stylesheet .to_css(PrinterOptions { minify: cli_args.minify, - source_map: source_map.as_mut(), project_root: Some(&project_root.to_string_lossy()), targets, - ..PrinterOptions::default() + ..PrinterOptions::default().with_source_map(source_map.as_mut()) }) .unwrap() }; diff --git a/src/printer.rs b/src/printer.rs index ca93cf6a..5d322a96 100644 --- a/src/printer.rs +++ b/src/printer.rs @@ -8,18 +8,214 @@ use crate::selector::SelectorList; use crate::targets::{Targets, TargetsWithSupportsScope}; use crate::vendor_prefix::VendorPrefix; use cssparser::{serialize_identifier, serialize_name}; +#[cfg(feature = "sourcemap")] +use parcel_sourcemap::SourceMap as ParcelSourceMap; #[cfg(feature = "custom_sourcemap")] -use parcel_sourcemap::{OriginalLocation, SourceMap}; +use rustc_hash::FxHashSet; use std::fmt::Write; +#[cfg(not(feature = "custom_sourcemap"))] +use std::marker::PhantomData; + +/// An original source location referenced by a generated source map mapping. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OriginalLocation { + /// The zero-based line number in the original source. + pub original_line: u32, + /// The zero-based column number in the original source. + pub original_column: u32, + /// The source index in the source map. + pub source: u32, + /// The optional name index in the source map. + pub name: Option, +} + +/// A source map implementation that can receive generated mappings and +/// provide input mappings for remapping. +#[cfg(feature = "custom_sourcemap")] +pub trait SourceMap { + /// Adds a source path and returns its source index. + fn add_source(&mut self, source: &str) -> u32; + + /// Adds a symbol name and returns its name index. + fn add_name(&mut self, name: &str) -> u32; + + /// Sets the original content for a source index. + fn set_source_content(&mut self, source_index: u32, source_content: &str); + + /// Adds a mapping from a generated position to an optional original location. + fn add_mapping(&mut self, generated_line: u32, generated_column: u32, original: Option); + + /// Reads an inline source map data URL. + fn from_data_url(source_root: &str, data_url: &str) -> Option + where + Self: Sized; + + /// Finds the closest original location for a generated position. + fn find_closest_mapping(&mut self, line: u32, column: u32) -> Option; + + /// Returns the source path for a source index. + fn get_source(&self, source_index: u32) -> Option<&str>; + + /// Returns the symbol name for a name index. + fn get_name(&self, name_index: u32) -> Option<&str>; + + /// Returns the source content for a source index. + fn get_source_content(&self, source_index: u32) -> Option<&str>; +} + +/// A source map placeholder used when custom source maps are disabled. +#[cfg(not(feature = "custom_sourcemap"))] +pub trait SourceMap {} + +#[cfg(not(feature = "custom_sourcemap"))] +impl SourceMap for () {} + +#[cfg(feature = "custom_sourcemap")] +impl SourceMap for () { + fn add_source(&mut self, _source: &str) -> u32 { + unreachable!() + } + + fn add_name(&mut self, _name: &str) -> u32 { + unreachable!() + } + + fn set_source_content(&mut self, _source_index: u32, _source_content: &str) { + unreachable!() + } + + fn add_mapping(&mut self, _generated_line: u32, _generated_column: u32, _original: Option) { + unreachable!() + } + + fn from_data_url(_source_root: &str, _data_url: &str) -> Option { + unreachable!() + } + + fn find_closest_mapping(&mut self, _line: u32, _column: u32) -> Option { + unreachable!() + } + + fn get_source(&self, _source_index: u32) -> Option<&str> { + unreachable!() + } + + fn get_name(&self, _name_index: u32) -> Option<&str> { + unreachable!() + } + + fn get_source_content(&self, _source_index: u32) -> Option<&str> { + unreachable!() + } +} + +#[cfg(feature = "custom_sourcemap")] +impl SourceMap for &mut T { + fn add_source(&mut self, source: &str) -> u32 { + (**self).add_source(source) + } + + fn add_name(&mut self, name: &str) -> u32 { + (**self).add_name(name) + } + + fn set_source_content(&mut self, source_index: u32, source_content: &str) { + (**self).set_source_content(source_index, source_content) + } + + fn add_mapping(&mut self, generated_line: u32, generated_column: u32, original: Option) { + (**self).add_mapping(generated_line, generated_column, original) + } + + fn from_data_url(source_root: &str, data_url: &str) -> Option + where + Self: Sized, + { + let _ = (source_root, data_url); + None + } + + fn find_closest_mapping(&mut self, line: u32, column: u32) -> Option { + (**self).find_closest_mapping(line, column) + } + + fn get_source(&self, source_index: u32) -> Option<&str> { + (**self).get_source(source_index) + } + + fn get_name(&self, name_index: u32) -> Option<&str> { + (**self).get_name(name_index) + } + + fn get_source_content(&self, source_index: u32) -> Option<&str> { + (**self).get_source_content(source_index) + } +} + +#[cfg(feature = "sourcemap")] +#[cfg_attr(docsrs, doc(cfg(feature = "sourcemap")))] +impl SourceMap for ParcelSourceMap { + fn add_source(&mut self, source: &str) -> u32 { + ParcelSourceMap::add_source(self, source) + } + + fn add_name(&mut self, name: &str) -> u32 { + ParcelSourceMap::add_name(self, name) + } + + fn set_source_content(&mut self, source_index: u32, source_content: &str) { + let _ = ParcelSourceMap::set_source_content(self, source_index as usize, source_content); + } + + fn add_mapping(&mut self, generated_line: u32, generated_column: u32, original: Option) { + ParcelSourceMap::add_mapping( + self, + generated_line, + generated_column, + original.map(|original| parcel_sourcemap::OriginalLocation { + original_line: original.original_line, + original_column: original.original_column, + source: original.source, + name: original.name, + }), + ); + } + + fn from_data_url(source_root: &str, data_url: &str) -> Option { + ParcelSourceMap::from_data_url(source_root, data_url).ok() + } + + fn find_closest_mapping(&mut self, line: u32, column: u32) -> Option { + let mapping = ParcelSourceMap::find_closest_mapping(self, line, column)?; + mapping.original.map(|original| OriginalLocation { + original_line: original.original_line, + original_column: original.original_column, + source: original.source, + name: original.name, + }) + } + + fn get_source(&self, source_index: u32) -> Option<&str> { + ParcelSourceMap::get_source(self, source_index).ok() + } + + fn get_name(&self, name_index: u32) -> Option<&str> { + ParcelSourceMap::get_name(self, name_index).ok() + } + + fn get_source_content(&self, source_index: u32) -> Option<&str> { + ParcelSourceMap::get_source_content(self, source_index).ok() + } +} /// Options that control how CSS is serialized to a string. -pub struct PrinterOptions<'a> { +pub struct PrinterOptions<'a, #[cfg(feature = "custom_sourcemap")] S: SourceMap = ()> { /// Whether to minify the CSS, i.e. remove white space. pub minify: bool, - /// An optional reference to a source map to write mappings into. + /// An optional source map to write mappings into. #[cfg(feature = "custom_sourcemap")] #[cfg_attr(docsrs, doc(cfg(feature = "custom_sourcemap")))] - pub source_map: Option<&'a mut SourceMap>, + pub source_map: Option<&'a mut S>, /// An optional project root path, used to generate relative paths for sources used in CSS module hashes. pub project_root: Option<&'a str>, /// Targets to output the CSS for. @@ -51,6 +247,21 @@ impl<'a> Default for PrinterOptions<'a> { } } +#[cfg(feature = "custom_sourcemap")] +impl<'a, S: SourceMap> PrinterOptions<'a, S> { + /// Set the source map writer while retaining all other printer options. + pub fn with_source_map(self, source_map: impl Into>) -> PrinterOptions<'a, T> { + PrinterOptions { + minify: self.minify, + source_map: source_map.into(), + project_root: self.project_root, + targets: self.targets, + analyze_dependencies: self.analyze_dependencies, + pseudo_classes: self.pseudo_classes, + } + } +} + pub(crate) struct PrinterState<'a, 'c> { pub(crate) loc: Location, pub(crate) generated_line: u32, @@ -91,23 +302,44 @@ pub struct PseudoClasses<'a> { /// /// `Printer` also includes helper functions that assist with writing output /// that respects options such as `minify`, and `css_modules`. -pub struct Printer<'a, 'c, W> { +pub struct Printer<'a, 'c, W, S: SourceMap = ()> { pub(crate) sources: Option<&'c Vec>, dest: &'a mut W, pub(crate) options: PrinterOptions<'a>, pub(crate) state: PrinterState<'a, 'c>, #[cfg(feature = "custom_sourcemap")] - pub(crate) source_map: Option<&'a mut SourceMap>, + pub(crate) source_map: Option<&'a mut S>, #[cfg(feature = "custom_sourcemap")] - pub(crate) source_maps: Vec>, + pub(crate) source_maps: Vec>, #[cfg(feature = "custom_sourcemap")] pub(crate) source_map_source_indices: &'c [Option], + #[cfg(feature = "custom_sourcemap")] + sources_with_content: FxHashSet, + #[cfg(not(feature = "custom_sourcemap"))] + source_map: PhantomData S>, +} + +#[cfg(not(feature = "custom_sourcemap"))] +impl<'a, 'c, W: Write> Printer<'a, 'c, W> { + /// Create a new Printer wrapping the given destination. + pub fn new(dest: &'a mut W, options: PrinterOptions<'a>) -> Self { + Self::new_impl(dest, options) + } } -impl<'a, 'c, W: std::fmt::Write + Sized> Printer<'a, 'c, W> { +impl<'a, 'c, W: std::fmt::Write + Sized, S: SourceMap> Printer<'a, 'c, W, S> { /// Create a new Printer wrapping the given destination. - #[cfg_attr(not(feature = "custom_sourcemap"), allow(unused_mut))] - pub fn new(dest: &'a mut W, mut options: PrinterOptions<'a>) -> Self { + #[cfg(feature = "custom_sourcemap")] + pub fn new(dest: &'a mut W, mut options: PrinterOptions<'a, S>) -> Self { + let source_map = options.source_map.take(); + Self::new_impl(dest, options.with_source_map(None), source_map) + } + + fn new_impl( + dest: &'a mut W, + options: PrinterOptions<'a>, + #[cfg(feature = "custom_sourcemap")] source_map: Option<&'a mut S>, + ) -> Self { let dependencies = if options.analyze_dependencies.is_some() { Some(Vec::new()) } else { @@ -118,7 +350,7 @@ impl<'a, 'c, W: std::fmt::Write + Sized> Printer<'a, 'c, W> { sources: None, dest, #[cfg(feature = "custom_sourcemap")] - source_map: options.source_map.take(), + source_map, state: PrinterState { loc: Location { source_index: 0, @@ -141,6 +373,10 @@ impl<'a, 'c, W: std::fmt::Write + Sized> Printer<'a, 'c, W> { source_maps: Vec::new(), #[cfg(feature = "custom_sourcemap")] source_map_source_indices: &[], + #[cfg(feature = "custom_sourcemap")] + sources_with_content: FxHashSet::default(), + #[cfg(not(feature = "custom_sourcemap"))] + source_map: PhantomData, } } @@ -328,23 +564,22 @@ impl<'a, 'c, W: std::fmt::Write + Sized> Printer<'a, 'c, W> { // Remap using input source map if possible. if let Some(Some(sm)) = self.source_maps.get_mut(loc.source_index as usize) { let mut found_mapping = false; - if let Some(mapping) = sm.find_closest_mapping(loc.line, loc.column - 1) { - if let Some(orig) = mapping.original { - let sources_len = map.get_sources().len(); - let source_index = map.add_source(sm.get_source(orig.source).unwrap()); - let name = orig.name.map(|name| map.add_name(sm.get_name(name).unwrap())); - original.original_line = orig.original_line; - original.original_column = orig.original_column; - original.source = source_index; - original.name = name; - - if map.get_sources().len() > sources_len { - let content = sm.get_source_content(orig.source).unwrap().to_owned(); - let _ = map.set_source_content(source_index as usize, &content); + if let Some(orig) = sm.find_closest_mapping(loc.line, loc.column - 1) { + let source_index = map.add_source(sm.get_source(orig.source).unwrap()); + let name = orig.name.map(|name| map.add_name(sm.get_name(name).unwrap())); + original.original_line = orig.original_line; + original.original_column = orig.original_column; + original.source = source_index; + original.name = name; + + if !self.sources_with_content.contains(&source_index) { + if let Some(content) = sm.get_source_content(orig.source) { + map.set_source_content(source_index, content); + self.sources_with_content.insert(source_index); } - - found_mapping = true; } + + found_mapping = true; } if !found_mapping { @@ -352,7 +587,7 @@ impl<'a, 'c, W: std::fmt::Write + Sized> Printer<'a, 'c, W> { } } - map.add_mapping(self.state.generated_line, self.state.generated_col, Some(original)); + map.add_mapping(self.state.generated_line, self.state.generated_col, Some(original)) } } @@ -388,7 +623,7 @@ impl<'a, 'c, W: std::fmt::Write + Sized> Printer<'a, 'c, W> { } } -impl<'a, 'c, W: std::fmt::Write + Sized> Write for Printer<'a, 'c, W> { +impl<'a, 'c, W: std::fmt::Write + Sized, S: SourceMap> Write for Printer<'a, 'c, W, S> { fn write_str(&mut self, s: &str) -> std::fmt::Result { let mut last_line_start = 0; for (idx, c) in s.char_indices() { @@ -493,7 +728,7 @@ pub trait PrinterTrait: std::fmt::Write + private::Sealed { fn without_css_module(&mut self, f: impl FnOnce(&mut Self) -> Result) -> Result; } -impl private::Sealed for Printer<'_, '_, W> { +impl private::Sealed for Printer<'_, '_, W, S> { fn targets(&self) -> &TargetsWithSupportsScope { &self.state.targets } @@ -583,7 +818,7 @@ impl private::Sealed for Printer<'_, '_, W> { } } -impl<'a, 'c, W: std::fmt::Write + Sized> PrinterTrait for Printer<'a, 'c, W> { +impl<'a, 'c, W: std::fmt::Write + Sized, S: SourceMap> PrinterTrait for Printer<'a, 'c, W, S> { fn options(&self) -> &PrinterOptions<'_> { &self.options } diff --git a/src/stylesheet.rs b/src/stylesheet.rs index e492c5b8..085c12c2 100644 --- a/src/stylesheet.rs +++ b/src/stylesheet.rs @@ -12,6 +12,8 @@ use crate::dependencies::Dependency; use crate::error::{Error, ErrorLocation, MinifyErrorKind, ParserError, PrinterError, PrinterErrorKind}; use crate::parser::{DefaultAtRule, DefaultAtRuleParser, TopLevelRuleParser}; use crate::printer::Printer; +#[cfg(feature = "custom_sourcemap")] +use crate::printer::SourceMap; use crate::rules::{CssRule, CssRuleList, MinifyContext}; use crate::targets::{should_compile, Targets, TargetsWithSupportsScope}; use crate::traits::{AtRuleParser, ToCss}; @@ -19,8 +21,6 @@ use crate::values::string::CowArcStr; #[cfg(feature = "visitor")] use crate::visitor::{Visit, VisitTypes, Visitor}; use cssparser::{Parser, ParserInput, StyleSheetParser}; -#[cfg(feature = "custom_sourcemap")] -use parcel_sourcemap::SourceMap; use std::collections::{HashMap, HashSet}; use std::fmt::Write; @@ -226,9 +226,8 @@ where /// Returns the inline source map associated with the source at the given index. #[cfg(feature = "custom_sourcemap")] - #[cfg_attr(docsrs, doc(cfg(feature = "custom_sourcemap")))] - pub fn source_map(&self, source_index: usize) -> Option { - SourceMap::from_data_url("/", self.source_map_url(source_index)?).ok() + pub fn source_map(&self, source_index: usize) -> Option { + S::from_data_url("/", self.source_map_url(source_index)?) } /// Minify and transform the style sheet for the provided browser targets. @@ -276,20 +275,20 @@ where } /// Serialize the style sheet to a CSS string. - pub fn to_css(&self, options: PrinterOptions) -> Result> { + pub fn to_css<'a, #[cfg(feature = "custom_sourcemap")] S: SourceMap>( + &self, + #[cfg(feature = "custom_sourcemap")] options: PrinterOptions<'a, S>, + #[cfg(not(feature = "custom_sourcemap"))] options: PrinterOptions<'a>, + ) -> Result> { // Make sure we always have capacity > 0: https://github.com/napi-rs/napi-rs/issues/1124. let mut dest = String::with_capacity(1); let project_root = options.project_root.clone(); let mut printer = Printer::new(&mut dest, options); - - #[cfg(feature = "custom_sourcemap")] - { - printer.sources = Some(&self.sources); - } + printer.sources = Some(&self.sources); #[cfg(feature = "custom_sourcemap")] if printer.source_map.is_some() { - printer.source_maps = self.sources.iter().enumerate().map(|(i, _)| self.source_map(i)).collect(); + printer.source_maps = self.sources.iter().enumerate().map(|(i, _)| self.source_map::(i)).collect(); printer.source_map_source_indices = &self.source_map_source_indices; } @@ -313,7 +312,7 @@ where printer.newline()?; Ok(ToCssResult { - dependencies: printer.take_dependencies(), + dependencies: printer.state.dependencies, exports: Some(std::mem::take( &mut printer.state.css_module.unwrap().exports_by_source_index[0], )), @@ -325,7 +324,7 @@ where printer.newline()?; Ok(ToCssResult { - dependencies: printer.take_dependencies(), + dependencies: printer.state.dependencies, code: dest, exports: None, references: None, @@ -408,12 +407,6 @@ impl<'i> StyleAttribute<'i> { /// Serializes the style attribute to a CSS string. pub fn to_css(&self, options: PrinterOptions) -> Result { - #[cfg(feature = "custom_sourcemap")] - assert!( - options.source_map.is_none(), - "Source maps are not supported for style attributes" - ); - // Make sure we always have capacity > 0: https://github.com/napi-rs/napi-rs/issues/1124. let mut dest = String::with_capacity(1); let mut printer = Printer::new(&mut dest, options); diff --git a/tests/test_bundler_source_map.rs b/tests/test_bundler_source_map.rs new file mode 100644 index 00000000..33bd511a --- /dev/null +++ b/tests/test_bundler_source_map.rs @@ -0,0 +1,205 @@ +#![cfg(feature = "bundler")] + +use rspack_lightningcss::{ + bundler::{Bundler, ResolveResult, SourceProvider}, + printer::{OriginalLocation, SourceMap}, + rules::CssRule, + stylesheet::{ParserOptions, PrinterOptions}, +}; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +struct TestProvider(HashMap); + +impl SourceProvider for TestProvider { + type Error = std::io::Error; + + fn read<'a>(&'a self, file: &Path) -> Result<&'a str, Self::Error> { + Ok(self.0.get(file).unwrap()) + } + + fn resolve(&self, specifier: &str, originating_file: &Path) -> Result { + Ok(originating_file.with_file_name(specifier).into()) + } +} + +// This test writer supports one inline map fixture. +const INPUT_MAP: &str = r#"data:application/json,{"version":3,"sources":["/original.scss"],"sourcesContent":[".original{color:red}"],"names":[],"mappings":"AAYG"}"#; + +#[derive(Default)] +struct TestSourceMap { + sources: Vec<(u32, String, Option)>, + mappings: Vec, + input_mapping: Option, +} + +impl SourceMap for TestSourceMap { + fn add_source(&mut self, source: &str) -> u32 { + // Simulate a writer that canonicalizes and deduplicates source paths. + let source = if source == "/alias.css" { + "/dependency.css" + } else { + source + }; + if let Some((index, _, _)) = self.sources.iter().find(|(_, path, _)| path == source) { + return *index; + } + + // Source indices need not be contiguous or start at zero. + let index = 100 + self.sources.len() as u32 * 10; + self.sources.push((index, source.into(), None)); + index + } + + fn add_name(&mut self, _: &str) -> u32 { + unreachable!() + } + + fn set_source_content(&mut self, source_index: u32, source_content: &str) { + self.sources.iter_mut().find(|(index, _, _)| *index == source_index).unwrap().2 = Some(source_content.into()); + } + + fn add_mapping(&mut self, _: u32, _: u32, original: Option) { + self.mappings.push(original.unwrap()); + } + + fn from_data_url(_: &str, data_url: &str) -> Option { + if data_url != INPUT_MAP { + return None; + } + + let mut map = Self::default(); + let source = map.add_source("/original.scss"); + map.set_source_content(source, ".original{color:red}"); + map.input_mapping = Some(OriginalLocation { + original_line: 12, + original_column: 3, + source, + name: None, + }); + Some(map) + } + + fn find_closest_mapping(&mut self, _: u32, _: u32) -> Option { + self.input_mapping + } + + fn get_source(&self, source_index: u32) -> Option<&str> { + self + .sources + .iter() + .find(|(index, _, _)| *index == source_index) + .map(|(_, source, _)| source.as_str()) + } + + fn get_name(&self, _: u32) -> Option<&str> { + None + } + + fn get_source_content(&self, source_index: u32) -> Option<&str> { + self + .sources + .iter() + .find(|(index, _, _)| *index == source_index) + .and_then(|(_, _, content)| content.as_deref()) + } +} + +#[test] +fn translates_bundler_source_indices() { + let entry = "@import 'dependency.css';\n@import 'alias.css';\n.entry { color: red }"; + let dependency = ".dependency { color: blue }"; + let fs = TestProvider(HashMap::from([ + (PathBuf::from("/entry.css"), entry.into()), + (PathBuf::from("/dependency.css"), dependency.into()), + (PathBuf::from("/alias.css"), dependency.into()), + ])); + + for prepopulated in [false, true] { + let mut map = TestSourceMap::default(); + if prepopulated { + let existing = map.add_source("/existing.css"); + map.set_source_content(existing, "existing content"); + map.add_source("/dependency.css"); + } + let mut bundler = Bundler::new(&fs, Some(&mut map), ParserOptions::default()); + let stylesheet = bundler.bundle(Path::new("/entry.css")).unwrap(); + + // Rule locations must still refer to the stylesheet's internal source list. + let rules: Vec<_> = stylesheet + .rules + .0 + .iter() + .filter_map(|rule| match rule { + CssRule::Style(rule) => Some(rule), + _ => None, + }) + .collect(); + assert_eq!(rules.len(), 3); + for (rule, source) in rules.iter().zip(["/dependency.css", "/alias.css", "/entry.css"]) { + assert_eq!(stylesheet.sources[rule.loc.source_index as usize], source); + } + + stylesheet.to_css(PrinterOptions::default().with_source_map(&mut map)).unwrap(); + assert_eq!(map.mappings.len(), 3); + for (mapping, (source, content)) in map.mappings.iter().zip([ + ("/dependency.css", dependency), + ("/dependency.css", dependency), + ("/entry.css", entry), + ]) { + assert_eq!(map.get_source(mapping.source), Some(source)); + assert_eq!(map.get_source_content(mapping.source), Some(content)); + } + assert_eq!(map.mappings[0].source, map.mappings[1].source); + if prepopulated { + assert_eq!(map.get_source_content(100), Some("existing content")); + } + } +} + +#[test] +fn translates_bundler_indices_alongside_inline_remapping() { + let dependency = ".dependency { color: blue }"; + let fs = TestProvider(HashMap::from([ + ( + PathBuf::from("/entry.css"), + format!("@import 'dependency.css';\n.entry {{ color: red }}\n/*# sourceMappingURL={INPUT_MAP} */"), + ), + (PathBuf::from("/dependency.css"), dependency.into()), + ])); + let mut map = TestSourceMap::default(); + map.add_source("/existing.css"); + let mut bundler = Bundler::new(&fs, Some(&mut map), ParserOptions::default()); + let stylesheet = bundler.bundle(Path::new("/entry.css")).unwrap(); + stylesheet.to_css(PrinterOptions::default().with_source_map(&mut map)).unwrap(); + + assert_eq!(map.mappings.len(), 2); + let dependency_mapping = map.mappings[0]; + assert_eq!(map.get_source(dependency_mapping.source), Some("/dependency.css")); + assert_eq!(map.get_source_content(dependency_mapping.source), Some(dependency)); + let original = map.mappings[1]; + assert_eq!(map.get_source(original.source), Some("/original.scss")); + assert_eq!(map.get_source_content(original.source), Some(".original{color:red}")); + assert_eq!((original.original_line, original.original_column), (12, 3)); + assert!(!map.sources.iter().any(|(_, source, _)| source == "/entry.css")); +} + +#[test] +fn registers_bundled_source_when_input_map_cannot_be_read() { + for source_map_url in ["data-bundle.map", "data:application/json,not-a-map"] { + let entry = format!(".entry {{ color: red }}\n/*# sourceMappingURL={source_map_url} */"); + let fs = TestProvider(HashMap::from([(PathBuf::from("/entry.css"), entry.clone())])); + let mut map = TestSourceMap::default(); + let mut bundler = Bundler::new(&fs, Some(&mut map), ParserOptions::default()); + let stylesheet = bundler.bundle(Path::new("/entry.css")).unwrap(); + stylesheet.to_css(PrinterOptions::default().with_source_map(&mut map)).unwrap(); + + assert_eq!(map.mappings.len(), 1); + let source = map.mappings[0].source; + assert_eq!(source, 100); + assert_eq!(map.get_source(source), Some("/entry.css")); + assert_eq!(map.get_source_content(source), Some(entry.as_str())); + } +} diff --git a/tests/test_parcel_bundler_source_map.rs b/tests/test_parcel_bundler_source_map.rs index 198a2db1..1b843e2e 100644 --- a/tests/test_parcel_bundler_source_map.rs +++ b/tests/test_parcel_bundler_source_map.rs @@ -33,12 +33,7 @@ fn assert_bundled_source(css: &'static str) { let mut bundler = Bundler::new(&fs, Some(&mut map), ParserOptions::default()); let stylesheet = bundler.bundle(Path::new("/entry.css")).unwrap(); - stylesheet - .to_css(PrinterOptions { - source_map: Some(&mut map), - ..PrinterOptions::default() - }) - .unwrap(); + stylesheet.to_css(PrinterOptions::default().with_source_map(&mut map)).unwrap(); let source_index = map.find_closest_mapping(0, 0).unwrap().original.unwrap().source; assert_ne!(source_index, existing_index);