Skip to main content

rolldown_plugin_copy_module/
lib.rs

1use std::{borrow::Cow, path::Path, sync::Arc};
2
3use arcstr::ArcStr;
4use memchr::memmem;
5use rolldown_common::{EmittedAsset, ModuleType, ResolvedExternal, StrOrBytes};
6use rolldown_plugin::{
7  HookRenderChunkArgs, HookRenderChunkOutput, HookRenderChunkReturn, HookResolveIdArgs,
8  HookResolveIdOutput, HookResolveIdReturn, HookTransformOutputMap, HookUsage, Plugin,
9  PluginContext, PluginHookMeta, PluginOrder,
10};
11use rolldown_utils::url::clean_url;
12use rustc_hash::FxHashSet;
13use string_wizard::{MagicString, SourceMapOptions};
14use sugar_path::SugarPath;
15
16const PREFIX: &str = "__ROLLDOWN_COPY_MODULE__#";
17
18#[derive(Debug)]
19pub struct CopyModulePlugin {
20  copy_extensions: FxHashSet<String>,
21}
22
23impl CopyModulePlugin {
24  pub fn new(module_types: &rustc_hash::FxHashMap<Cow<'static, str>, ModuleType>) -> Self {
25    let mut copy_extensions = FxHashSet::default();
26    for (ext, module_type) in module_types {
27      if matches!(module_type, ModuleType::Copy) {
28        let ext = ext.strip_prefix('.').unwrap_or(ext);
29        copy_extensions.insert(ext.to_string());
30      }
31    }
32    Self { copy_extensions }
33  }
34
35  pub fn is_active(&self) -> bool {
36    !self.copy_extensions.is_empty()
37  }
38}
39
40impl Plugin for CopyModulePlugin {
41  fn name(&self) -> Cow<'static, str> {
42    Cow::Borrowed("builtin:copy-module")
43  }
44
45  fn register_hook_usage(&self) -> HookUsage {
46    HookUsage::ResolveId | HookUsage::RenderChunk
47  }
48
49  fn resolve_id_meta(&self) -> Option<PluginHookMeta> {
50    // Run before users' resolve_id hooks to ensure:
51    // - For matched modules, to handle it correctly without users' interference.
52    Some(PluginHookMeta { order: Some(PluginOrder::Pre) })
53  }
54
55  async fn resolve_id(
56    &self,
57    ctx: &PluginContext,
58    args: &HookResolveIdArgs<'_>,
59  ) -> HookResolveIdReturn {
60    if self.copy_extensions.is_empty() {
61      return Ok(None);
62    }
63
64    // Don't re-resolve our own prefixed IDs
65    if args.specifier.starts_with(PREFIX) {
66      return Ok(None);
67    }
68
69    // Resolve the specifier to get the absolute path
70    let resolved = ctx.resolve(args.specifier, args.importer, None).await?;
71
72    let resolved_id = match resolved {
73      Ok(id) => id,
74      Err(_) => return Ok(None),
75    };
76
77    // Honor external resolutions from other plugins. `rolldown-plugin-dts`,
78    // for instance, marks TypeScript ambient-module glob specifiers like
79    // `typeof import("*.jpg")` as `{ id, external: true }` so they pass
80    // through untouched. Without this check the copy plugin would ignore the
81    // `external` flag and try to read `*.jpg` from disk, emitting a
82    // confusing `Failed to read copy module *.jpg` error.
83    if resolved_id.external.is_external() {
84      return Ok(None);
85    }
86
87    // Strip query/fragment (e.g. `file.txt?url`) before extension check and file read
88    let clean_id = clean_url(resolved_id.id.as_str());
89    let resolved_path = Path::new(clean_id);
90
91    // Check if the resolved path has a copy extension
92    let ext = match resolved_path.extension().and_then(|e| e.to_str()) {
93      Some(e) => e,
94      None => return Ok(None),
95    };
96
97    if !self.copy_extensions.contains(ext) {
98      return Ok(None);
99    }
100
101    // Read the file bytes asynchronously to avoid blocking the tokio worker thread
102    let bytes = tokio::fs::read(clean_id)
103      .await
104      .map_err(|e| anyhow::anyhow!("Failed to read copy module {}: {e}", resolved_id.id))?;
105
106    // Derive a name from the file path
107    let file_name =
108      resolved_path.file_name().and_then(|n| n.to_str()).unwrap_or("asset").to_string();
109
110    // Use relative path for original_file_name to avoid leaking absolute paths into output
111    let original_file_name =
112      resolved_path.strip_prefix(ctx.cwd()).unwrap_or(resolved_path).to_string_lossy().into_owned();
113
114    // Emit the file as an asset
115    let reference_id = ctx
116      .emit_file_async(EmittedAsset {
117        name: Some(file_name),
118        original_file_name: Some(original_file_name),
119        source: StrOrBytes::Bytes(bytes),
120        ..Default::default()
121      })
122      .await?;
123
124    // Add watch file for watch mode (use the clean absolute path)
125    ctx.add_watch_file(clean_id);
126
127    // Return a prefixed external ID — the prefix will be rewritten in render_chunk
128    let placeholder_id: ArcStr = format!("{PREFIX}{reference_id}").into();
129
130    Ok(Some(HookResolveIdOutput {
131      id: placeholder_id,
132      external: Some(ResolvedExternal::Bool(true)),
133      ..Default::default()
134    }))
135  }
136
137  fn render_chunk_meta(&self) -> Option<PluginHookMeta> {
138    // Run before users' render_chunk hooks to ensure:
139    // - The placeholder IDs are replaced before any user hooks, so they won't see the placeholder IDs and won't interfere with our processing.
140    Some(PluginHookMeta { order: Some(PluginOrder::Pre) })
141  }
142
143  async fn render_chunk(
144    &self,
145    ctx: &PluginContext,
146    args: &HookRenderChunkArgs<'_>,
147  ) -> HookRenderChunkReturn {
148    // Quick bail: if the code doesn't contain our prefix, nothing to do
149    if !args.code.contains(PREFIX) {
150      return Ok(None);
151    }
152
153    let chunk_filename = &args.chunk.filename;
154    let code = args.code.as_str();
155    let mut magic_string = MagicString::new(code);
156    let mut changed = false;
157
158    // Use memchr for SIMD-accelerated substring search
159    let finder = memmem::find_iter(code.as_bytes(), PREFIX.as_bytes());
160
161    for abs_pos in finder {
162      let after_prefix = abs_pos + PREFIX.len();
163
164      // Extract ref_id: scan until we hit a quote (", ') or end of string
165      let rest = &code[after_prefix..];
166      let ref_end = rest.find(['"', '\'']).unwrap_or(rest.len());
167      let ref_id = &rest[..ref_end];
168
169      if ref_id.is_empty() {
170        continue;
171      }
172
173      // Resolve the asset filename
174      let asset_filename = match ctx.get_file_name(ref_id) {
175        Ok(name) => name,
176        Err(_) => continue,
177      };
178
179      // Compute relative path from chunk to asset
180      let relative = compute_relative_path(chunk_filename, &asset_filename);
181
182      let end = after_prefix + ref_end;
183      #[expect(clippy::cast_possible_truncation)]
184      if magic_string.update(abs_pos as u32, end as u32, relative).is_ok() {
185        changed = true;
186      }
187    }
188
189    if changed {
190      Ok(Some(HookRenderChunkOutput {
191        code: magic_string.to_string(),
192        map: HookTransformOutputMap::from_if_enabled(args.options.sourcemap.is_some(), || {
193          magic_string.source_map(SourceMapOptions {
194            hires: string_wizard::Hires::Boundary,
195            include_content: false,
196            source: Arc::from(args.chunk.filename.as_str()),
197          })
198        }),
199      }))
200    } else {
201      Ok(None)
202    }
203  }
204}
205
206/// Compute the relative path from a chunk file to an asset file,
207/// ensuring it starts with "./" for relative paths.
208fn compute_relative_path(chunk_filename: &str, asset_filename: &str) -> String {
209  let chunk_dir = Path::new(chunk_filename).parent().unwrap_or(Path::new(""));
210
211  let relative = Path::new(asset_filename).relative(chunk_dir);
212  let relative_str = relative.to_slash_lossy();
213
214  if relative_str.starts_with("..") {
215    relative_str.into_owned()
216  } else if relative_str.is_empty() {
217    ".".to_string()
218  } else {
219    format!("./{relative_str}")
220  }
221}