use std::path::Path;
use fallow_types::discover::FileId;
use super::{ResolveResult, extract_package_name, is_bare_specifier};
const LOADER_OVERRIDE_PREFIXES: &[&str] = &["-!", "!!", "!"];
const ASSET_LOADERS: &[&str] = &[
"arraybuffer-loader",
"arraybuffer",
"base64-inline-loader",
"binary-loader",
"binary",
"buffer-loader",
"buffer",
"file-loader",
"file",
"raw-loader",
"raw",
"svg-inline-loader",
"svg-url-loader",
"text-loader",
"text",
"url-loader",
"url",
];
const THREAD_LOADERS: &[&str] = &[
"comlink-loader",
"service-worker-loader",
"serviceworker-loader",
"serviceworker",
"shared-worker-loader",
"shared-worker",
"sharedworker-loader",
"sharedworker",
"worker-loader",
"worker",
"worker-plugin",
"workerize-loader",
"worklet-loader",
];
#[derive(Debug, PartialEq, Eq)]
pub struct InlineLoaderRequest<'a> {
loaders: Vec<&'a str>,
resource: &'a str,
has_prefix: bool,
}
impl<'a> InlineLoaderRequest<'a> {
#[must_use]
pub fn parse(specifier: &'a str) -> Option<Self> {
if !specifier.contains('!') || specifier.contains("://") || specifier.starts_with("data:") {
return None;
}
let stripped = LOADER_OVERRIDE_PREFIXES
.iter()
.find_map(|prefix| specifier.strip_prefix(prefix));
let has_prefix = stripped.is_some();
let request = stripped.unwrap_or(specifier);
let (loader_chain, resource) = request.rsplit_once('!').unwrap_or(("", request));
if resource.is_empty() {
return None;
}
let loaders: Vec<&str> = loader_chain
.split('!')
.map(|segment| segment.split_once('?').map_or(segment, |(name, _)| name))
.filter(|name| !name.is_empty())
.collect();
if !has_prefix && !is_path(resource) && loaders.iter().all(|loader| is_path(loader)) {
return None;
}
Some(Self {
loaders,
resource,
has_prefix,
})
}
#[must_use]
pub fn resolved<'p>(
specifier: &'a str,
target: impl FnOnce() -> Option<&'p Path>,
) -> Option<Self> {
let request = Self::parse(specifier)?;
if request.has_prefix {
return Some(request);
}
let resolved_as_plain_path = target().is_some_and(|path| {
specifier
.split('/')
.find(|segment| segment.contains('!'))
.is_some_and(|segment| path.to_string_lossy().contains(segment))
});
(!resolved_as_plain_path).then_some(request)
}
#[must_use]
pub fn resolved_to<'p>(
specifier: &'a str,
target: &'p ResolveResult,
file_path: impl FnOnce(FileId) -> Option<&'p Path>,
) -> Option<Self> {
if target.is_bare_package() {
let request = Self::parse(specifier)?;
let plain = target
.package_usage_name()
.is_some_and(|package| request.names_package_file(specifier, package));
return (!plain).then_some(request);
}
Self::resolved(specifier, || match target {
ResolveResult::ExternalFile(path) => Some(path.as_path()),
_ => target.internal_file_id().and_then(file_path),
})
}
#[must_use]
pub fn names_package_file(&self, specifier: &str, package: &str) -> bool {
let names =
|request: &str| is_bare_specifier(request) && extract_package_name(request) == package;
!self.has_prefix && names(specifier) && !names(self.resource)
}
#[must_use]
pub fn loaders(&self) -> &[&'a str] {
&self.loaders
}
#[must_use]
pub const fn resource(&self) -> &'a str {
self.resource
}
#[must_use]
pub const fn has_prefix(&self) -> bool {
self.has_prefix
}
#[must_use]
pub fn reads_resource_as_asset(&self) -> bool {
self.loaders
.last()
.is_some_and(|loader| ASSET_LOADERS.contains(&loader_package_name(loader)))
}
#[must_use]
pub fn runs_resource_in_another_thread(&self) -> bool {
self.loaders
.iter()
.any(|loader| THREAD_LOADERS.contains(&loader_package_name(loader)))
}
}
fn is_path(segment: &str) -> bool {
segment.starts_with('.') || segment.starts_with('/')
}
fn loader_package_name(loader: &str) -> &str {
if is_path(loader) {
return loader;
}
let mut parts = loader.splitn(3, '/');
let first = parts.next().unwrap_or(loader);
if first.starts_with('@') {
parts
.next()
.map_or(loader, |second| &loader[..first.len() + 1 + second.len()])
} else {
first
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(specifier: &str) -> Option<(Vec<&str>, &str)> {
InlineLoaderRequest::parse(specifier).map(|request| (request.loaders, request.resource))
}
#[test]
fn parses_prefixes_loader_options_and_resource_query() {
assert_eq!(
parse("!raw-loader?esModule=false!./shim.js"),
Some((vec!["raw-loader"], "./shim.js"))
);
assert_eq!(
parse("!!style-loader!css-loader?modules!./a.css"),
Some((vec!["style-loader", "css-loader"], "./a.css"))
);
assert_eq!(
parse("-!./loaders/local.js!./worker.js"),
Some((vec!["./loaders/local.js"], "./worker.js"))
);
assert_eq!(
parse("raw-loader!./template.js?inline"),
Some((vec!["raw-loader"], "./template.js?inline"))
);
assert_eq!(
parse("@scope/loader!pkg/file"),
Some((vec!["@scope/loader"], "pkg/file"))
);
}
#[test]
fn prefix_without_loaders_keeps_the_resource() {
assert_eq!(parse("!!./file.js"), Some((vec![], "./file.js")));
}
#[test]
fn plain_and_url_specifiers_are_not_loader_requests() {
assert_eq!(parse("./file.js"), None);
assert_eq!(parse("react"), None);
assert_eq!(parse("https://example.com/a!b"), None);
assert_eq!(parse("data:text/javascript,1!2"), None);
assert_eq!(parse("raw-loader!"), None);
}
#[test]
fn systemjs_plugin_suffix_is_not_a_loader_request() {
assert_eq!(parse("./style.css!css"), None);
assert_eq!(parse("../a/b.txt!text"), None);
assert_eq!(
parse("!./style.css!css"),
Some((vec!["./style.css"], "css")),
"a prefix always selects the loader syntax"
);
}
#[test]
fn a_plain_path_with_a_bang_resolves_as_a_plain_path() {
let plain = Path::new("/project/src/we!rd.js");
assert_eq!(
InlineLoaderRequest::resolved("./we!rd.js", || Some(plain)),
None
);
assert_eq!(
InlineLoaderRequest::resolved("./we!rd", || Some(plain)),
None
);
let dir = Path::new("/project/src/a!b/c.js");
assert_eq!(
InlineLoaderRequest::resolved("./a!b/c.js", || Some(dir)),
None
);
}
#[test]
fn a_loader_request_resolves_to_its_resource() {
let resource = Path::new("/project/src/shim.js");
let request = InlineLoaderRequest::resolved("raw-loader!./shim.js", || Some(resource))
.expect("a loader request");
assert_eq!(request.loaders(), ["raw-loader"]);
assert!(InlineLoaderRequest::resolved("raw-loader!./missing.js", || None).is_some());
assert!(
InlineLoaderRequest::resolved("!!./we!rd.js", || Some(Path::new("/p/we!rd.js")))
.is_some(),
"a prefix always selects the loader syntax"
);
}
#[test]
fn a_package_target_tells_a_plain_subpath_from_a_loader_request() {
let plain = ResolveResult::NpmPackage("pkg".to_string());
let resource = ResolveResult::NpmPackage("b.js".to_string());
let no_file = |_| None;
assert_eq!(
InlineLoaderRequest::resolved_to("pkg/a!b.js", &plain, no_file),
None,
"the installed file `a!b.js` of `pkg`"
);
let request = InlineLoaderRequest::resolved_to("pkg/a!b.js", &resource, no_file)
.expect("the loader reading resolved the resource package");
assert_eq!(request.loaders(), ["pkg/a"]);
assert!(
InlineLoaderRequest::resolved_to("!pkg/a!b.js", &plain, no_file).is_some(),
"a prefix always selects the loader syntax"
);
assert!(
InlineLoaderRequest::resolved_to("pkg/loader!pkg/file.txt", &plain, no_file).is_some(),
"a resource in the same package keeps the loader reading"
);
let notes = ResolveResult::NpmPackage("notes-pkg".to_string());
assert!(
InlineLoaderRequest::resolved_to("raw-loader!notes-pkg/notes.txt", ¬es, no_file)
.is_some()
);
assert!(
InlineLoaderRequest::resolved_to(
"pkg/a!b.js",
&ResolveResult::Unresolvable("pkg/a!b.js".to_string()),
no_file
)
.is_some()
);
}
#[test]
fn an_unresolved_bare_request_with_a_bang_is_read_as_webpack_reads_it() {
let request = InlineLoaderRequest::resolved("pkg/a!b", || None)
.expect("webpack splits every request on `!`");
assert_eq!(request.loaders(), ["pkg/a"]);
assert_eq!(request.resource(), "b");
let installed = Path::new("/project/node_modules/pkg/a!b.js");
assert_eq!(
InlineLoaderRequest::resolved("pkg/a!b", || Some(installed)),
None,
"an installed package file with a `!` in its path resolves as a plain path"
);
}
#[test]
fn only_the_loader_next_to_the_resource_decides_asset_reads() {
let asset = |specifier| {
InlineLoaderRequest::parse(specifier)
.expect("a loader request")
.reads_resource_as_asset()
};
assert!(asset("!raw-loader!./a.js"));
assert!(asset("raw!./a.js"));
assert!(asset("file-loader?name=[name].[ext]!./a.png"));
assert!(asset("url-loader/dist/cjs.js!./a.png"));
assert!(asset("style-loader!raw-loader!./a.css"));
assert!(!asset("raw-loader!sass-loader!./a.scss"));
assert!(!asset("worker-loader!./worker.js"));
assert!(!asset("!!style-loader!css-loader!./a.css"));
assert!(!asset("babel-loader!./a.js"));
assert!(!asset("!!./a.js"));
assert!(!asset("-!./loaders/raw!./a.js"));
}
#[test]
fn any_thread_loader_in_the_chain_runs_the_resource_in_another_thread() {
let thread = |specifier| {
InlineLoaderRequest::parse(specifier)
.expect("a loader request")
.runs_resource_in_another_thread()
};
assert!(thread("worker-loader!./w.js"));
assert!(thread("worker!./w.js"));
assert!(thread(
"!!worker-loader?inline=fallback!babel-loader!./w.js"
));
assert!(thread("sharedworker-loader?name=s!./s.js"));
assert!(thread("shared-worker!./s.js"));
assert!(thread("worklet-loader!./audio.js"));
assert!(thread("workerize-loader!./w.js"));
assert!(thread("comlink-loader?singleton!./w.js"));
assert!(thread("service-worker-loader!./sw.js"));
assert!(thread("serviceworker!./sw.js"));
assert!(thread("worker-plugin/loader?esModule!./w.js"));
assert!(!thread("raw-loader!./w.js"));
assert!(!thread("babel-loader!./w.js"));
assert!(!thread("./loaders/worker!./w.js"));
assert!(!thread("!!./w.js"));
}
#[test]
fn loader_package_name_keeps_the_scope() {
assert_eq!(loader_package_name("raw-loader"), "raw-loader");
assert_eq!(loader_package_name("raw-loader/dist/cjs.js"), "raw-loader");
assert_eq!(loader_package_name("@scope/loader/x.js"), "@scope/loader");
assert_eq!(loader_package_name("./loaders/raw"), "./loaders/raw");
}
}