Skip to main content

rspack_core/options/
filename.rs

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/// Filename placeholders or function
44///
45/// The function type is generic. The default function type `Arc<dyn FilenameFn>` is thread-safe,
46/// implements `Hash` and `Eq`, and its error type is `rspack_error::Error`.
47///
48/// Other possible function types are `NoFilenameFn` and `LocalJsFilenameFn`
49#[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/// The minimum requirement for a filename fn.
119#[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
128/// The default filename fn trait.
129pub 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  // file-level
204  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        // "XXXX" used for updateHash, so we don't need it here
211        .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              // "" -> "", "folder" -> "folder/"
258              .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  // compilation-level
268  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  // shared by chunk-level and module-level
288  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      // set version as content hash
298      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  // chunk-level
317  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  // other things
340  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}