diff --git a/.changeset/replace-fastest-levenshtein.md b/.changeset/replace-fastest-levenshtein.md new file mode 100644 index 00000000000..b284ffbf95e --- /dev/null +++ b/.changeset/replace-fastest-levenshtein.md @@ -0,0 +1,5 @@ +--- +"webpack-cli": patch +--- + +Replace the `fastest-levenshtein` dependency with a small in-tree implementation used for command/option "did you mean" suggestions. diff --git a/package-lock.json b/package-lock.json index e22c662dfd4..f5de0b87c6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10535,15 +10535,6 @@ "fast-string-width": "^3.0.2" } }, - "node_modules/fastest-levenshtein": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", - "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", - "license": "MIT", - "engines": { - "node": ">= 4.9.1" - } - }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -20889,7 +20880,6 @@ "commander": "^14.0.3", "cross-spawn": "^7.0.6", "envinfo": "^7.14.0", - "fastest-levenshtein": "^1.0.12", "import-local": "^3.0.2", "interpret": "^3.1.1", "rechoir": "^0.8.0", diff --git a/packages/webpack-cli/package.json b/packages/webpack-cli/package.json index 3fe90835142..fbd251e6e51 100644 --- a/packages/webpack-cli/package.json +++ b/packages/webpack-cli/package.json @@ -35,7 +35,6 @@ "commander": "^14.0.3", "cross-spawn": "^7.0.6", "envinfo": "^7.14.0", - "fastest-levenshtein": "^1.0.12", "import-local": "^3.0.2", "interpret": "^3.1.1", "rechoir": "^0.8.0", diff --git a/packages/webpack-cli/src/levenshtein.ts b/packages/webpack-cli/src/levenshtein.ts new file mode 100644 index 00000000000..f719670bfa4 --- /dev/null +++ b/packages/webpack-cli/src/levenshtein.ts @@ -0,0 +1,165 @@ +// Levenshtein distance via Myers' bit-parallel algorithm. +// Inspired by fastest-levenshtein (MIT, https://github.com/ka-weihe/fastest-levenshtein). + +const peq = new Uint32Array(0x10000); + +function myers32(a: string, b: string): number { + const n = a.length; + const m = b.length; + const lst = 1 << (n - 1); + let pv = -1; + let mv = 0; + let sc = n; + let i = n; + + while (i--) { + peq[a.charCodeAt(i)] |= 1 << i; + } + + for (i = 0; i < m; i++) { + let eq = peq[b.charCodeAt(i)]; + const xv = eq | mv; + + eq |= ((eq & pv) + pv) ^ pv; + mv |= ~(eq | pv); + pv &= eq; + + if (mv & lst) { + sc++; + } + + if (pv & lst) { + sc--; + } + + mv = (mv << 1) | 1; + pv = (pv << 1) | ~(xv | mv); + mv &= xv; + } + + i = n; + + while (i--) { + peq[a.charCodeAt(i)] = 0; + } + + return sc; +} + +function myersX(longer: string, shorter: string): number { + const n = shorter.length; + const m = longer.length; + const mhc: number[] = []; + const phc: number[] = []; + const horizontalSize = Math.ceil(n / 32); + const verticalSize = Math.ceil(m / 32); + + for (let i = 0; i < horizontalSize; i++) { + phc[i] = -1; + mhc[i] = 0; + } + + let j = 0; + + for (; j < verticalSize - 1; j++) { + let mv = 0; + let pv = -1; + const start = j * 32; + const verticalLen = Math.min(32, m) + start; + + for (let k = start; k < verticalLen; k++) { + peq[longer.charCodeAt(k)] |= 1 << k; + } + + for (let i = 0; i < n; i++) { + const eq = peq[shorter.charCodeAt(i)]; + const pb = (phc[(i / 32) | 0] >>> i) & 1; + const mb = (mhc[(i / 32) | 0] >>> i) & 1; + const xv = eq | mv; + const xh = ((((eq | mb) & pv) + pv) ^ pv) | eq | mb; + let ph = mv | ~(xh | pv); + let mh = pv & xh; + + if ((ph >>> 31) ^ pb) { + phc[(i / 32) | 0] ^= 1 << i; + } + + if ((mh >>> 31) ^ mb) { + mhc[(i / 32) | 0] ^= 1 << i; + } + + ph = (ph << 1) | pb; + mh = (mh << 1) | mb; + pv = mh | ~(xv | ph); + mv = ph & xv; + } + + for (let k = start; k < verticalLen; k++) { + peq[longer.charCodeAt(k)] = 0; + } + } + + let mv = 0; + let pv = -1; + const start = j * 32; + const verticalLen = Math.min(32, m - start) + start; + + for (let k = start; k < verticalLen; k++) { + peq[longer.charCodeAt(k)] |= 1 << k; + } + + let score = m; + + for (let i = 0; i < n; i++) { + const eq = peq[shorter.charCodeAt(i)]; + const pb = (phc[(i / 32) | 0] >>> i) & 1; + const mb = (mhc[(i / 32) | 0] >>> i) & 1; + const xv = eq | mv; + const xh = ((((eq | mb) & pv) + pv) ^ pv) | eq | mb; + let ph = mv | ~(xh | pv); + let mh = pv & xh; + + score += (ph >>> (m - 1)) & 1; + score -= (mh >>> (m - 1)) & 1; + + if ((ph >>> 31) ^ pb) { + phc[(i / 32) | 0] ^= 1 << i; + } + + if ((mh >>> 31) ^ mb) { + mhc[(i / 32) | 0] ^= 1 << i; + } + + ph = (ph << 1) | pb; + mh = (mh << 1) | mb; + pv = mh | ~(xv | ph); + mv = ph & xv; + } + + for (let k = start; k < verticalLen; k++) { + peq[longer.charCodeAt(k)] = 0; + } + + return score; +} + +/** + * Returns the Levenshtein edit distance between two strings. + */ +export function distance(first: string, second: string): number { + let a = first; + let b = second; + + if (a.length < b.length) { + const tmp = b; + + b = a; + a = tmp; + } + + if (b.length === 0) { + return a.length; + } + + return a.length <= 32 ? myers32(a, b) : myersX(a, b); +} diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index 3b897068c60..7b3bca9345c 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -13,7 +13,6 @@ import { program, } from "commander"; import { type Config as EnvinfoConfig, type Options as EnvinfoOptions } from "envinfo"; -import { distance } from "fastest-levenshtein"; import { type prepare } from "rechoir"; import { type Argument as WebpackArgument, @@ -31,6 +30,7 @@ import { default as webpack, } from "webpack"; import { type Configuration as DevServerConfiguration } from "webpack-dev-server"; +import { distance } from "./levenshtein.js"; const WEBPACK_PACKAGE_IS_CUSTOM = Boolean(process.env.WEBPACK_PACKAGE); const WEBPACK_PACKAGE = WEBPACK_PACKAGE_IS_CUSTOM diff --git a/test/api/levenshtein.test.js b/test/api/levenshtein.test.js new file mode 100644 index 00000000000..68ed2897488 --- /dev/null +++ b/test/api/levenshtein.test.js @@ -0,0 +1,44 @@ +const { distance } = require("../../packages/webpack-cli/lib/levenshtein"); + +describe("distance", () => { + it("should return 0 for equal strings", () => { + expect(distance("", "")).toBe(0); + expect(distance("webpack", "webpack")).toBe(0); + }); + + it("should return the length of the other string when one is empty", () => { + expect(distance("", "abc")).toBe(3); + expect(distance("abc", "")).toBe(3); + }); + + it("should not depend on argument order", () => { + expect(distance("kitten", "sitting")).toBe(distance("sitting", "kitten")); + }); + + it("should count single edits", () => { + expect(distance("server", "serve")).toBe(1); + expect(distance("test", "tests")).toBe(1); + expect(distance("cat", "car")).toBe(1); + }); + + it("should compute classic distances", () => { + expect(distance("kitten", "sitting")).toBe(3); + expect(distance("flying", "sailing")).toBe(4); + }); + + it("should handle strings longer than 32 characters", () => { + const a = "a".repeat(40); + const b = `${"a".repeat(39)}b`; + + expect(distance(a, b)).toBe(1); + expect(distance("a".repeat(40), "b".repeat(40))).toBe(40); + }); + + it("should handle a long string against a much shorter one", () => { + const long = "abcdefghijklmnopqrstuvwxyz0123456789ABCD"; + + expect(long).toHaveLength(40); + expect(distance(long, "abcde")).toBe(35); + expect(distance("abcde", long)).toBe(35); + }); +});