1use std::{
2 borrow::Cow,
3 fmt::Debug,
4 hash::{Hash, Hasher},
5 ops::Deref,
6 ptr,
7 sync::Arc,
8};
9
10use rspack_cacheable::{
11 cacheable,
12 with::{AsPreset, Unsupported},
13};
14use rspack_error::ToStringResultToRspackResultExt;
15use rspack_paths::Utf8PathBuf;
16use rspack_util::{MergeFrom, atom::Atom, base64, ext::CowExt};
17
18use crate::{AssetInfo, PathData, ReplaceAllPlaceholder, ResourceParsedData, parse_resource};
19
20static FILE_PLACEHOLDER: &str = "[file]";
21static BASE_PLACEHOLDER: &str = "[base]";
22static NAME_PLACEHOLDER: &str = "[name]";
23static PATH_PLACEHOLDER: &str = "[path]";
24static EXT_PLACEHOLDER: &str = "[ext]";
25static QUERY_PLACEHOLDER: &str = "[query]";
26static FRAGMENT_PLACEHOLDER: &str = "[fragment]";
27static ID_PLACEHOLDER: &str = "[id]";
28static RUNTIME_PLACEHOLDER: &str = "[runtime]";
29static URL_PLACEHOLDER: &str = "[url]";
30
31pub static HASH_PLACEHOLDER: &str = "[hash]";
32pub static FULL_HASH_PLACEHOLDER: &str = "[fullhash]";
33pub static CHUNK_HASH_PLACEHOLDER: &str = "[chunkhash]";
34pub static CONTENT_HASH_PLACEHOLDER: &str = "[contenthash]";
35
36#[cacheable]
37#[derive(PartialEq, Debug, Hash, Eq, Clone, PartialOrd, Ord)]
38enum FilenameKind {
39 Template(#[cacheable(with=AsPreset)] Atom),
40 Fn(#[cacheable(with=Unsupported)] Arc<dyn FilenameFn>),
41}
42
43#[cacheable]
50#[derive(PartialEq, Debug, Hash, Eq, Clone, PartialOrd, Ord)]
51pub struct Filename(FilenameKind);
52
53impl Filename {
54 pub fn as_str(&self) -> &str {
55 self.template().unwrap_or("")
56 }
57 pub fn has_hash_placeholder(&self) -> bool {
58 match &self.0 {
59 FilenameKind::Template(atom) => has_hash_placeholder(atom.as_str()),
60 FilenameKind::Fn(_) => true,
61 }
62 }
63 pub fn has_content_hash_placeholder(&self) -> bool {
64 match &self.0 {
65 FilenameKind::Template(atom) => has_content_hash_placeholder(atom.as_str()),
66 FilenameKind::Fn(_) => true,
67 }
68 }
69 pub fn template(&self) -> Option<&str> {
70 match &self.0 {
71 FilenameKind::Template(template) => Some(template.as_str()),
72 _ => None,
73 }
74 }
75
76 pub async fn render(
77 &self,
78 options: PathData<'_>,
79 asset_info: Option<&mut AssetInfo>,
80 ) -> rspack_error::Result<String> {
81 let template = match &self.0 {
82 FilenameKind::Template(template) => Cow::Borrowed(template.as_str()),
83 FilenameKind::Fn(filename_fn) => {
84 Cow::Owned(filename_fn.call(&options, asset_info.as_deref()).await?)
85 }
86 };
87 Ok(render_template(template, options, asset_info))
88 }
89}
90
91impl MergeFrom for Filename {
92 fn merge_from(self, other: &Self) -> Self {
93 other.clone()
94 }
95}
96
97impl From<String> for Filename {
98 fn from(value: String) -> Self {
99 Self(FilenameKind::Template(Atom::from(value)))
100 }
101}
102impl From<&Utf8PathBuf> for Filename {
103 fn from(value: &Utf8PathBuf) -> Self {
104 Self(FilenameKind::Template(Atom::from(value.as_str())))
105 }
106}
107impl From<&str> for Filename {
108 fn from(value: &str) -> Self {
109 Self(FilenameKind::Template(Atom::from(value)))
110 }
111}
112impl From<Arc<dyn FilenameFn>> for Filename {
113 fn from(value: Arc<dyn FilenameFn>) -> Self {
114 Self(FilenameKind::Fn(value))
115 }
116}
117
118#[async_trait::async_trait]
120pub trait LocalFilenameFn {
121 async fn call(
122 &self,
123 path_data: &PathData,
124 asset_info: Option<&AssetInfo>,
125 ) -> rspack_error::Result<String>;
126}
127
128pub trait FilenameFn: LocalFilenameFn + Debug + Send + Sync {}
130
131impl Hash for dyn FilenameFn + '_ {
132 fn hash<H: Hasher>(&self, _: &mut H) {}
133}
134impl PartialEq for dyn FilenameFn + '_ {
135 fn eq(&self, other: &Self) -> bool {
136 ptr::eq(self, other)
137 }
138}
139impl Eq for dyn FilenameFn + '_ {}
140
141impl PartialOrd for dyn FilenameFn + '_ {
142 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
143 Some(self.cmp(other))
144 }
145}
146impl Ord for dyn FilenameFn + '_ {
147 fn cmp(&self, _: &Self) -> std::cmp::Ordering {
148 std::cmp::Ordering::Equal
149 }
150}
151
152#[async_trait::async_trait]
153impl LocalFilenameFn for Arc<dyn FilenameFn> {
154 async fn call(
155 &self,
156 path_data: &PathData,
157 asset_info: Option<&AssetInfo>,
158 ) -> rspack_error::Result<String> {
159 self
160 .deref()
161 .call(path_data, asset_info)
162 .await
163 .to_rspack_result_with_message(|e| {
164 format!("Failed to render filename function: {e}. Did you return the correct filename?")
165 })
166 }
167}
168
169#[inline]
170fn hash_len(hash: &str, len: Option<usize>) -> usize {
171 let hash_len = hash.len();
172 len.unwrap_or(hash_len).min(hash_len)
173}
174
175pub fn has_hash_placeholder(template: &str) -> bool {
176 for key in [HASH_PLACEHOLDER, FULL_HASH_PLACEHOLDER] {
177 let offset = key.len() - 1;
178 if let Some(start) = template.find(&key[..offset])
179 && template[start + offset..].find(']').is_some()
180 {
181 return true;
182 }
183 }
184 false
185}
186
187pub fn has_content_hash_placeholder(template: &str) -> bool {
188 let offset = CONTENT_HASH_PLACEHOLDER.len() - 1;
189 if let Some(start) = template.find(&CONTENT_HASH_PLACEHOLDER[..offset])
190 && template[start + offset..].find(']').is_some()
191 {
192 return true;
193 }
194 false
195}
196
197fn render_template(
198 template: Cow<str>,
199 options: PathData,
200 mut asset_info: Option<&mut AssetInfo>,
201) -> String {
202 let mut t = template;
203 if let Some(filename) = options.filename {
205 if let Ok(caps) = data_uri(filename) {
206 let ext = mime_guess::get_mime_extensions_str(caps).map(|exts| exts[0]);
207
208 let replacer = options
209 .content_hash
210 .filter(|hash| !hash.contains('X'))
212 .unwrap_or("");
213
214 t = t
215 .map(|t| t.replace_all(FILE_PLACEHOLDER, ""))
216 .map(|t| t.replace_all(QUERY_PLACEHOLDER, ""))
217 .map(|t| t.replace_all(FRAGMENT_PLACEHOLDER, ""))
218 .map(|t| t.replace_all(PATH_PLACEHOLDER, ""))
219 .map(|t| t.replace_all(BASE_PLACEHOLDER, replacer))
220 .map(|t| t.replace_all(NAME_PLACEHOLDER, replacer))
221 .map(|t| {
222 t.replace_all(
223 EXT_PLACEHOLDER,
224 &ext.map(|ext| format!(".{ext}")).unwrap_or_default(),
225 )
226 });
227 } else if let Some(ResourceParsedData {
228 path: file,
229 query,
230 fragment,
231 }) = parse_resource(filename)
232 {
233 t = t
234 .map(|t| t.replace_all(FILE_PLACEHOLDER, file.as_str()))
235 .map(|t| {
236 t.replace_all(
237 EXT_PLACEHOLDER,
238 &file
239 .extension()
240 .map(|p| format!(".{p}"))
241 .unwrap_or_default(),
242 )
243 });
244
245 if let Some(base) = file.file_name() {
246 t = t.map(|t| t.replace_all(BASE_PLACEHOLDER, base));
247 }
248 if let Some(name) = file.file_stem() {
249 t = t.map(|t| t.replace_all(NAME_PLACEHOLDER, name));
250 }
251 t = t
252 .map(|t| {
253 t.replace_all(
254 PATH_PLACEHOLDER,
255 &file
256 .parent()
257 .filter(|p| !p.as_str().is_empty())
259 .map(|p| p.as_str().to_owned() + "/")
260 .unwrap_or_default(),
261 )
262 })
263 .map(|t| t.replace_all(QUERY_PLACEHOLDER, &query.unwrap_or_default()))
264 .map(|t| t.replace_all(FRAGMENT_PLACEHOLDER, &fragment.unwrap_or_default()));
265 }
266 }
267 if let Some(hash) = options.hash {
269 for key in [HASH_PLACEHOLDER, FULL_HASH_PLACEHOLDER] {
270 t = t.map(|t| {
271 t.replace_all_with_len(key, |len, need_base64| {
272 let content: Cow<str> = if need_base64 {
273 base64::encode_to_string(hash).into()
274 } else {
275 hash.into()
276 };
277 let content = content.map(|s| s[..hash_len(s, len)].into());
278 if let Some(asset_info) = asset_info.as_mut() {
279 asset_info.set_immutable(Some(true));
280 asset_info.set_full_hash(content.to_string());
281 }
282 content
283 })
284 });
285 }
286 }
287 if let Some(id) = options.id {
289 t = t.map(|t| t.replace_all(ID_PLACEHOLDER, id));
290 } else if let Some(chunk_id) = options.chunk_id {
291 t = t.map(|t| t.replace_all(ID_PLACEHOLDER, chunk_id));
292 } else if let Some(module_id) = options.module_id {
293 t = t.map(|t| t.replace_all(ID_PLACEHOLDER, module_id));
294 }
295 if let Some(content_hash) = options.content_hash {
296 if let Some(asset_info) = asset_info.as_mut() {
297 asset_info.version = content_hash.to_string();
299 }
300 t = t.map(|t| {
301 t.replace_all_with_len(CONTENT_HASH_PLACEHOLDER, |len, need_base64| {
302 let content: Cow<str> = if need_base64 {
303 base64::encode_to_string(content_hash).into()
304 } else {
305 content_hash.into()
306 };
307 let content = content.map(|s| s[..hash_len(s, len)].into());
308 if let Some(asset_info) = asset_info.as_mut() {
309 asset_info.set_immutable(Some(true));
310 asset_info.set_content_hash(content.to_string());
311 }
312 content
313 })
314 });
315 }
316 if let Some(name) = options.chunk_name {
318 t = t.map(|t| t.replace_all(NAME_PLACEHOLDER, name));
319 } else if let Some(id) = options.chunk_id {
320 t = t.map(|t| t.replace_all(NAME_PLACEHOLDER, id));
321 }
322 if let Some(hash) = options.chunk_hash {
323 t = t.map(|t| {
324 t.replace_all_with_len(CHUNK_HASH_PLACEHOLDER, |len, need_base64| {
325 let content: Cow<str> = if need_base64 {
326 base64::encode_to_string(hash).into()
327 } else {
328 hash.into()
329 };
330 let content = content.map(|s| s[..hash_len(s, len)].into());
331 if let Some(asset_info) = asset_info.as_mut() {
332 asset_info.set_immutable(Some(true));
333 asset_info.set_chunk_hash(content.to_string());
334 }
335 content
336 })
337 });
338 }
339 t = t.map(|t| t.replace_all(RUNTIME_PLACEHOLDER, options.runtime.unwrap_or("_")));
341 if let Some(url) = options.url {
342 t = t.map(|t| t.replace_all(URL_PLACEHOLDER, url));
343 }
344 t.into_owned()
345}
346
347fn data_uri(mut input: &str) -> winnow::ModalResult<&str> {
348 use winnow::{combinator::preceded, prelude::*, token::take_till};
349
350 preceded("data:", take_till(1.., (';', ','))).parse_next(&mut input)
351}
352
353#[test]
354fn test_data_uri() {
355 assert_eq!(data_uri("data:good").ok(), Some("good"));
356 assert_eq!(data_uri("data:g;ood").ok(), Some("g"));
357 assert_eq!(data_uri("data:;ood").ok(), None);
358}