rolldown_plugin_copy_module/
lib.rs1use 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 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 if args.specifier.starts_with(PREFIX) {
66 return Ok(None);
67 }
68
69 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 if resolved_id.external.is_external() {
84 return Ok(None);
85 }
86
87 let clean_id = clean_url(resolved_id.id.as_str());
89 let resolved_path = Path::new(clean_id);
90
91 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 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 let file_name =
108 resolved_path.file_name().and_then(|n| n.to_str()).unwrap_or("asset").to_string();
109
110 let original_file_name =
112 resolved_path.strip_prefix(ctx.cwd()).unwrap_or(resolved_path).to_string_lossy().into_owned();
113
114 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 ctx.add_watch_file(clean_id);
126
127 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 Some(PluginHookMeta { order: Some(PluginOrder::Pre) })
141 }
142
143 async fn render_chunk(
144 &self,
145 ctx: &PluginContext,
146 args: &HookRenderChunkArgs<'_>,
147 ) -> HookRenderChunkReturn {
148 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 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 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 let asset_filename = match ctx.get_file_name(ref_id) {
175 Ok(name) => name,
176 Err(_) => continue,
177 };
178
179 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
206fn 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}