1use std::{
2 borrow::Cow,
3 hash::Hasher,
4 path::{Component, Path, PathBuf},
5 sync::{Arc, LazyLock},
6};
7
8use cow_utils::CowUtils;
9use derive_more::Debug;
10use futures::future::{BoxFuture, join_all};
11use itertools::Itertools;
12use rayon::prelude::*;
13use regex::Regex;
14use rspack_collections::DatabaseItem;
15use rspack_core::{
16 AssetInfo, Chunk, ChunkUkey, Compilation, CompilationAsset, CompilationProcessAssets, Filename,
17 Logger, ModuleIdentifier, PathData, Plugin,
18 rspack_sources::{ConcatSource, MapOptions, RawStringSource, Source, SourceExt},
19};
20use rspack_error::{Result, ToStringResultToRspackResultExt, error, miette::IntoDiagnostic};
21use rspack_hash::RspackHash;
22use rspack_hook::{plugin, plugin_hook};
23use rspack_util::{asset_condition::AssetConditions, identifier::make_paths_absolute};
24use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
25use sugar_path::SugarPath;
26
27use crate::{
28 ModuleFilenameTemplateFn, ModuleOrSource, generate_debug_id::generate_debug_id,
29 mapped_assets_cache::MappedAssetsCache, module_filename_helpers::ModuleFilenameHelpers,
30};
31
32static SCHEMA_SOURCE_REGEXP: LazyLock<Regex> =
33 LazyLock::new(|| Regex::new(r"^(data|https?):").expect("failed to compile SCHEMA_SOURCE_REGEXP"));
34
35static CSS_EXTENSION_DETECT_REGEXP: LazyLock<Regex> = LazyLock::new(|| {
36 Regex::new(r"\.css($|\?)").expect("failed to compile CSS_EXTENSION_DETECT_REGEXP")
37});
38static URL_FORMATTING_REGEXP: LazyLock<Regex> = LazyLock::new(|| {
39 Regex::new(r"^\n\/\/(.*)$").expect("failed to compile URL_FORMATTING_REGEXP regex")
40});
41
42#[derive(Clone)]
43pub enum ModuleFilenameTemplate {
44 String(String),
45 Fn(ModuleFilenameTemplateFn),
46}
47
48type AppendFn = Box<dyn Fn(PathData) -> BoxFuture<'static, Result<String>> + Sync + Send>;
49
50pub enum Append {
51 String(String),
52 Fn(AppendFn),
53 Disabled,
54}
55
56#[derive(Debug)]
57pub struct SourceMapDevToolPluginOptions {
58 #[debug(skip)]
60 pub append: Option<Append>,
61 pub columns: bool,
63 #[debug(skip)]
65 pub fallback_module_filename_template: Option<ModuleFilenameTemplate>,
66 pub file_context: Option<String>,
68 pub filename: Option<String>,
70 pub module: bool,
72 #[debug(skip)]
74 pub module_filename_template: Option<ModuleFilenameTemplate>,
75 pub namespace: Option<String>,
77 pub no_sources: bool,
79 pub public_path: Option<String>,
81 pub source_root: Option<String>,
83 pub test: Option<AssetConditions>,
84 pub include: Option<AssetConditions>,
85 pub exclude: Option<AssetConditions>,
86 pub debug_ids: bool,
87}
88
89enum SourceMappingUrlComment {
90 String(String),
91 Fn(AppendFn),
92}
93
94enum SourceMappingUrlCommentRef<'a> {
95 String(Cow<'a, str>),
96 Fn(&'a AppendFn),
97}
98
99#[derive(Debug, Clone)]
100pub(crate) struct MappedAsset {
101 pub(crate) asset: (String, CompilationAsset),
102 pub(crate) source_map: Option<(String, CompilationAsset)>,
103}
104
105#[plugin]
106#[derive(Debug)]
107pub struct SourceMapDevToolPlugin {
108 source_map_filename: Option<Filename>,
109 #[debug(skip)]
110 source_mapping_url_comment: Option<SourceMappingUrlComment>,
111 file_context: Option<String>,
112 #[debug(skip)]
113 module_filename_template: ModuleFilenameTemplate,
114 #[debug(skip)]
115 fallback_module_filename_template: ModuleFilenameTemplate,
116 namespace: String,
117 columns: bool,
118 no_sources: bool,
119 public_path: Option<String>,
120 #[expect(dead_code)]
121 module: bool,
122 source_root: Option<Arc<str>>,
123 test: Option<AssetConditions>,
124 include: Option<AssetConditions>,
125 exclude: Option<AssetConditions>,
126 debug_ids: bool,
127 mapped_assets_cache: MappedAssetsCache,
128}
129
130fn match_object(obj: &SourceMapDevToolPlugin, str: &str) -> bool {
131 if let Some(condition) = &obj.test
132 && !condition.try_match(str)
133 {
134 return false;
135 }
136 if let Some(condition) = &obj.include
137 && !condition.try_match(str)
138 {
139 return false;
140 }
141 if let Some(condition) = &obj.exclude
142 && condition.try_match(str)
143 {
144 return false;
145 }
146 true
147}
148
149impl SourceMapDevToolPlugin {
150 pub fn new(options: SourceMapDevToolPluginOptions) -> Self {
151 let source_mapping_url_comment = match options.append {
152 Some(append) => match append {
153 Append::String(s) => Some(SourceMappingUrlComment::String(s)),
154 Append::Fn(f) => Some(SourceMappingUrlComment::Fn(f)),
155 Append::Disabled => None,
156 },
157 None => Some(SourceMappingUrlComment::String(
158 "\n//# sourceMappingURL=[url]".to_string(),
159 )),
160 };
161
162 let fallback_module_filename_template =
163 options
164 .fallback_module_filename_template
165 .unwrap_or(ModuleFilenameTemplate::String(
166 "webpack://[namespace]/[resourcePath]?[hash]".to_string(),
167 ));
168
169 let module_filename_template =
170 options
171 .module_filename_template
172 .unwrap_or(ModuleFilenameTemplate::String(
173 "webpack://[namespace]/[resourcePath]".to_string(),
174 ));
175
176 Self::new_inner(
177 options.filename.map(Filename::from),
178 source_mapping_url_comment,
179 options.file_context,
180 module_filename_template,
181 fallback_module_filename_template,
182 options.namespace.unwrap_or("".to_string()),
183 options.columns,
184 options.no_sources,
185 options.public_path,
186 options.module,
187 options.source_root.map(Arc::from),
188 options.test,
189 options.include,
190 options.exclude,
191 options.debug_ids,
192 MappedAssetsCache::new(),
193 )
194 }
195
196 async fn map_assets(
197 &self,
198 compilation: &Compilation,
199 file_to_chunk: &HashMap<&String, &Chunk>,
200 raw_assets: Vec<(String, &CompilationAsset)>,
201 ) -> Result<Vec<MappedAsset>> {
202 let output_options = &compilation.options.output;
203 let map_options = MapOptions::new(self.columns);
204 let need_match = self.test.is_some() || self.include.is_some() || self.exclude.is_some();
205
206 let mut mapped_sources = raw_assets
207 .into_par_iter()
208 .filter_map(|(file, asset)| {
209 let is_match = if need_match {
210 match_object(self, &file)
211 } else {
212 true
213 };
214
215 if is_match {
216 asset.get_source().map(|source| {
217 let source_map = source.map(&map_options);
218 (file, source, source_map)
219 })
220 } else {
221 None
222 }
223 })
224 .collect::<Vec<_>>();
225
226 let source_map_modules = mapped_sources
227 .par_iter()
228 .filter_map(|(file, _asset, source_map)| source_map.as_ref().map(|s| (file, s)))
229 .flat_map(|(file, source_map)| {
230 source_map
231 .sources()
232 .iter()
233 .map(|i| (file, i))
234 .collect::<Vec<_>>()
235 })
236 .map(|(file, source)| {
237 let module_or_source = if let Some(stripped) = source.strip_prefix("webpack://") {
238 let source = make_paths_absolute(compilation.options.context.as_str(), stripped);
239 let identifier = ModuleIdentifier::from(source.as_str());
240 match compilation
241 .get_module_graph()
242 .module_by_identifier(&identifier)
243 {
244 Some(module) => ModuleOrSource::Module(module.identifier()),
245 None => ModuleOrSource::Source(source),
246 }
247 } else {
248 ModuleOrSource::Source(source.to_string())
249 };
250
251 (source.to_string(), (file.to_string(), module_or_source))
252 })
253 .collect::<HashMap<_, _>>();
254
255 let mut module_to_source_name = match &self.module_filename_template {
256 ModuleFilenameTemplate::String(template) => {
257 let source_names = rspack_futures::scope::<_, Result<_>>(|token| {
258 source_map_modules.values()
259 .for_each(|(file, module_or_source)| {
260 let s = unsafe {
261 token.used((
262 self.namespace.as_str(),
263 &compilation,
264 file,
265 module_or_source,
266 file_to_chunk,
267 template,
268 ))
269 };
270 s.spawn(
271 |(namespace, compilation, file, module_or_source, file_to_chunk, template)| async move {
272 if let ModuleOrSource::Source(source) = module_or_source
273 && SCHEMA_SOURCE_REGEXP.is_match(source) {
274 return Ok(source.to_string());
275 }
276
277 let chunk = file_to_chunk.get(file);
278 let path_data = PathData::default()
279 .chunk_id_optional(
280 chunk
281 .and_then(|c| c.id(&compilation.chunk_ids_artifact).map(|id| id.as_str())),
282 )
283 .chunk_name_optional(chunk.and_then(|c| c.name()))
284 .chunk_hash_optional(chunk.and_then(|c| {
285 c.rendered_hash(
286 &compilation.chunk_hashes_artifact,
287 compilation.options.output.hash_digest_length,
288 )
289 }));
290
291 let filename = Filename::from(namespace);
292 let namespace = compilation.get_path(&filename, path_data).await?;
293
294 let source_name = ModuleFilenameHelpers::create_filename_of_string_template(
295 module_or_source,
296 compilation,
297 template,
298 &compilation.options.output,
299 &namespace,
300 );
301 Ok(source_name)
302 },
303 );
304 })
305 })
306 .await
307 .into_iter()
308 .map(|r| r.to_rspack_result())
309 .collect::<Result<Vec<_>>>()?;
310
311 let mut res = HashMap::default();
312
313 for ((_, module_or_source), source_name) in
314 source_map_modules.values().zip(source_names.into_iter())
315 {
316 res.insert(module_or_source, source_name?);
317 }
318
319 res
320 }
321 ModuleFilenameTemplate::Fn(f) => {
322 let tasks = source_map_modules
324 .values()
325 .map(|(_, module_or_source)| async move {
326 if let ModuleOrSource::Source(source) = module_or_source
327 && SCHEMA_SOURCE_REGEXP.is_match(source)
328 {
329 return Ok((module_or_source, source.to_string()));
330 }
331
332 let source_name = ModuleFilenameHelpers::create_filename_of_fn_template(
333 module_or_source,
334 compilation,
335 f,
336 output_options,
337 &self.namespace,
338 )
339 .await?;
340 Ok((module_or_source, source_name))
341 })
342 .collect::<Vec<_>>();
343 join_all(tasks)
344 .await
345 .into_iter()
346 .collect::<Result<HashMap<_, _>>>()?
347 }
348 };
349
350 let mut used_names_set = HashSet::<&String>::default();
351 for (module_or_source, source_name) in
352 module_to_source_name
353 .iter_mut()
354 .sorted_by(|(key_a, _), (key_b, _)| {
355 let ident_a = match key_a {
356 ModuleOrSource::Module(identifier) => identifier,
357 ModuleOrSource::Source(source) => source.as_str(),
358 };
359 let ident_b = match key_b {
360 ModuleOrSource::Module(identifier) => identifier,
361 ModuleOrSource::Source(source) => source.as_str(),
362 };
363 ident_a.len().cmp(&ident_b.len())
364 })
365 {
366 let mut has_name = used_names_set.contains(source_name);
367 if !has_name {
368 used_names_set.insert(source_name);
369 continue;
370 }
371
372 let mut new_source_name = match &self.fallback_module_filename_template {
374 ModuleFilenameTemplate::String(s) => {
375 ModuleFilenameHelpers::create_filename_of_string_template(
376 module_or_source,
377 compilation,
378 s,
379 output_options,
380 self.namespace.as_str(),
381 )
382 }
383 ModuleFilenameTemplate::Fn(f) => {
384 ModuleFilenameHelpers::create_filename_of_fn_template(
385 module_or_source,
386 compilation,
387 f,
388 output_options,
389 self.namespace.as_str(),
390 )
391 .await?
392 }
393 };
394
395 has_name = used_names_set.contains(&new_source_name);
396 if !has_name {
397 *source_name = new_source_name;
398 used_names_set.insert(source_name);
399 continue;
400 }
401
402 while has_name {
404 new_source_name.push('*');
405 has_name = used_names_set.contains(&new_source_name);
406 }
407 *source_name = new_source_name;
408 used_names_set.insert(source_name);
409 }
410
411 for (filename, _asset, source_map) in mapped_sources.iter_mut() {
412 if let Some(source_map) = source_map {
413 source_map.set_file(Some(filename.clone()));
414
415 source_map.set_sources(
416 source_map
417 .sources()
418 .iter()
419 .map(|source| {
420 let module_or_source = source_map_modules
421 .get(source)
422 .expect("expected a module or source");
423 module_to_source_name
424 .get(&module_or_source.1)
425 .expect("expected a filename at the given index but found None")
426 .clone()
427 })
428 .collect::<Vec<_>>(),
429 );
430 if self.no_sources {
431 source_map.set_sources_content([]);
432 }
433 if let Some(source_root) = &self.source_root {
434 source_map.set_source_root(Some(source_root.clone()));
435 }
436 }
437 }
438
439 let mapped_assets = rspack_futures::scope::<_, Result<_>>(|token| {
440 mapped_sources
441 .into_iter()
442 .for_each(|(source_filename, source, source_map)| {
443 let s = unsafe {
444 token.used((
445 &self,
446 compilation,
447 file_to_chunk,
448 source_filename,
449 source,
450 source_map,
451 ))
452 };
453
454 s.spawn(
455 |(plugin, compilation, file_to_chunk, source_filename, source, source_map)| async move {
456 let (source_map_json, debug_id) = match source_map {
457 Some(mut map) => {
458 let debug_id = plugin.debug_ids.then(|| {
459 let debug_id = generate_debug_id(&source_filename, &source.buffer());
460 map.set_debug_id(Some(debug_id.clone()));
461 debug_id
462 });
463
464 (Some(map.to_json().into_diagnostic()?), debug_id)
465 }
466 None => (None, None),
467 };
468
469 let mut asset = compilation
470 .assets()
471 .get(&source_filename)
472 .unwrap_or_else(|| {
473 panic!(
474 "expected to find filename '{}' in compilation.assets, but it was not present",
475 &source_filename
476 )
477 })
478 .clone();
479 let Some(source_map_json) = source_map_json else {
480 return Ok(MappedAsset {
481 asset: (source_filename, asset),
482 source_map: None,
483 });
484 };
485 let css_extension_detected = CSS_EXTENSION_DETECT_REGEXP.is_match(&source_filename);
486 let current_source_mapping_url_comment = match &plugin.source_mapping_url_comment {
487 Some(SourceMappingUrlComment::String(s)) => {
488 let s = if css_extension_detected {
489 URL_FORMATTING_REGEXP.replace_all(s, "\n/*$1*/")
490 } else {
491 Cow::from(s)
492 };
493 Some(SourceMappingUrlCommentRef::String(s))
494 }
495 Some(SourceMappingUrlComment::Fn(f)) => Some(SourceMappingUrlCommentRef::Fn(f)),
496 None => None,
497 };
498
499 if let Some(source_map_filename_config) = &plugin.source_map_filename {
500 let chunk = file_to_chunk.get(&source_filename);
501 let filename = match &plugin.file_context {
502 Some(file_context) => Cow::Owned(
503 Path::new(&source_filename)
504 .relative(Path::new(file_context))
505 .to_string_lossy()
506 .to_string(),
507 ),
508 None => Cow::Borrowed(&source_filename),
509 };
510
511 let mut hasher = RspackHash::from(&compilation.options.output);
512 hasher.write(source_map_json.as_bytes());
513 let digest = hasher.digest(&compilation.options.output.hash_digest);
514
515 let data = PathData::default().filename(&filename);
516 let data = match chunk {
517 Some(chunk) => data
518 .chunk_id_optional(
519 chunk
520 .id(&compilation.chunk_ids_artifact)
521 .map(|id| id.as_str()),
522 )
523 .chunk_hash_optional(chunk.rendered_hash(
524 &compilation.chunk_hashes_artifact,
525 compilation.options.output.hash_digest_length,
526 ))
527 .chunk_name_optional(
528 chunk.name_for_filename_template(&compilation.chunk_ids_artifact),
529 )
530 .content_hash_optional(Some(digest.encoded())),
531 None => data,
532 };
533 let source_map_filename = compilation
534 .get_asset_path(source_map_filename_config, data)
535 .await?;
536
537 if let Some(current_source_mapping_url_comment) = current_source_mapping_url_comment
538 {
539 let source_map_url = if let Some(public_path) = &plugin.public_path {
540 format!("{public_path}{source_map_filename}")
541 } else {
542 let mut file_path = PathBuf::new();
543 file_path.push(Component::RootDir);
544 file_path.extend(Path::new(filename.as_ref()).components());
545
546 let mut source_map_path = PathBuf::new();
547 source_map_path.push(Component::RootDir);
548 source_map_path.extend(Path::new(&source_map_filename).components());
549
550 source_map_path
551 .relative(
552 #[allow(clippy::unwrap_used)]
553 file_path.parent().unwrap(),
554 )
555 .to_string_lossy()
556 .to_string()
557 };
558 let data = data.url(&source_map_url);
559 let current_source_mapping_url_comment = match ¤t_source_mapping_url_comment
560 {
561 SourceMappingUrlCommentRef::String(s) => {
562 compilation
563 .get_asset_path(&Filename::from(s.as_ref()), data)
564 .await?
565 }
566 SourceMappingUrlCommentRef::Fn(f) => {
567 let comment = f(data).await?;
568 Filename::from(comment).render(data, None).await?
569 }
570 };
571 let current_source_mapping_url_comment = current_source_mapping_url_comment
572 .cow_replace("[url]", &source_map_url)
573 .into_owned();
574
575 let debug_id_comment = debug_id
576 .map(|id| format!("\n//# debugId={id}"))
577 .unwrap_or_default();
578
579 asset.source = Some(
580 ConcatSource::new([
581 source.clone(),
582 RawStringSource::from(debug_id_comment).boxed(),
583 RawStringSource::from(current_source_mapping_url_comment).boxed(),
584 ])
585 .boxed(),
586 );
587 asset.info.related.source_map = Some(source_map_filename.clone());
588 } else {
589 asset.source = Some(source.clone());
590 }
591 let mut source_map_asset_info = AssetInfo::default().with_development(Some(true));
592 if let Some(asset) = compilation.assets().get(&source_filename) {
593 source_map_asset_info.version = asset.info.version.clone();
595 }
596 let source_map_asset = CompilationAsset::new(
597 Some(RawStringSource::from(source_map_json).boxed()),
598 source_map_asset_info,
599 );
600 Ok(MappedAsset {
601 asset: (source_filename, asset),
602 source_map: Some((source_map_filename, source_map_asset)),
603 })
604 } else {
605 let current_source_mapping_url_comment = current_source_mapping_url_comment.expect(
606 "SourceMapDevToolPlugin: append can't be false when no filename is provided.",
607 );
608 let current_source_mapping_url_comment = match ¤t_source_mapping_url_comment {
609 SourceMappingUrlCommentRef::String(s) => s,
610 SourceMappingUrlCommentRef::Fn(_) => {
611 return Err(error!(
612 "SourceMapDevToolPlugin: append can't be a function when no filename is provided"
613 ))
614 }
615 };
616 let base64 = rspack_base64::encode_to_string(source_map_json.as_bytes());
617 asset.source = Some(
618 ConcatSource::new([
619 source.clone(),
620 RawStringSource::from(
621 current_source_mapping_url_comment
622 .cow_replace(
623 "[url]",
624 &format!("data:application/json;charset=utf-8;base64,{base64}"),
625 )
626 .into_owned(),
627 )
628 .boxed(),
629 ])
630 .boxed(),
631 );
632 Ok(MappedAsset {
633 asset: (source_filename, asset),
634 source_map: None,
635 })
636 }
637 },
638 );
639 });
640 })
641 .await
642 .into_iter()
643 .map(|r| r.to_rspack_result())
644 .collect::<Result<Vec<_>>>()?;
645
646 mapped_assets.into_iter().collect::<Result<Vec<_>>>()
647 }
648}
649
650#[plugin_hook(CompilationProcessAssets for SourceMapDevToolPlugin, stage = Compilation::PROCESS_ASSETS_STAGE_DEV_TOOLING)]
651async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> {
652 let logger = compilation.get_logger("rspack.SourceMapDevToolPlugin");
653
654 let mut file_to_chunk: HashMap<&String, &Chunk> = HashMap::default();
656 let mut file_to_chunk_ukey: HashMap<String, ChunkUkey> = HashMap::default();
658 for chunk in compilation.chunk_by_ukey.values() {
659 for file in chunk.files() {
660 file_to_chunk.insert(file, chunk);
661 file_to_chunk_ukey.insert(file.to_string(), chunk.ukey());
662 }
663 for file in chunk.auxiliary_files() {
664 file_to_chunk.insert(file, chunk);
665 file_to_chunk_ukey.insert(file.to_string(), chunk.ukey());
666 }
667 }
668
669 let start = logger.time("collect source maps");
670 let raw_assets = compilation
671 .assets()
672 .iter()
673 .filter(|(_filename, asset)| asset.info.related.source_map.is_none())
674 .collect::<Vec<_>>();
675 let mapped_asstes = self
676 .mapped_assets_cache
677 .use_cache(raw_assets, |assets| {
678 self.map_assets(compilation, &file_to_chunk, assets)
679 })
680 .await?;
681 logger.time_end(start);
682
683 let start = logger.time("emit source map assets");
684 for mapped_asset in mapped_asstes {
685 let MappedAsset {
686 asset: (source_filename, mut source_asset),
687 source_map,
688 } = mapped_asset;
689 if let Some(asset) = compilation.assets_mut().remove(&source_filename) {
690 source_asset.info = asset.info;
691 if let Some((ref source_map_filename, _)) = source_map {
692 source_asset.info.related.source_map = Some(source_map_filename.clone());
693 }
694 }
695
696 let chunk_ukey = file_to_chunk_ukey.get(&source_filename);
697 compilation.emit_asset(source_filename, source_asset);
698 if let Some((source_map_filename, source_map_asset)) = source_map {
699 compilation.emit_asset(source_map_filename.to_owned(), source_map_asset);
700
701 let chunk = chunk_ukey.map(|ukey| compilation.chunk_by_ukey.expect_get_mut(ukey));
702 if let Some(chunk) = chunk {
703 chunk.add_auxiliary_file(source_map_filename);
704 }
705 }
706 }
707 logger.time_end(start);
708
709 Ok(())
710}
711
712impl Plugin for SourceMapDevToolPlugin {
713 fn name(&self) -> &'static str {
714 "rspack.SourceMapDevToolPlugin"
715 }
716
717 fn apply(&self, ctx: &mut rspack_core::ApplyContext<'_>) -> Result<()> {
718 ctx
719 .compilation_hooks
720 .process_assets
721 .tap(process_assets::new(self));
722 Ok(())
723 }
724}