rspack_plugin_copy/
lib.rs

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    // Exclude directories
163    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 this came from a glob or dir, add it to the file dependencies
259    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    // TODO cache
266
267    logger.debug(format!("reading '{absolute_filename}'..."));
268    // TODO inputFileSystem
269
270    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      // TODO: support cache in the future.
293      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    // Enable copy files starts with dot
400    let mut dot_enable = pattern.glob_options.dot;
401
402    /*
403     * If input is a glob query like `/a/b/**/*.js`, we need to add common directory
404     * to context_dependencies
405     */
406    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        // A glob pattern ending with /** should match all files within a directory, not just the directory itself.
440        // Since the standard glob only matches directories, we append /* to align with webpack's behavior.
441        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              // If filters length is 0, exist is true by default
470              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          // TODO err handler
530          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  // Keep track of source to destination file mappings for permission copying
626  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    // Store the paths for permission copying along with the pattern index
660    permission_copies.push((result.pattern_index, source_path, dest_path));
661  });
662  logger.time_end(start);
663
664  // Handle permission copying after all assets are emitted
665  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      // Make sure the output directory exists
671      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      // Make sure the file exists before trying to set permissions
682      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 there are no matching files, return `None`.
720  if paths.is_empty() {
721    return None;
722  }
723
724  // Get the first file path and use it as the initial value for the common parent directory.
725  let mut parent_dir: Utf8PathBuf = paths[0].parent()?.to_path_buf();
726
727  // Iterate over the remaining file paths, updating the common parent directory as necessary.
728  for path in paths.iter().skip(1) {
729    // Find the common parent directory between the current file path and the previous common parent directory.
730    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// If this test fails, you should modify `set_info` function, according to your changes about AssetInfo
800// Make sure every field of AssetInfo is considered
801#[test]
802fn ensure_info_fields() {
803  let info = AssetInfo::default();
804  std::hint::black_box(info);
805}