1use std::{
2 borrow::Cow,
3 fmt::Display,
4 hash::Hash,
5 ops::DerefMut,
6 path::{MAIN_SEPARATOR, Path, PathBuf},
7 sync::{Arc, LazyLock, Mutex},
8};
9
10use dashmap::DashSet;
11use derive_more::Debug;
12use futures::future::{BoxFuture, join_all};
13use glob::{MatchOptions, Pattern as GlobPattern};
14use regex::Regex;
15use rspack_core::{
16 AssetInfo, AssetInfoRelated, Compilation, CompilationAsset, CompilationLogger,
17 CompilationProcessAssets, Filename, Logger, PathData, Plugin,
18 rspack_sources::{RawSource, Source},
19};
20use rspack_error::{Diagnostic, Error, Result};
21use rspack_hash::{HashDigest, HashFunction, HashSalt, RspackHash, RspackHashDigest};
22use rspack_hook::{plugin, plugin_hook};
23use rspack_paths::{AssertUtf8, Utf8Path, Utf8PathBuf};
24use sugar_path::SugarPath;
25
26#[derive(Debug)]
27pub struct CopyRspackPluginOptions {
28 pub patterns: Vec<CopyPattern>,
29}
30
31#[derive(Debug, Clone)]
32pub struct Info {
33 pub immutable: Option<bool>,
34 pub minimized: Option<bool>,
35 pub chunk_hash: Option<Vec<String>>,
36 pub content_hash: Option<Vec<String>>,
37 pub development: Option<bool>,
38 pub hot_module_replacement: Option<bool>,
39 pub related: Option<Related>,
40 pub version: Option<String>,
41}
42
43#[derive(Debug, Clone)]
44pub struct Related {
45 pub source_map: Option<String>,
46}
47
48#[derive(Debug, Clone, Copy)]
49pub enum FromType {
50 Dir,
51 File,
52 Glob,
53}
54
55#[derive(Debug, Clone)]
56pub enum ToType {
57 Dir,
58 File,
59 Template,
60}
61
62impl Display for ToType {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 f.write_str(match self {
65 ToType::Dir => "dir",
66 ToType::File => "file",
67 ToType::Template => "template",
68 })
69 }
70}
71
72pub type TransformerFn =
73 Box<dyn for<'a> Fn(Vec<u8>, &'a str) -> BoxFuture<'a, Result<RawSource>> + Sync + Send>;
74
75pub struct ToFnCtx<'a> {
76 pub context: &'a Utf8Path,
77 pub absolute_filename: &'a Utf8Path,
78}
79
80pub type ToFn = Box<dyn for<'a> Fn(ToFnCtx<'a>) -> BoxFuture<'a, Result<String>> + Sync + Send>;
81
82pub enum ToOption {
83 String(String),
84 Fn(ToFn),
85}
86
87#[derive(Debug)]
88pub struct CopyPattern {
89 pub from: String,
90 #[debug(skip)]
91 pub to: Option<ToOption>,
92 pub context: Option<Utf8PathBuf>,
93 pub to_type: Option<ToType>,
94 pub no_error_on_missing: bool,
95 pub info: Option<Info>,
96 pub force: bool,
97 pub priority: i32,
98 pub glob_options: CopyGlobOptions,
99 pub copy_permissions: Option<bool>,
100 #[debug(skip)]
101 pub transform_fn: Option<TransformerFn>,
102 pub cache: Option<bool>,
103}
104
105#[derive(Debug, Clone)]
106pub struct CopyGlobOptions {
107 pub case_sensitive_match: Option<bool>,
108 pub dot: Option<bool>,
109 pub ignore: Option<Vec<GlobPattern>>,
110}
111
112#[derive(Debug, Clone)]
113pub struct RunPatternResult {
114 pub source_filename: Utf8PathBuf,
115 pub absolute_filename: Utf8PathBuf,
116 pub filename: String,
117 pub source: RawSource,
118 pub info: Option<Info>,
119 pub force: bool,
120 pub priority: i32,
121 pub pattern_index: usize,
122}
123
124#[plugin]
125#[derive(Debug)]
126pub struct CopyRspackPlugin {
127 pub patterns: Vec<CopyPattern>,
128}
129
130static TEMPLATE_RE: LazyLock<Regex> =
131 LazyLock::new(|| Regex::new(r"\[\\*([\w:]+)\\*\]").expect("This never fail"));
132
133impl CopyRspackPlugin {
134 pub fn new(patterns: Vec<CopyPattern>) -> Self {
135 Self::new_inner(patterns)
136 }
137
138 fn get_content_hash(
139 source: &RawSource,
140 function: &HashFunction,
141 digest: &HashDigest,
142 salt: &HashSalt,
143 ) -> RspackHashDigest {
144 let mut hasher = RspackHash::with_salt(function, salt);
145 source.buffer().hash(&mut hasher);
146 hasher.digest(digest)
147 }
148
149 #[allow(clippy::too_many_arguments)]
150 async fn analyze_every_entry(
151 entry: Utf8PathBuf,
152 pattern: &CopyPattern,
153 context: &Utf8Path,
154 output_path: &Utf8Path,
155 from_type: FromType,
156 file_dependencies: &DashSet<PathBuf>,
157 diagnostics: Arc<Mutex<Vec<Diagnostic>>>,
158 compilation: &Compilation,
159 logger: &CompilationLogger,
160 pattern_index: usize,
161 ) -> Result<Option<RunPatternResult>> {
162 if entry.is_dir() {
164 return Ok(None);
165 }
166 if let Some(ignore) = &pattern.glob_options.ignore
167 && ignore.iter().any(|ignore| ignore.matches(entry.as_str()))
168 {
169 return Ok(None);
170 }
171
172 let from = entry;
173
174 logger.debug(format!("found '{from}'"));
175
176 let absolute_filename = if from.is_absolute() {
177 from.clone()
178 } else {
179 context.join(&from)
180 };
181
182 let to = if let Some(to) = pattern.to.as_ref() {
183 let to = match to {
184 ToOption::String(s) => s.to_owned(),
185 ToOption::Fn(r) => {
186 let result = r(ToFnCtx {
187 context,
188 absolute_filename: &absolute_filename,
189 })
190 .await;
191
192 match result {
193 Ok(to) => to,
194 Err(e) => {
195 diagnostics
196 .lock()
197 .expect("failed to obtain lock of `diagnostics`")
198 .push(Diagnostic::error(
199 "Run copy to fn error".into(),
200 e.to_string(),
201 ));
202 "".to_string()
203 }
204 }
205 }
206 };
207
208 to.clone()
209 .as_path()
210 .normalize()
211 .to_string_lossy()
212 .to_string()
213 } else {
214 "".into()
215 };
216
217 let to_type = if let Some(to_type) = pattern.to_type.as_ref() {
218 to_type.clone()
219 } else if TEMPLATE_RE.is_match(&to) {
220 ToType::Template
221 } else if Path::new(&to).extension().is_none() || to.ends_with(MAIN_SEPARATOR) {
222 ToType::Dir
223 } else {
224 ToType::File
225 };
226
227 logger.log(format!("'to' option '{to}' determined as '{to_type}'"));
228
229 let relative = pathdiff::diff_utf8_paths(&absolute_filename, context);
230 let filename = if matches!(to_type, ToType::Dir) {
231 if let Some(relative) = &relative {
232 Utf8PathBuf::from(&to).join(relative)
233 } else {
234 to.into()
235 }
236 } else {
237 to.into()
238 };
239
240 let filename = if filename.is_absolute() {
241 if let Some(filename) = pathdiff::diff_utf8_paths(filename, output_path) {
242 filename
243 } else {
244 return Ok(None);
245 }
246 } else {
247 filename
248 };
249
250 logger.log(format!(
251 "determined that '{from}' should write to '{filename}'"
252 ));
253
254 let Some(source_filename) = relative else {
255 return Ok(None);
256 };
257
258 if matches!(from_type, FromType::Dir | FromType::Glob) {
260 logger.debug(format!("added '{absolute_filename}' as a file dependency",));
261
262 file_dependencies.insert(absolute_filename.clone().into_std_path_buf());
263 }
264
265 logger.debug(format!("reading '{absolute_filename}'..."));
268 let data = compilation.input_filesystem.read(&absolute_filename).await;
271
272 let source_vec = match data {
273 Ok(data) => {
274 logger.debug(format!("read '{absolute_filename}'..."));
275
276 data
277 }
278 Err(e) => {
279 let e: Error = e.into();
280 diagnostics
281 .lock()
282 .expect("failed to obtain lock of `diagnostics`")
283 .push(e.into());
284 return Ok(None);
285 }
286 };
287
288 let mut source = RawSource::from(source_vec.clone());
289
290 if let Some(transformer) = &pattern.transform_fn {
291 logger.debug(format!("transforming content for '{absolute_filename}'..."));
292 handle_transform(
294 transformer,
295 source_vec,
296 absolute_filename.clone(),
297 &mut source,
298 diagnostics,
299 )
300 .await
301 }
302
303 let filename = if matches!(&to_type, ToType::Template) {
304 logger.log(format!(
305 "interpolating template '{filename}' for '${source_filename}'...`"
306 ));
307
308 let content_hash = Self::get_content_hash(
309 &source,
310 &compilation.options.output.hash_function,
311 &compilation.options.output.hash_digest,
312 &compilation.options.output.hash_salt,
313 );
314 let content_hash = content_hash.rendered(compilation.options.output.hash_digest_length);
315 let template_str = compilation
316 .get_asset_path(
317 &Filename::from(filename.to_string()),
318 PathData::default()
319 .filename(source_filename.as_str())
320 .content_hash(content_hash)
321 .hash_optional(compilation.get_hash()),
322 )
323 .await?;
324
325 logger.log(format!(
326 "interpolated template '{template_str}' for '{filename}'"
327 ));
328
329 template_str
330 } else {
331 filename.as_str().normalize().to_string_lossy().to_string()
332 };
333
334 Ok(Some(RunPatternResult {
335 source_filename,
336 absolute_filename,
337 filename,
338 source,
339 info: pattern.info.clone(),
340 force: pattern.force,
341 priority: pattern.priority,
342 pattern_index,
343 }))
344 }
345
346 async fn run_patter(
347 compilation: &Compilation,
348 pattern: &CopyPattern,
349 index: usize,
350 file_dependencies: &DashSet<PathBuf>,
351 context_dependencies: &DashSet<PathBuf>,
352 diagnostics: Arc<Mutex<Vec<Diagnostic>>>,
353 logger: &CompilationLogger,
354 ) -> Result<Option<Vec<Option<RunPatternResult>>>> {
355 let orig_from = &pattern.from;
356 let normalized_orig_from = Utf8PathBuf::from(orig_from);
357
358 let pattern_context = if pattern.context.is_none() {
359 Some(Cow::Borrowed(compilation.options.context.as_path()))
360 } else if let Some(ref ctx) = pattern.context
361 && !ctx.is_absolute()
362 {
363 Some(Cow::Owned(compilation.options.context.as_path().join(ctx)))
364 } else {
365 pattern.context.as_deref().map(Into::into)
366 };
367
368 logger.log(format!(
369 "starting to process a pattern from '{normalized_orig_from}' using '{pattern_context:?}' context"
370 ));
371
372 let mut context =
373 pattern_context.unwrap_or_else(|| Cow::Borrowed(compilation.options.context.as_path()));
374
375 let abs_from = if normalized_orig_from.is_absolute() {
376 normalized_orig_from
377 } else {
378 context.join(&normalized_orig_from)
379 };
380
381 logger.debug(format!("getting stats for '{abs_from}'..."));
382
383 let from_type = if let Ok(meta) = compilation.input_filesystem.metadata(&abs_from).await {
384 if meta.is_directory {
385 logger.debug(format!("determined '{abs_from}' is a directory"));
386 FromType::Dir
387 } else if meta.is_file {
388 logger.debug(format!("determined '{abs_from}' is a file"));
389 FromType::File
390 } else {
391 logger.debug(format!("determined '{abs_from}' is a unknown"));
392 FromType::Glob
393 }
394 } else {
395 logger.debug(format!("determined '{abs_from}' is a glob"));
396 FromType::Glob
397 };
398
399 let mut dot_enable = pattern.glob_options.dot;
401
402 let mut need_add_context_to_dependency = false;
407 let glob_query = match from_type {
408 FromType::Dir => {
409 logger.debug(format!("added '{abs_from}' as a context dependency"));
410 context_dependencies.insert(abs_from.clone().into_std_path_buf());
411 context = abs_from.as_path().into();
412
413 if dot_enable.is_none() {
414 dot_enable = Some(true);
415 }
416 let mut escaped = Utf8PathBuf::from(GlobPattern::escape(abs_from.as_str()));
417 escaped.push("**/*");
418
419 escaped.as_str().to_string()
420 }
421 FromType::File => {
422 logger.debug(format!("added '{abs_from}' as a file dependency"));
423 file_dependencies.insert(abs_from.clone().into_std_path_buf());
424 context = abs_from.parent().unwrap_or(Utf8Path::new("")).into();
425
426 if dot_enable.is_none() {
427 dot_enable = Some(true);
428 }
429
430 GlobPattern::escape(abs_from.as_str())
431 }
432 FromType::Glob => {
433 need_add_context_to_dependency = true;
434 let glob_query = if Path::new(orig_from).is_absolute() {
435 orig_from.into()
436 } else {
437 context.join(orig_from).as_str().to_string()
438 };
439 if glob_query.ends_with("/**") {
442 format!("{glob_query}/*")
443 } else {
444 glob_query
445 }
446 }
447 };
448
449 logger.log(format!("begin globbing '{glob_query}'..."));
450
451 let glob_entries = glob::glob_with(
452 &glob_query,
453 MatchOptions {
454 case_sensitive: pattern.glob_options.case_sensitive_match.unwrap_or(true),
455 require_literal_separator: Default::default(),
456 require_literal_leading_dot: !dot_enable.unwrap_or(false),
457 },
458 );
459
460 match glob_entries {
461 Ok(entries) => {
462 let entries: Vec<_> = entries
463 .filter_map(|entry| {
464 let entry = entry.ok()?.assert_utf8();
465
466 let filters = pattern.glob_options.ignore.as_ref();
467
468 if let Some(filters) = filters {
469 let exist = filters.iter().all(|filter| !filter.matches(entry.as_str()));
471 exist.then_some(entry)
472 } else {
473 Some(entry)
474 }
475 })
476 .collect();
477
478 if need_add_context_to_dependency
479 && let Some(common_dir) = get_closest_common_parent_dir(
480 &entries.iter().map(|it| it.as_path()).collect::<Vec<_>>(),
481 )
482 {
483 context_dependencies.insert(common_dir.into_std_path_buf());
484 }
485
486 if entries.is_empty() {
487 if pattern.no_error_on_missing {
488 logger.log(
489 "finished to process a pattern from '${normalizedOriginalFrom}' using '${pattern.context}' context to '${pattern.to}'"
490 );
491 return Ok(None);
492 }
493
494 diagnostics
495 .lock()
496 .expect("failed to obtain lock of `diagnostics`")
497 .push(Diagnostic::error(
498 "CopyRspackPlugin Error".into(),
499 format!("unable to locate '{glob_query}' glob"),
500 ));
501 }
502
503 let output_path = &compilation.options.output.path;
504
505 let copied_result = join_all(entries.into_iter().map(|entry| async {
506 Self::analyze_every_entry(
507 entry,
508 pattern,
509 &context,
510 output_path,
511 from_type,
512 file_dependencies,
513 diagnostics.clone(),
514 compilation,
515 logger,
516 index,
517 )
518 .await
519 }))
520 .await
521 .into_iter()
522 .collect::<Result<Vec<_>>>()?;
523
524 if copied_result.is_empty() {
525 if pattern.no_error_on_missing {
526 return Ok(None);
527 }
528
529 diagnostics
531 .lock()
532 .expect("failed to obtain lock of `diagnostics`")
533 .push(Diagnostic::error(
534 "CopyRspackPlugin Error".into(),
535 format!("unable to locate '{glob_query}' glob"),
536 ));
537 return Ok(None);
538 }
539
540 Ok(Some(copied_result))
541 }
542 Err(e) => {
543 if pattern.no_error_on_missing {
544 let to = if let Some(to) = &pattern.to {
545 match to {
546 ToOption::String(s) => s,
547 ToOption::Fn(_) => "",
548 }
549 } else {
550 ""
551 };
552
553 logger.log(format!(
554 "finished to process a pattern from '{}' using '{}' context to '{:?}'",
555 Utf8PathBuf::from(orig_from),
556 context,
557 to,
558 ));
559
560 return Ok(None);
561 }
562
563 diagnostics
564 .lock()
565 .expect("failed to obtain lock of `diagnostics`")
566 .push(Diagnostic::error("Glob Error".into(), e.msg.to_string()));
567
568 Ok(None)
569 }
570 }
571 }
572}
573
574#[plugin_hook(CompilationProcessAssets for CopyRspackPlugin, stage = Compilation::PROCESS_ASSETS_STAGE_ADDITIONAL)]
575async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> {
576 let logger = compilation.get_logger("rspack.CopyRspackPlugin");
577 let start = logger.time("run pattern");
578 let file_dependencies = DashSet::default();
579 let context_dependencies = DashSet::default();
580 let diagnostics = Arc::new(Mutex::new(Vec::new()));
581
582 let mut copied_result: Vec<(i32, RunPatternResult)> =
583 join_all(self.patterns.iter().enumerate().map(|(index, pattern)| {
584 CopyRspackPlugin::run_patter(
585 compilation,
586 pattern,
587 index,
588 &file_dependencies,
589 &context_dependencies,
590 diagnostics.clone(),
591 &logger,
592 )
593 }))
594 .await
595 .into_iter()
596 .collect::<Result<Vec<_>>>()?
597 .into_iter()
598 .flatten()
599 .flat_map(|item| {
600 item
601 .into_iter()
602 .flatten()
603 .map(|item| (item.priority, item))
604 .collect::<Vec<_>>()
605 })
606 .collect();
607 logger.time_end(start);
608
609 let start = logger.time("emit assets");
610 compilation
611 .file_dependencies
612 .extend(file_dependencies.into_iter().map(Into::into));
613 compilation
614 .context_dependencies
615 .extend(context_dependencies.into_iter().map(Into::into));
616 compilation.extend_diagnostics(std::mem::take(
617 diagnostics
618 .lock()
619 .expect("failed to obtain lock of `diagnostics`")
620 .deref_mut(),
621 ));
622
623 copied_result.sort_unstable_by(|a, b| a.0.cmp(&b.0));
624
625 let mut permission_copies = Vec::new();
627
628 copied_result.into_iter().for_each(|(_priority, result)| {
629 let source_path = result.absolute_filename.clone();
630 let dest_path = compilation.options.output.path.join(&result.filename);
631
632 if let Some(exist_asset) = compilation.assets_mut().get_mut(&result.filename) {
633 if !result.force {
634 return;
635 }
636 exist_asset.set_source(Some(Arc::new(result.source)));
637 if let Some(info) = result.info {
638 set_info(&mut exist_asset.info, info);
639 }
640 exist_asset.info.source_filename = Some(result.source_filename.to_string());
641 exist_asset.info.copied = Some(true);
642 } else {
643 let mut asset_info = AssetInfo {
644 source_filename: Some(result.source_filename.to_string()),
645 copied: Some(true),
646 ..Default::default()
647 };
648
649 if let Some(info) = result.info {
650 set_info(&mut asset_info, info);
651 }
652
653 compilation.emit_asset(
654 result.filename,
655 CompilationAsset::new(Some(Arc::new(result.source)), asset_info),
656 );
657 }
658
659 permission_copies.push((result.pattern_index, source_path, dest_path));
661 });
662 logger.time_end(start);
663
664 for (pattern_index, source_path, dest_path) in permission_copies.iter() {
666 if let Some(pattern) = self.patterns.get(*pattern_index)
667 && pattern.copy_permissions.unwrap_or(false)
668 && let Ok(Some(permissions)) = compilation.input_filesystem.permissions(source_path).await
669 {
670 if let Some(parent) = dest_path.parent() {
672 compilation
673 .output_filesystem
674 .create_dir_all(parent)
675 .await
676 .unwrap_or_else(|e| {
677 logger.warn(format!("Failed to create directory {parent:?}: {e}"));
678 });
679 }
680
681 if !dest_path.exists() {
683 logger.warn(format!(
684 "Destination file {dest_path:?} does not exist, cannot copy permissions"
685 ));
686 continue;
687 }
688
689 if let Err(e) = compilation
690 .output_filesystem
691 .set_permissions(dest_path, permissions)
692 .await
693 {
694 logger.warn(format!(
695 "Failed to copy permissions from {source_path:?} to {dest_path:?}: {e}"
696 ));
697 } else {
698 logger.log(format!(
699 "Successfully copied permissions from {source_path:?} to {dest_path:?}"
700 ));
701 }
702 }
703 }
704
705 Ok(())
706}
707
708impl Plugin for CopyRspackPlugin {
709 fn apply(&self, ctx: &mut rspack_core::ApplyContext<'_>) -> Result<()> {
710 ctx
711 .compilation_hooks
712 .process_assets
713 .tap(process_assets::new(self));
714 Ok(())
715 }
716}
717
718fn get_closest_common_parent_dir(paths: &[&Utf8Path]) -> Option<Utf8PathBuf> {
719 if paths.is_empty() {
721 return None;
722 }
723
724 let mut parent_dir: Utf8PathBuf = paths[0].parent()?.to_path_buf();
726
727 for path in paths.iter().skip(1) {
729 while !path.starts_with(&parent_dir) {
731 parent_dir = parent_dir.parent()?.into();
732 }
733 }
734
735 Some(parent_dir)
736}
737
738fn set_info(target: &mut AssetInfo, info: Info) {
739 if let Some(minimized) = info.minimized {
740 target.minimized.replace(minimized);
741 }
742
743 if let Some(immutable) = info.immutable {
744 target.immutable.replace(immutable);
745 }
746
747 if let Some(chunk_hash) = info.chunk_hash {
748 target.chunk_hash = rustc_hash::FxHashSet::from_iter(chunk_hash);
749 }
750
751 if let Some(content_hash) = info.content_hash {
752 target.content_hash = rustc_hash::FxHashSet::from_iter(content_hash);
753 }
754
755 if let Some(development) = info.development {
756 target.development.replace(development);
757 }
758
759 if let Some(hot_module_replacement) = info.hot_module_replacement {
760 target
761 .hot_module_replacement
762 .replace(hot_module_replacement);
763 }
764
765 if let Some(related) = info.related {
766 target.related = AssetInfoRelated {
767 source_map: related.source_map,
768 };
769 }
770
771 if let Some(version) = info.version {
772 target.version = version;
773 }
774}
775
776async fn handle_transform(
777 transformer: &TransformerFn,
778 source_vec: Vec<u8>,
779 absolute_filename: Utf8PathBuf,
780 source: &mut RawSource,
781 diagnostics: Arc<Mutex<Vec<Diagnostic>>>,
782) {
783 match transformer(source_vec, absolute_filename.as_str()).await {
784 Ok(code) => {
785 *source = code;
786 }
787 Err(e) => {
788 diagnostics
789 .lock()
790 .expect("failed to obtain lock of `diagnostics`")
791 .push(Diagnostic::error(
792 "Run copy transform fn error".into(),
793 e.to_string(),
794 ));
795 }
796 }
797}
798
799#[test]
802fn ensure_info_fields() {
803 let info = AssetInfo::default();
804 std::hint::black_box(info);
805}