Repository: webpack/webpack (JavaScript)

Issue:
Webpack unable to resolve loaders path contains a special character

**What is the current behavior?**
When the path contains a special character Webpack is unable to correctly resolve loaders.

**If the current behavior is a bug, please provide the steps to reproduce.**
- Create a directory with a special character, example `/Users/reproductions/webpack-#`
- Install and configure a loader 
- Run Webpack

Config
```js
const { resolve } = require("path");

module.exports = {
  mode: "development",
  entry: "./index.js",
  output: {
    path: resolve(__dirname, "dist"),
    filename: "[name].js",
  },
  module: {
    rules: [
      {
        test: /.css/,
        loader: 'postcss-loader',
      },
    ],
  }
};
```

**What is the expected behavior?**
Build works, but instead it errors out with the below

```
➜  webpack-# git:(main) ✗ yarn webpack
yarn run v1.22.17
$ '/Users/reproductions/webpack-#/node_modules/.bin/webpack'
asset main.js 3.39 KiB [compared for emit] (name: main)
../webpack-#/index.js 39 bytes [not cacheable] [built] [code generated] [1 error]

ERROR in ../webpack-#/index.js
Module build failed (from ../webpack-#/node_modules/postcss-loader/dist/cjs.js):
Error: Cannot find module '/Users/reproductions/webpack-#/node_modules/postcss-loader/dist/cjs.js'
Require stack:
- /Users/reproductions/webpack-#/node_modules/loader-runner/lib/loadLoader.js
- /Users/reproductions/webpack-#/node_modules/loader-runner/lib/LoaderRunner.js
- /Users/reproductions/webpack-#/node_modules/webpack/lib/NormalModuleFactory.js
- /Users/reproductions/webpack-#/node_modules/webpack/lib/Compiler.js
- /Users/reproductions/webpack-#/node_modules/webpack/lib/webpack.js
- /Users/reproductions/webpack-#/node_modules/webpack/lib/index.js
- /Users/reproductions/webpack-#/node_modules/webpack-cli/lib/webpack-cli.js
- /Users/reproductions/webpack-#/node_modules/webpack-cli/lib/bootstrap.js
- /Users/reproductions/webpack-#/node_modules/webpack-cli/bin/cli.js
- /Users/reproductions/webpack-#/node_modules/webpack/bin/webpack.js
    at Function.Module._resolveFilename (node:internal/modules/cjs/loader:933:15)
    at Function.Module._load (node:internal/modules/cjs/loader:778:27)
    at Module.require (node:internal/modules/cjs/loader:1005:19)
    at require (node:internal/modules/cjs/helpers:102:18)
    at loadLoader (/Users/reproductions/webpack-#/node_modules/loader-runner/lib/loadLoader.js:19:17)
    at iteratePitchingLoaders (/Users/reproductions/webpack-#/node_modules/loader-runner/lib/LoaderRunner.js:182:2)
    at runLoaders (/Users/reproductions/webpack-#/node_modules/loader-runner/lib/LoaderRunner.js:397:2)
    at NormalModule._doBuild (/Users/reproductions/webpack-#/node_modules/webpack/lib/NormalModule.js:812:3)
    at NormalModule.build (/Users/reproductions/webpack-#/node_modules/webpack/lib/NormalModule.js:956:15)
    at /Users/reproductions/webpack-#/node_modules/webpack/lib/Compilation.js:1367:12

webpack 5.64.0 compiled with 1 error in 79 ms
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.




// Check with Yarn
yarn why postcss-loader
yarn why v1.22.17
warning package.json: No license field
warning ../package.json: No license field
[1/4] 🤔  Why do we have the module "postcss-loader"...?
[2/4] 🚚  Initialising dependency graph...
warning No license field
[3/4] 🔍  Finding dependency...
[4/4] 🚡  Calculating file sizes...
=> Found "postcss-loader@6.2.0"
info Has been hoisted to "postcss-loader"
info This module exists because it's specified in "dependencies".
info Disk size without dependencies: "108KB"
info Disk size with unique dependencies: "688KB"
info Disk size with transitive dependencies: "1.92MB"
info Number of shared dependencies: 6
✨  Done in 0.15s.



// File exists
➜  webpack-# git:(main) ✗ cat /Users/reproductions/webpack-#/node_modules/postcss-loader/dist/cjs.js
"use strict";

module.exports = require("./index").default;
```

<!-- "It should work" is not a helpful explanation -->
<!-- Explain exactly how it should behave -->

**Other relevant information:**
webpack version:  "5.64.0
Node.js version: 16.13.0
Operating System: darwin x64
Additional tools:



Hints:
Thanks for your report, this was fixed some times ago.. maybe we did not fix problem with loader.

Patch:
diff --git a/lib/NormalModuleFactory.js b/lib/NormalModuleFactory.js
--- a/lib/NormalModuleFactory.js
+++ b/lib/NormalModuleFactory.js
@@ -28,7 +28,10 @@ const LazySet = require("./util/LazySet");
 const { getScheme } = require("./util/URLAbsoluteSpecifier");
 const { cachedCleverMerge, cachedSetProperty } = require("./util/cleverMerge");
 const { join } = require("./util/fs");
-const { parseResource } = require("./util/identifier");
+const {
+	parseResource,
+	parseResourceWithoutFragment
+} = require("./util/identifier");
 
 /** @typedef {import("../declarations/WebpackOptions").ModuleOptionsNormalized} ModuleOptions */
 /** @typedef {import("./Generator")} Generator */
@@ -66,6 +69,11 @@ const { parseResource } = require("./util/identifier");
 
 /** @typedef {ResourceData & { data: Record<string, any> }} ResourceDataWithData */
 
+/** @typedef {Object} ParsedLoaderRequest
+ * @property {string} loader loader
+ * @property {string|undefined} options options
+ */
+
 const EMPTY_RESOLVE_OPTIONS = {};
 const EMPTY_PARSER_OPTIONS = {};
 const EMPTY_GENERATOR_OPTIONS = {};
@@ -97,27 +105,6 @@ const stringifyLoadersAndResource = (loaders, resource) => {
 	return str + resource;
 };
 
-/**
- * @param {string} resultString resultString
- * @returns {{loader: string, options: string|undefined}} parsed loader request
- */
-const identToLoaderRequest = resultString => {
-	const idx = resultString.indexOf("?");
-	if (idx >= 0) {
-		const loader = resultString.substr(0, idx);
-		const options = resultString.substr(idx + 1);
-		return {
-			loader,
-			options
-		};
-	} else {
-		return {
-			loader: resultString,
-			options: undefined
-		};
-	}
-};
-
 const needCalls = (times, callback) => {
 	return err => {
 		if (--times === 0) {
@@ -264,6 +251,9 @@ class NormalModuleFactory extends ModuleFactory {
 		const cacheParseResource = parseResource.bindCache(
 			associatedObjectForCache
 		);
+		const cachedParseResourceWithoutFragment =
+			parseResourceWithoutFragment.bindCache(associatedObjectForCache);
+		this._parseResourceWithoutFragment = cachedParseResourceWithoutFragment;
 
 		this.hooks.factorize.tapAsync(
 			{
@@ -351,7 +341,7 @@ class NormalModuleFactory extends ModuleFactory {
 				let matchResourceData = undefined;
 				/** @type {string} */
 				let unresolvedResource;
-				/** @type {{loader: string, options: string|undefined}[]} */
+				/** @type {ParsedLoaderRequest[]} */
 				let elements;
 				let noPreAutoLoaders = false;
 				let noAutoLoaders = false;
@@ -405,7 +395,13 @@ class NormalModuleFactory extends ModuleFactory {
 							)
 							.split(/!+/);
 						unresolvedResource = rawElements.pop();
-						elements = rawElements.map(identToLoaderRequest);
+						elements = rawElements.map(el => {
+							const { path, query } = cachedParseResourceWithoutFragment(el);
+							return {
+								loader: path,
+								options: query ? query.slice(1) : undefined
+							};
+						});
 						scheme = getScheme(unresolvedResource);
 					} else {
 						unresolvedResource = requestWithoutMatchResource;
@@ -1017,12 +1013,14 @@ If changing the source code is not an option there is also a resolve options cal
 						}
 						if (err) return callback(err);
 
-						const parsedResult = identToLoaderRequest(result);
+						const parsedResult = this._parseResourceWithoutFragment(result);
 						const resolved = {
-							loader: parsedResult.loader,
+							loader: parsedResult.path,
 							options:
 								item.options === undefined
-									? parsedResult.options
+									? parsedResult.query
+										? parsedResult.query.slice(1)
+										: undefined
 									: item.options,
 							ident: item.options === undefined ? undefined : item.ident
 						};
diff --git a/lib/util/identifier.js b/lib/util/identifier.js
--- a/lib/util/identifier.js
+++ b/lib/util/identifier.js
@@ -81,7 +81,49 @@ const requestToAbsolute = (context, relativePath) => {
 	return relativePath;
 };
 
-const makeCacheable = fn => {
+const makeCacheable = realFn => {
+	/** @type {WeakMap<object, Map<string, ParsedResource>>} */
+	const cache = new WeakMap();
+
+	const getCache = associatedObjectForCache => {
+		const entry = cache.get(associatedObjectForCache);
+		if (entry !== undefined) return entry;
+		/** @type {Map<string, ParsedResource>} */
+		const map = new Map();
+		cache.set(associatedObjectForCache, map);
+		return map;
+	};
+
+	/**
+	 * @param {string} str the path with query and fragment
+	 * @param {Object=} associatedObjectForCache an object to which the cache will be attached
+	 * @returns {ParsedResource} parsed parts
+	 */
+	const fn = (str, associatedObjectForCache) => {
+		if (!associatedObjectForCache) return realFn(str);
+		const cache = getCache(associatedObjectForCache);
+		const entry = cache.get(str);
+		if (entry !== undefined) return entry;
+		const result = realFn(str);
+		cache.set(str, result);
+		return result;
+	};
+
+	fn.bindCache = associatedObjectForCache => {
+		const cache = getCache(associatedObjectForCache);
+		return str => {
+			const entry = cache.get(str);
+			if (entry !== undefined) return entry;
+			const result = realFn(str);
+			cache.set(str, result);
+			return result;
+		};
+	};
+
+	return fn;
+};
+
+const makeCacheableWithContext = fn => {
 	/** @type {WeakMap<object, Map<string, Map<string, string>>>} */
 	const cache = new WeakMap();
 
@@ -215,7 +257,7 @@ const _makePathsRelative = (context, identifier) => {
 		.join("");
 };
 
-exports.makePathsRelative = makeCacheable(_makePathsRelative);
+exports.makePathsRelative = makeCacheableWithContext(_makePathsRelative);
 
 /**
  *
@@ -230,7 +272,7 @@ const _makePathsAbsolute = (context, identifier) => {
 		.join("");
 };
 
-exports.makePathsAbsolute = makeCacheable(_makePathsAbsolute);
+exports.makePathsAbsolute = makeCacheableWithContext(_makePathsAbsolute);
 
 /**
  * @param {string} context absolute context path
@@ -244,7 +286,7 @@ const _contextify = (context, request) => {
 		.join("!");
 };
 
-const contextify = makeCacheable(_contextify);
+const contextify = makeCacheableWithContext(_contextify);
 exports.contextify = contextify;
 
 /**
@@ -259,13 +301,15 @@ const _absolutify = (context, request) => {
 		.join("!");
 };
 
-const absolutify = makeCacheable(_absolutify);
+const absolutify = makeCacheableWithContext(_absolutify);
 exports.absolutify = absolutify;
 
 const PATH_QUERY_FRAGMENT_REGEXP =
 	/^((?:\0.|[^?#\0])*)(\?(?:\0.|[^#\0])*)?(#.*)?$/;
+const PATH_QUERY_REGEXP = /^((?:\0.|[^?\0])*)(\?.*)?$/;
 
 /** @typedef {{ resource: string, path: string, query: string, fragment: string }} ParsedResource */
+/** @typedef {{ resource: string, path: string, query: string }} ParsedResourceWithoutFragment */
 
 /**
  * @param {string} str the path with query and fragment
@@ -280,47 +324,24 @@ const _parseResource = str => {
 		fragment: match[3] || ""
 	};
 };
-exports.parseResource = (realFn => {
-	/** @type {WeakMap<object, Map<string, ParsedResource>>} */
-	const cache = new WeakMap();
+exports.parseResource = makeCacheable(_parseResource);
 
-	const getCache = associatedObjectForCache => {
-		const entry = cache.get(associatedObjectForCache);
-		if (entry !== undefined) return entry;
-		/** @type {Map<string, ParsedResource>} */
-		const map = new Map();
-		cache.set(associatedObjectForCache, map);
-		return map;
-	};
-
-	/**
-	 * @param {string} str the path with query and fragment
-	 * @param {Object=} associatedObjectForCache an object to which the cache will be attached
-	 * @returns {ParsedResource} parsed parts
-	 */
-	const fn = (str, associatedObjectForCache) => {
-		if (!associatedObjectForCache) return realFn(str);
-		const cache = getCache(associatedObjectForCache);
-		const entry = cache.get(str);
-		if (entry !== undefined) return entry;
-		const result = realFn(str);
-		cache.set(str, result);
-		return result;
-	};
-
-	fn.bindCache = associatedObjectForCache => {
-		const cache = getCache(associatedObjectForCache);
-		return str => {
-			const entry = cache.get(str);
-			if (entry !== undefined) return entry;
-			const result = realFn(str);
-			cache.set(str, result);
-			return result;
-		};
+/**
+ * Parse resource, skips fragment part
+ * @param {string} str the path with query and fragment
+ * @returns {ParsedResourceWithoutFragment} parsed parts
+ */
+const _parseResourceWithoutFragment = str => {
+	const match = PATH_QUERY_REGEXP.exec(str);
+	return {
+		resource: str,
+		path: match[1].replace(/\0(.)/g, "$1"),
+		query: match[2] ? match[2].replace(/\0(.)/g, "$1") : ""
 	};
-
-	return fn;
-})(_parseResource);
+};
+exports.parseResourceWithoutFragment = makeCacheable(
+	_parseResourceWithoutFragment
+);
 
 /**
  * @param {string} filename the filename which should be undone


Test patch:
diff --git a/test/configCases/loaders/#-issue-14755-#/#.my b/test/configCases/loaders/#-issue-14755-#/#.my
new file mode 100644
--- /dev/null
+++ b/test/configCases/loaders/#-issue-14755-#/#.my
@@ -0,0 +1 @@
+[a-z]
diff --git a/test/configCases/loaders/#-issue-14755-#/index.js b/test/configCases/loaders/#-issue-14755-#/index.js
new file mode 100644
--- /dev/null
+++ b/test/configCases/loaders/#-issue-14755-#/index.js
@@ -0,0 +1,6 @@
+import regexp from './#.my';
+
+it("should load regexp correctly", () => {
+	expect(regexp.test("1")).toBe(false);
+	expect(regexp.test("a")).toBe(true);
+});
diff --git a/test/configCases/loaders/#-issue-14755-#/node_modules/regexp-#-loader/index.js b/test/configCases/loaders/#-issue-14755-#/node_modules/regexp-#-loader/index.js
new file mode 100644
--- /dev/null
+++ b/test/configCases/loaders/#-issue-14755-#/node_modules/regexp-#-loader/index.js
@@ -0,0 +1,5 @@
+"use strict";
+
+module.exports = function loader(data) {
+	return `export default new RegExp(${JSON.stringify(data.trim())})`
+}
diff --git a/test/configCases/loaders/#-issue-14755-#/node_modules/regexp-#-loader/package.json b/test/configCases/loaders/#-issue-14755-#/node_modules/regexp-#-loader/package.json
new file mode 100644
--- /dev/null
+++ b/test/configCases/loaders/#-issue-14755-#/node_modules/regexp-#-loader/package.json
@@ -0,0 +1,6 @@
+{
+  "name": "regexp-#-loader",
+  "version": "1.0.0",
+  "dependencies": {
+  }
+}
diff --git a/test/configCases/loaders/#-issue-14755-#/webpack.config.js b/test/configCases/loaders/#-issue-14755-#/webpack.config.js
new file mode 100644
--- /dev/null
+++ b/test/configCases/loaders/#-issue-14755-#/webpack.config.js
@@ -0,0 +1,11 @@
+/** @type {import("../../../../").Configuration} */
+module.exports = {
+	module: {
+		rules: [
+			{
+				test: /\.my$/,
+				loader: "regexp-#-loader"
+			}
+		]
+	}
+};
diff --git a/test/identifier.unittest.js b/test/identifier.unittest.js
--- a/test/identifier.unittest.js
+++ b/test/identifier.unittest.js
@@ -89,4 +89,34 @@ describe("util/identifier", () => {
 			});
 		}
 	});
+
+	describe("parseResourceWithoutFragment", () => {
+		// [input, expectedPath, expectedQuery]
+		/** @type {[string, string, string][]} */
+		const cases = [
+			["path#hash?query", "path#hash", "?query"],
+			["path?query#hash", "path", "?query#hash"],
+			["\0#path\0??\0#query#hash", "#path?", "?#query#hash"],
+			[
+				'./loader.js?{"items":["a\0^","b\0!","c#","d"]}',
+				"./loader.js",
+				'?{"items":["a^","b!","c#","d"]}'
+			],
+			[
+				"C:\\Users\\\0#\\repo\\loader.js?",
+				"C:\\Users\\#\\repo\\loader.js",
+				"?"
+			],
+			["/Users/\0#/repo/loader-\0#.js", "/Users/#/repo/loader-#.js", ""]
+		];
+		cases.forEach(case_ => {
+			it(case_[0], () => {
+				const { resource, path, query } =
+					identifierUtil.parseResourceWithoutFragment(case_[0]);
+				expect(case_[0]).toBe(resource);
+				expect(case_[1]).toBe(path);
+				expect(case_[2]).toBe(query);
+			});
+		});
+	});
 });