1use std::path::Path;
44
45use fallow_types::discover::FileId;
46
47use super::{ResolveResult, extract_package_name, is_bare_specifier};
48
49const LOADER_OVERRIDE_PREFIXES: &[&str] = &["-!", "!!", "!"];
51
52const ASSET_LOADERS: &[&str] = &[
59 "arraybuffer-loader",
61 "arraybuffer",
62 "base64-inline-loader",
64 "binary-loader",
66 "binary",
67 "buffer-loader",
69 "buffer",
70 "file-loader",
72 "file",
73 "raw-loader",
75 "raw",
76 "svg-inline-loader",
78 "svg-url-loader",
80 "text-loader",
82 "text",
83 "url-loader",
85 "url",
86];
87
88const THREAD_LOADERS: &[&str] = &[
96 "comlink-loader",
98 "service-worker-loader",
100 "serviceworker-loader",
102 "serviceworker",
103 "shared-worker-loader",
105 "shared-worker",
106 "sharedworker-loader",
108 "sharedworker",
109 "worker-loader",
111 "worker",
112 "worker-plugin",
114 "workerize-loader",
116 "worklet-loader",
118];
119
120#[derive(Debug, PartialEq, Eq)]
122pub struct InlineLoaderRequest<'a> {
123 loaders: Vec<&'a str>,
125 resource: &'a str,
127 has_prefix: bool,
129}
130
131impl<'a> InlineLoaderRequest<'a> {
132 #[must_use]
138 pub fn parse(specifier: &'a str) -> Option<Self> {
139 if !specifier.contains('!') || specifier.contains("://") || specifier.starts_with("data:") {
140 return None;
141 }
142 let stripped = LOADER_OVERRIDE_PREFIXES
143 .iter()
144 .find_map(|prefix| specifier.strip_prefix(prefix));
145 let has_prefix = stripped.is_some();
146 let request = stripped.unwrap_or(specifier);
147 let (loader_chain, resource) = request.rsplit_once('!').unwrap_or(("", request));
148 if resource.is_empty() {
149 return None;
150 }
151 let loaders: Vec<&str> = loader_chain
152 .split('!')
153 .map(|segment| segment.split_once('?').map_or(segment, |(name, _)| name))
154 .filter(|name| !name.is_empty())
155 .collect();
156 if !has_prefix && !is_path(resource) && loaders.iter().all(|loader| is_path(loader)) {
157 return None;
158 }
159 Some(Self {
160 loaders,
161 resource,
162 has_prefix,
163 })
164 }
165
166 #[must_use]
178 pub fn resolved<'p>(
179 specifier: &'a str,
180 target: impl FnOnce() -> Option<&'p Path>,
181 ) -> Option<Self> {
182 let request = Self::parse(specifier)?;
183 if request.has_prefix {
184 return Some(request);
185 }
186 let resolved_as_plain_path = target().is_some_and(|path| {
187 specifier
188 .split('/')
189 .find(|segment| segment.contains('!'))
190 .is_some_and(|segment| path.to_string_lossy().contains(segment))
191 });
192 (!resolved_as_plain_path).then_some(request)
193 }
194
195 #[must_use]
202 pub fn resolved_to<'p>(
203 specifier: &'a str,
204 target: &'p ResolveResult,
205 file_path: impl FnOnce(FileId) -> Option<&'p Path>,
206 ) -> Option<Self> {
207 if target.is_bare_package() {
208 let request = Self::parse(specifier)?;
209 let plain = target
210 .package_usage_name()
211 .is_some_and(|package| request.names_package_file(specifier, package));
212 return (!plain).then_some(request);
213 }
214 Self::resolved(specifier, || match target {
215 ResolveResult::ExternalFile(path) => Some(path.as_path()),
216 _ => target.internal_file_id().and_then(file_path),
217 })
218 }
219
220 #[must_use]
231 pub fn names_package_file(&self, specifier: &str, package: &str) -> bool {
232 let names =
233 |request: &str| is_bare_specifier(request) && extract_package_name(request) == package;
234 !self.has_prefix && names(specifier) && !names(self.resource)
235 }
236
237 #[must_use]
240 pub fn loaders(&self) -> &[&'a str] {
241 &self.loaders
242 }
243
244 #[must_use]
246 pub const fn resource(&self) -> &'a str {
247 self.resource
248 }
249
250 #[must_use]
252 pub const fn has_prefix(&self) -> bool {
253 self.has_prefix
254 }
255
256 #[must_use]
263 pub fn reads_resource_as_asset(&self) -> bool {
264 self.loaders
265 .last()
266 .is_some_and(|loader| ASSET_LOADERS.contains(&loader_package_name(loader)))
267 }
268
269 #[must_use]
276 pub fn runs_resource_in_another_thread(&self) -> bool {
277 self.loaders
278 .iter()
279 .any(|loader| THREAD_LOADERS.contains(&loader_package_name(loader)))
280 }
281}
282
283fn is_path(segment: &str) -> bool {
285 segment.starts_with('.') || segment.starts_with('/')
286}
287
288fn loader_package_name(loader: &str) -> &str {
291 if is_path(loader) {
292 return loader;
293 }
294 let mut parts = loader.splitn(3, '/');
295 let first = parts.next().unwrap_or(loader);
296 if first.starts_with('@') {
297 parts
298 .next()
299 .map_or(loader, |second| &loader[..first.len() + 1 + second.len()])
300 } else {
301 first
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 fn parse(specifier: &str) -> Option<(Vec<&str>, &str)> {
310 InlineLoaderRequest::parse(specifier).map(|request| (request.loaders, request.resource))
311 }
312
313 #[test]
314 fn parses_prefixes_loader_options_and_resource_query() {
315 assert_eq!(
316 parse("!raw-loader?esModule=false!./shim.js"),
317 Some((vec!["raw-loader"], "./shim.js"))
318 );
319 assert_eq!(
320 parse("!!style-loader!css-loader?modules!./a.css"),
321 Some((vec!["style-loader", "css-loader"], "./a.css"))
322 );
323 assert_eq!(
324 parse("-!./loaders/local.js!./worker.js"),
325 Some((vec!["./loaders/local.js"], "./worker.js"))
326 );
327 assert_eq!(
328 parse("raw-loader!./template.js?inline"),
329 Some((vec!["raw-loader"], "./template.js?inline"))
330 );
331 assert_eq!(
332 parse("@scope/loader!pkg/file"),
333 Some((vec!["@scope/loader"], "pkg/file"))
334 );
335 }
336
337 #[test]
338 fn prefix_without_loaders_keeps_the_resource() {
339 assert_eq!(parse("!!./file.js"), Some((vec![], "./file.js")));
340 }
341
342 #[test]
343 fn plain_and_url_specifiers_are_not_loader_requests() {
344 assert_eq!(parse("./file.js"), None);
345 assert_eq!(parse("react"), None);
346 assert_eq!(parse("https://example.com/a!b"), None);
347 assert_eq!(parse("data:text/javascript,1!2"), None);
348 assert_eq!(parse("raw-loader!"), None);
349 }
350
351 #[test]
352 fn systemjs_plugin_suffix_is_not_a_loader_request() {
353 assert_eq!(parse("./style.css!css"), None);
354 assert_eq!(parse("../a/b.txt!text"), None);
355 assert_eq!(
356 parse("!./style.css!css"),
357 Some((vec!["./style.css"], "css")),
358 "a prefix always selects the loader syntax"
359 );
360 }
361
362 #[test]
363 fn a_plain_path_with_a_bang_resolves_as_a_plain_path() {
364 let plain = Path::new("/project/src/we!rd.js");
365 assert_eq!(
366 InlineLoaderRequest::resolved("./we!rd.js", || Some(plain)),
367 None
368 );
369 assert_eq!(
370 InlineLoaderRequest::resolved("./we!rd", || Some(plain)),
371 None
372 );
373 let dir = Path::new("/project/src/a!b/c.js");
374 assert_eq!(
375 InlineLoaderRequest::resolved("./a!b/c.js", || Some(dir)),
376 None
377 );
378 }
379
380 #[test]
381 fn a_loader_request_resolves_to_its_resource() {
382 let resource = Path::new("/project/src/shim.js");
383 let request = InlineLoaderRequest::resolved("raw-loader!./shim.js", || Some(resource))
384 .expect("a loader request");
385 assert_eq!(request.loaders(), ["raw-loader"]);
386 assert!(InlineLoaderRequest::resolved("raw-loader!./missing.js", || None).is_some());
387 assert!(
388 InlineLoaderRequest::resolved("!!./we!rd.js", || Some(Path::new("/p/we!rd.js")))
389 .is_some(),
390 "a prefix always selects the loader syntax"
391 );
392 }
393
394 #[test]
395 fn a_package_target_tells_a_plain_subpath_from_a_loader_request() {
396 let plain = ResolveResult::NpmPackage("pkg".to_string());
397 let resource = ResolveResult::NpmPackage("b.js".to_string());
398 let no_file = |_| None;
399 assert_eq!(
400 InlineLoaderRequest::resolved_to("pkg/a!b.js", &plain, no_file),
401 None,
402 "the installed file `a!b.js` of `pkg`"
403 );
404 let request = InlineLoaderRequest::resolved_to("pkg/a!b.js", &resource, no_file)
405 .expect("the loader reading resolved the resource package");
406 assert_eq!(request.loaders(), ["pkg/a"]);
407 assert!(
408 InlineLoaderRequest::resolved_to("!pkg/a!b.js", &plain, no_file).is_some(),
409 "a prefix always selects the loader syntax"
410 );
411 assert!(
412 InlineLoaderRequest::resolved_to("pkg/loader!pkg/file.txt", &plain, no_file).is_some(),
413 "a resource in the same package keeps the loader reading"
414 );
415 let notes = ResolveResult::NpmPackage("notes-pkg".to_string());
416 assert!(
417 InlineLoaderRequest::resolved_to("raw-loader!notes-pkg/notes.txt", ¬es, no_file)
418 .is_some()
419 );
420 assert!(
421 InlineLoaderRequest::resolved_to(
422 "pkg/a!b.js",
423 &ResolveResult::Unresolvable("pkg/a!b.js".to_string()),
424 no_file
425 )
426 .is_some()
427 );
428 }
429
430 #[test]
431 fn an_unresolved_bare_request_with_a_bang_is_read_as_webpack_reads_it() {
432 let request = InlineLoaderRequest::resolved("pkg/a!b", || None)
433 .expect("webpack splits every request on `!`");
434 assert_eq!(request.loaders(), ["pkg/a"]);
435 assert_eq!(request.resource(), "b");
436 let installed = Path::new("/project/node_modules/pkg/a!b.js");
437 assert_eq!(
438 InlineLoaderRequest::resolved("pkg/a!b", || Some(installed)),
439 None,
440 "an installed package file with a `!` in its path resolves as a plain path"
441 );
442 }
443
444 #[test]
445 fn only_the_loader_next_to_the_resource_decides_asset_reads() {
446 let asset = |specifier| {
447 InlineLoaderRequest::parse(specifier)
448 .expect("a loader request")
449 .reads_resource_as_asset()
450 };
451 assert!(asset("!raw-loader!./a.js"));
452 assert!(asset("raw!./a.js"));
453 assert!(asset("file-loader?name=[name].[ext]!./a.png"));
454 assert!(asset("url-loader/dist/cjs.js!./a.png"));
455 assert!(asset("style-loader!raw-loader!./a.css"));
456 assert!(!asset("raw-loader!sass-loader!./a.scss"));
457 assert!(!asset("worker-loader!./worker.js"));
458 assert!(!asset("!!style-loader!css-loader!./a.css"));
459 assert!(!asset("babel-loader!./a.js"));
460 assert!(!asset("!!./a.js"));
461 assert!(!asset("-!./loaders/raw!./a.js"));
462 }
463
464 #[test]
465 fn any_thread_loader_in_the_chain_runs_the_resource_in_another_thread() {
466 let thread = |specifier| {
467 InlineLoaderRequest::parse(specifier)
468 .expect("a loader request")
469 .runs_resource_in_another_thread()
470 };
471 assert!(thread("worker-loader!./w.js"));
472 assert!(thread("worker!./w.js"));
473 assert!(thread(
474 "!!worker-loader?inline=fallback!babel-loader!./w.js"
475 ));
476 assert!(thread("sharedworker-loader?name=s!./s.js"));
477 assert!(thread("shared-worker!./s.js"));
478 assert!(thread("worklet-loader!./audio.js"));
479 assert!(thread("workerize-loader!./w.js"));
480 assert!(thread("comlink-loader?singleton!./w.js"));
481 assert!(thread("service-worker-loader!./sw.js"));
482 assert!(thread("serviceworker!./sw.js"));
483 assert!(thread("worker-plugin/loader?esModule!./w.js"));
484 assert!(!thread("raw-loader!./w.js"));
485 assert!(!thread("babel-loader!./w.js"));
486 assert!(!thread("./loaders/worker!./w.js"));
487 assert!(!thread("!!./w.js"));
488 }
489
490 #[test]
491 fn loader_package_name_keeps_the_scope() {
492 assert_eq!(loader_package_name("raw-loader"), "raw-loader");
493 assert_eq!(loader_package_name("raw-loader/dist/cjs.js"), "raw-loader");
494 assert_eq!(loader_package_name("@scope/loader/x.js"), "@scope/loader");
495 assert_eq!(loader_package_name("./loaders/raw"), "./loaders/raw");
496 }
497}