Skip to main content

fallow_graph/resolve/
inline_loaders.rs

1//! Webpack inline loader requests (`!raw-loader?opts!./file.js`).
2//!
3//! Webpack lets an import specifier name the loaders for one resource. The
4//! request starts with an optional `!`, `!!` or `-!` prefix that disables
5//! configured loaders. Loader segments follow, separated by `!`, and the last
6//! segment is the resource. A loader segment and the resource can carry a
7//! `?query`.
8//!
9//! A `!` is also a valid character in a file name. The resolver therefore
10//! resolves a request without a prefix as a plain path first, and uses the
11//! loader syntax only when that path does not resolve to a file. A request
12//! with a prefix is always a loader request. Webpack itself always splits a
13//! request on `!`, so a bare request such as `pkg/a!b` that does not resolve
14//! (for example without installed packages) is a loader request, as webpack
15//! reads it. Outside webpack it can be a package subpath; without the
16//! installed file nothing tells the two apart.
17//!
18//! Loaders run at build time, so they get no graph edge. The analysis layer
19//! credits loader packages as referenced tooling through
20//! [`InlineLoaderRequest::loaders`].
21//!
22//! A loader replaces the exports of its resource: `raw-loader` gives text,
23//! `worker-loader` gives a constructor, `css-loader` gives a class map. The
24//! imported and re-exported bindings do not name exports of the resource, so
25//! the graph gives the edge whole-module usage and keeps no re-export edge.
26//! When the loader next to the resource is in
27//! [`ASSET_LOADERS`], the bundle never runs the resource as code, so the edge
28//! gets `ImportLoadKind::AssetReference` and the graph does not follow the
29//! imports of the resource. A package behind such a loader is used at build
30//! time, not imported at runtime. When a loader in the chain is in
31//! [`THREAD_LOADERS`], the resource runs in another thread, so the edge gets
32//! the same load kind as `new Worker(new URL(...))`.
33//!
34//! Only inline requests count. A loader that a webpack config applies through
35//! `module.rules` does not change the edge: the rule conditions (`test`,
36//! `include`, `exclude`, `issuer`, `resourceQuery`, `oneOf` order, `enforce`)
37//! use JavaScript regular expressions and computed paths, and many projects
38//! build the rules in code (`webpack-merge`, the `webpack` hook of
39//! `next.config.js`, Storybook `webpackFinal`). A static read would miss or
40//! misapply rules, and a misapplied asset rule hides the imports of every
41//! file it matches.
42
43use std::path::Path;
44
45use fallow_types::discover::FileId;
46
47use super::{ResolveResult, extract_package_name, is_bare_specifier};
48
49/// Prefixes that turn off configured loaders, longest first.
50const LOADER_OVERRIDE_PREFIXES: &[&str] = &["-!", "!!", "!"];
51
52/// Loaders that turn their resource into text, bytes or a URL, from the
53/// documentation of each package. The bundle never runs the resource as code,
54/// so the imports of the resource are not runtime imports. Loaders that run or
55/// transform the resource as code (`babel-loader`, `ts-loader`, `css-loader`,
56/// `sass-loader`, `html-loader`, `worker-loader` and similar) are not in this
57/// table. The short names without `-loader` are the webpack 1 spelling.
58const ASSET_LOADERS: &[&str] = &[
59    // An `ArrayBuffer` with the file bytes.
60    "arraybuffer-loader",
61    "arraybuffer",
62    // A base64 data URL.
63    "base64-inline-loader",
64    // A binary string with the file bytes.
65    "binary-loader",
66    "binary",
67    // A Node `Buffer` with the file bytes.
68    "buffer-loader",
69    "buffer",
70    // The public URL of a copy of the file.
71    "file-loader",
72    "file",
73    // The file text.
74    "raw-loader",
75    "raw",
76    // The SVG markup as a string.
77    "svg-inline-loader",
78    // A data URL of the SVG.
79    "svg-url-loader",
80    // The file text (RequireJS `text!` plugin).
81    "text-loader",
82    "text",
83    // A data URL, or the public URL of a copy of the file.
84    "url-loader",
85    "url",
86];
87
88/// Loaders that bundle their resource as a separate script that runs in
89/// another thread or global scope (a worker, a shared worker, a service
90/// worker or a worklet), from the documentation of each package. The importer
91/// gets a constructor, a register function or a URL, never the resource
92/// exports. The graph gives such an edge the same load kind as
93/// `new Worker(new URL(...))`. The short names without `-loader` are the
94/// spelling that each package documents or the webpack 1 spelling.
95const THREAD_LOADERS: &[&str] = &[
96    // `comlink-loader`: moves the module into a Web Worker behind a proxy.
97    "comlink-loader",
98    // `service-worker-loader`: a function that registers a service worker.
99    "service-worker-loader",
100    // `serviceworker-loader`: a function that registers a service worker.
101    "serviceworker-loader",
102    "serviceworker",
103    // `shared-worker-loader`: a `SharedWorker` constructor.
104    "shared-worker-loader",
105    "shared-worker",
106    // `sharedworker-loader`: a `SharedWorker` constructor.
107    "sharedworker-loader",
108    "sharedworker",
109    // `worker-loader`: a `Worker` constructor.
110    "worker-loader",
111    "worker",
112    // `worker-plugin/loader`: the URL of a separate bundle for a worker.
113    "worker-plugin",
114    // `workerize-loader`: moves the module into a Web Worker behind a proxy.
115    "workerize-loader",
116    // `worklet-loader`: the URL of a script for `addModule` on a worklet.
117    "worklet-loader",
118];
119
120/// One parsed webpack inline loader request.
121#[derive(Debug, PartialEq, Eq)]
122pub struct InlineLoaderRequest<'a> {
123    /// Loader requests without their `?options`, in source order.
124    loaders: Vec<&'a str>,
125    /// The resource request, with its `?query` kept for the resolver.
126    resource: &'a str,
127    /// Whether the request starts with `!`, `!!` or `-!`.
128    has_prefix: bool,
129}
130
131impl<'a> InlineLoaderRequest<'a> {
132    /// Parse `specifier` as a webpack inline loader request.
133    ///
134    /// Returns `None` when the specifier has no `!` separator, is a URL, has
135    /// an empty resource segment, or has the SystemJS plugin shape
136    /// (`./style.css!css`: path loaders and a bare resource, no prefix).
137    #[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    /// Return the loader request that `specifier` resolved through, given the
167    /// file it resolved to.
168    ///
169    /// Returns `None` when `specifier` is not a loader request, or when it
170    /// resolved as a plain path. The resolver tries a request without a prefix
171    /// as a plain path first. A plain path keeps its `!` in the path of the
172    /// target, while a resource segment never contains a `!`. So the request
173    /// resolved as a plain path when `target` contains the first path segment
174    /// of `specifier` that holds a `!`. `target` gives the path of the target,
175    /// or `None` for a target that is not a file. It runs only for a loader
176    /// request without a prefix.
177    #[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    /// [`Self::resolved`] for a request that resolved to `target`.
196    ///
197    /// `file_path` gives the path of a project file by its id. An external
198    /// file gives its own path. A package target has no path: the request
199    /// resolved as a plain path when [`Self::names_package_file`] holds for
200    /// the target package. An unresolved request is a loader request.
201    #[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    /// Whether `specifier`, read as a plain bare request, names a file of
221    /// `package`, such as `pkg/a!b.js` for the installed file `a!b.js` of
222    /// `pkg`.
223    ///
224    /// The resolver accepts that reading only when the file is installed.
225    /// The loader reading resolves the resource, so its target is the
226    /// package of the resource. The target package therefore tells the two
227    /// readings apart, except when the resource names the same package: such
228    /// a request is always a loader request. A request with a prefix is never
229    /// a plain request.
230    #[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    /// Loader requests without their `?options`, in source order. A loader
238    /// can be a package name or a path to a local loader file.
239    #[must_use]
240    pub fn loaders(&self) -> &[&'a str] {
241        &self.loaders
242    }
243
244    /// The resource request, with its `?query`.
245    #[must_use]
246    pub const fn resource(&self) -> &'a str {
247        self.resource
248    }
249
250    /// Whether the request must use the loader syntax (it has a prefix).
251    #[must_use]
252    pub const fn has_prefix(&self) -> bool {
253        self.has_prefix
254    }
255
256    /// Whether the loader next to the resource reads it as an asset (text,
257    /// bytes or a URL), so the bundle never runs the resource as code.
258    ///
259    /// Webpack runs the loaders from right to left, so only the last loader
260    /// reads the resource file. In `raw-loader!sass-loader!./a.scss` Sass
261    /// compiles the file and follows its imports first.
262    #[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    /// Whether a loader in the chain runs the resource in another thread or
270    /// global scope, such as a worker or a worklet.
271    ///
272    /// Any position counts. A worker loader is a pitching loader: it compiles
273    /// the rest of the request as a separate bundle, and the loaders to its
274    /// left only see its output.
275    #[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
283/// Whether a request segment is a relative or absolute path.
284fn is_path(segment: &str) -> bool {
285    segment.starts_with('.') || segment.starts_with('/')
286}
287
288/// The package name of a loader request (`raw-loader/dist/cjs.js` gives
289/// `raw-loader`). A path to a local loader file stays as it is.
290fn 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", &notes, 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}