From 29333fdb4fa48e9800a314affe7c055476fe3f82 Mon Sep 17 00:00:00 2001 From: Andrei Alecu Date: Thu, 26 Mar 2026 19:12:29 +0200 Subject: [PATCH] Replace javascript-natural-sort package with inline ESM implementation --- package-lock.json | 24 +- package.json | 1 - src/function/relational/compareNatural.js | 2 +- src/function/relational/naturalSort.js | 93 +++++++ .../function/relational/naturalSort.test.js | 259 ++++++++++++++++++ 5 files changed, 370 insertions(+), 9 deletions(-) create mode 100644 src/function/relational/naturalSort.js create mode 100644 test/unit-tests/function/relational/naturalSort.test.js diff --git a/package-lock.json b/package-lock.json index 2637b244f6..6c60a5f9e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,6 @@ "decimal.js": "^10.4.3", "escape-latex": "^1.2.0", "fraction.js": "^5.2.1", - "javascript-natural-sort": "^0.7.1", "seedrandom": "^3.0.5", "tiny-emitter": "^2.1.0", "typed-function": "^4.2.1" @@ -117,6 +116,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2302,6 +2302,7 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -2366,6 +2367,7 @@ "integrity": "sha512-ixiWrCSRi33uqBMRuICcKECW7rtgY43TbsHDpM2XK7lXispd48opW+0IXrBVxv9NMhaz/Ue9kyj6r3NTVyXm8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.20.0" } @@ -2415,6 +2417,7 @@ "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", @@ -2828,6 +2831,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3812,6 +3816,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5262,6 +5267,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5347,6 +5353,7 @@ "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -5462,6 +5469,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5577,6 +5585,7 @@ "integrity": "sha512-6TyDmZ1HXoFQXnhCTUjVFULReoBPOAjpuiKELMkeP40yffI/1ZRO+d9ug/VC6fqISo2WkuIBk3cvuRPALaWlOQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "builtins": "^5.0.1", @@ -5690,6 +5699,7 @@ "integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -8191,12 +8201,6 @@ "node": ">=18" } }, - "node_modules/javascript-natural-sort": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", - "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==", - "license": "MIT" - }, "node_modules/jest-worker": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", @@ -8366,6 +8370,7 @@ "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@colors/colors": "1.5.0", "body-parser": "^1.19.0", @@ -10324,6 +10329,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11018,6 +11024,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -12079,6 +12086,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12420,6 +12428,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12994,6 +13003,7 @@ "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", diff --git a/package.json b/package.json index 07653a8be0..e56b148bf2 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,6 @@ "decimal.js": "^10.4.3", "escape-latex": "^1.2.0", "fraction.js": "^5.2.1", - "javascript-natural-sort": "^0.7.1", "seedrandom": "^3.0.5", "tiny-emitter": "^2.1.0", "typed-function": "^4.2.1" diff --git a/src/function/relational/compareNatural.js b/src/function/relational/compareNatural.js index 493007bd04..178576314f 100644 --- a/src/function/relational/compareNatural.js +++ b/src/function/relational/compareNatural.js @@ -1,4 +1,4 @@ -import naturalSort from 'javascript-natural-sort' +import { naturalSort } from './naturalSort.js' import { isDenseMatrix, isSparseMatrix, typeOf } from '../../utils/is.js' import { factory } from '../../utils/factory.js' diff --git a/src/function/relational/naturalSort.js b/src/function/relational/naturalSort.js new file mode 100644 index 0000000000..8bc206f2f5 --- /dev/null +++ b/src/function/relational/naturalSort.js @@ -0,0 +1,93 @@ +// Inline ESM replacement for the 'javascript-natural-sort' package (v0.7.1) +// Original: https://github.com/Bill4Time/javascript-natural-sort (MIT license) +// Author: Jim Palmer (based on chunking idea from Dave Koelle) + +const NUMBER_RE = /(^([+-]?(?:0|[1-9]\d*)(?:\.\d*)?(?:[eE][+-]?\d+)?)?$|^0x[0-9a-f]+$|\d+)/gi +const WHITESPACE_RE = /(^[ ]*|[ ]*$)/g +const DATE_RE = /(^([\w ]+,?[\w ]+)?[\w ]+,?[\w ]+\d+:\d+(:\d+)?[\w ]?|^\d{1,4}[/\-]\d{1,4}[/\-]\d{1,4}|^\w+, \w+ \d+, \d{4})/ // eslint-disable-line no-useless-escape +const HEX_RE = /^0x[0-9a-f]+$/i + +/** + * Split a string into chunks of text and numeric tokens for natural comparison. + * Uses null-character delimiters to separate numeric from non-numeric parts. + */ +function tokenize (str) { + // Reset lastIndex since NUMBER_RE has the global flag + NUMBER_RE.lastIndex = 0 + const s = str.replace(NUMBER_RE, '\0$1\0') + // Trim leading/trailing null chars and split — avoids two regex replacements + const start = s.charCodeAt(0) === 0 ? 1 : 0 + const end = s.charCodeAt(s.length - 1) === 0 ? s.length - 1 : s.length + return s.substring(start, end).split('\0') +} + +/** + * Parse a chunk as a number (if it doesn't have a leading zero) or keep as string. + * Falls back to 0 for undefined/empty chunks. + */ +function parseChunk (chunk) { + const s = chunk || '' + // charCodeAt check is ~4x faster than regex for leading-zero detection + if (s.charCodeAt(0) !== 48 /* '0' */) { + const n = parseFloat(s) + if (n) return n + } + return s || 0 +} + +/** + * Try to interpret the string as a hex number or a date for fast comparison. + * Returns the numeric value if successful, or a falsy value otherwise. + */ +function detectHexOrDate (str, tokens, otherDetected) { + if (HEX_RE.test(str)) return parseInt(str, 16) + // Only attempt date parse for multi-token strings (contains numbers mixed with text) + if (otherDetected !== undefined) { + return otherDetected && DATE_RE.test(str) && Date.parse(str) + } + return tokens.length !== 1 && DATE_RE.test(str) && Date.parse(str) +} + +/** + * Natural sort comparator. Compares two values by splitting them into + * text and numeric chunks and comparing each chunk appropriately. + */ +export function naturalSort (a, b) { + const strA = ('' + a).replace(WHITESPACE_RE, '') || '' + const strB = ('' + b).replace(WHITESPACE_RE, '') || '' + + const tokensA = tokenize(strA) + const tokensB = tokenize(strB) + + const detectedA = detectHexOrDate(strA, tokensA) + const detectedB = detectHexOrDate(strB, tokensB, detectedA) + + // If both are hex or dates, compare directly + if (detectedB) { + if (detectedA < detectedB) return -1 + if (detectedA > detectedB) return 1 + } + + // Compare chunk by chunk + const maxLen = Math.max(tokensA.length, tokensB.length) + for (let i = 0; i < maxLen; i++) { + let chunkA = parseChunk(tokensA[i]) + let chunkB = parseChunk(tokensB[i]) + + // Numbers sort before strings + if (isNaN(chunkA) !== isNaN(chunkB)) { + return isNaN(chunkA) ? 1 : -1 + } + + // Coerce to same type for comparison (e.g. '02' vs 2) + if (typeof chunkA !== typeof chunkB) { + chunkA += '' + chunkB += '' + } + + if (chunkA < chunkB) return -1 + if (chunkA > chunkB) return 1 + } + + return 0 +} diff --git a/test/unit-tests/function/relational/naturalSort.test.js b/test/unit-tests/function/relational/naturalSort.test.js new file mode 100644 index 0000000000..c505b4fc18 --- /dev/null +++ b/test/unit-tests/function/relational/naturalSort.test.js @@ -0,0 +1,259 @@ +import assert from 'assert' +import { naturalSort } from '../../../../src/function/relational/naturalSort.js' + +// Helper: sort an array using naturalSort and return the result +function sorted (arr) { + return arr.slice().sort(naturalSort) +} + +describe('naturalSort', function () { + it('should return 0 for identical strings', function () { + assert.strictEqual(naturalSort('a', 'a'), 0) + }) + + // Simple string comparisons + it('should sort simple strings', function () { + assert.deepStrictEqual(sorted(['b', 'a', 'c']), ['a', 'b', 'c']) + }) + + // Different value types + it('should sort numeric strings and numbers', function () { + assert.deepStrictEqual(sorted([2, 10, 1]), [1, 2, 10]) + assert.deepStrictEqual(sorted(['10', 9, 2, '1', '4']), ['1', 2, '4', 9, '10']) + }) + + it('should sort numeric strings with padding', function () { + assert.deepStrictEqual( + sorted(['0001', '002', '001']), + ['0001', '001', '002'] + ) + }) + + // DateTime sorting + it('should sort dates in MM/DD/YYYY format', function () { + assert.deepStrictEqual( + sorted(['10/12/2008', '10/11/2008', '10/11/2007', '10/12/2007']), + ['10/11/2007', '10/12/2007', '10/11/2008', '10/12/2008'] + ) + }) + + it('should sort dates in YYYY/MM/DD format', function () { + assert.deepStrictEqual( + sorted(['2008/10/12', '2008/10/11', '2007/10/12', '2007/10/11']), + ['2007/10/11', '2007/10/12', '2008/10/11', '2008/10/12'] + ) + }) + + it('should sort JavaScript toString() dates', function () { + assert.deepStrictEqual( + sorted([ + 'Wed Jan 01 2010 00:00:00 GMT-0800 (Pacific Standard Time)', + 'Thu Dec 25 2008 00:00:00 GMT-0800 (Pacific Standard Time)', + 'Wed Jan 01 2008 00:00:00 GMT-0800 (Pacific Standard Time)' + ]), + [ + 'Wed Jan 01 2008 00:00:00 GMT-0800 (Pacific Standard Time)', + 'Thu Dec 25 2008 00:00:00 GMT-0800 (Pacific Standard Time)', + 'Wed Jan 01 2010 00:00:00 GMT-0800 (Pacific Standard Time)' + ] + ) + }) + + // Version numbers + it('should sort version number strings', function () { + assert.deepStrictEqual( + sorted(['1.0.2', '1.0.1', '1.0.0', '1.0.9']), + ['1.0.0', '1.0.1', '1.0.2', '1.0.9'] + ) + }) + + it('should sort version numbers with alpha/beta', function () { + assert.deepStrictEqual( + sorted(['1.1.100', '1.1.1', '1.1.10', '1.1.54']), + ['1.1.1', '1.1.10', '1.1.54', '1.1.100'] + ) + }) + + it('should sort prefixed version numbers', function () { + assert.deepStrictEqual( + sorted(['v1.1', 'v1.2', 'v1.0']), + ['v1.0', 'v1.1', 'v1.2'] + ) + }) + + // Numeric comparisons + it('should sort floats', function () { + assert.deepStrictEqual( + sorted([1, 1.1, 1.01, 1.001]), + [1, 1.001, 1.01, 1.1] + ) + }) + + it('should sort scientific notation', function () { + assert.deepStrictEqual( + sorted(['1.528535047e5', '1.528535047e7', '1.528535047e3']), + ['1.528535047e3', '1.528535047e5', '1.528535047e7'] + ) + }) + + it('should sort negative numbers', function () { + assert.deepStrictEqual( + sorted([-1, -2, -10, -100]), + [-100, -10, -2, -1] + ) + }) + + it('should sort negative floats', function () { + assert.deepStrictEqual( + sorted([-2.01, -2.1, -2.001]), + [-2.1, -2.01, -2.001] + ) + }) + + // IP addresses + it('should sort IP addresses', function () { + assert.deepStrictEqual( + sorted(['192.168.0.100', '192.168.0.1', '192.168.1.1']), + ['192.168.0.1', '192.168.0.100', '192.168.1.1'] + ) + }) + + // Filenames + it('should sort filenames with numbers', function () { + assert.deepStrictEqual( + sorted(['img12.png', 'img10.png', 'img2.png', 'img1.png']), + ['img1.png', 'img2.png', 'img10.png', 'img12.png'] + ) + }) + + it('should sort filenames with complex names', function () { + assert.deepStrictEqual( + sorted(['car.mov', '01alpha.syi', '001alpha.syi', '1alpha.syi']), + ['001alpha.syi', '01alpha.syi', '1alpha.syi', 'car.mov'] + ) + }) + + it('should sort unix filenames with paths', function () { + assert.deepStrictEqual( + sorted([ + '/home/user/img/img10.png', + '/home/user/img/img2.png', + '/home/user/img/img1.png' + ]), + [ + '/home/user/img/img1.png', + '/home/user/img/img2.png', + '/home/user/img/img10.png' + ] + ) + }) + + // Whitespace + it('should handle leading spaces', function () { + assert.deepStrictEqual( + sorted([' b', 'a', ' c']), + ['a', ' b', ' c'] + ) + }) + + it('should handle empty strings', function () { + assert.deepStrictEqual( + sorted(['', 'a', '']), + ['', '', 'a'] + ) + }) + + // Hex + it('should sort hex numbers', function () { + assert.deepStrictEqual( + sorted(['0xA', '0x9', '0xB']), + ['0x9', '0xA', '0xB'] + ) + }) + + it('should not treat non-hex as hex', function () { + assert.deepStrictEqual( + sorted(['0xZZ', '0xAA']), + ['0xZZ', '0xAA'] + ) + }) + + // Unicode + it('should sort unicode characters', function () { + assert.deepStrictEqual( + sorted(['\u00e6', '\u00e4', '\u00f6']), + ['\u00e4', '\u00e6', '\u00f6'] + ) + }) + + // Case sensitivity + it('should be case-sensitive by default', function () { + assert.deepStrictEqual( + sorted(['A', 'b', 'C', 'd', 'E', 'f']), + ['A', 'C', 'E', 'b', 'd', 'f'] + ) + }) + + // Undefined handling + it('should handle undefined values', function () { + assert.deepStrictEqual( + sorted([undefined, 'a', undefined]), + ['a', undefined, undefined] + ) + }) + + // Numbers vs strings + it('should sort numbers before strings', function () { + assert.strictEqual(naturalSort('a', '1') > 0, true) + assert.strictEqual(naturalSort('1', 'a') < 0, true) + }) + + // Mixed alphanumeric + it('should sort mixed alphanumeric strings naturally', function () { + assert.deepStrictEqual( + sorted(['a1', 'a10', 'a2', 'a20', 'a3']), + ['a1', 'a2', 'a3', 'a10', 'a20'] + ) + }) + + // Zero-padded numbers + it('should sort zero-padded numbers', function () { + assert.deepStrictEqual( + sorted(['02', '1', '003', '10']), + ['003', '02', '1', '10'] + ) + }) + + // Non-string inputs + it('should handle null values', function () { + assert.strictEqual(naturalSort(null, null), 0) + assert.strictEqual(naturalSort(null, 'a') > 0, true) + assert.strictEqual(naturalSort('a', null) < 0, true) + }) + + it('should handle boolean values', function () { + assert.strictEqual(naturalSort(true, false) > 0, true) + assert.strictEqual(naturalSort(true, 'true'), 0) + }) + + it('should handle Infinity', function () { + assert.strictEqual(naturalSort(Infinity, 1000000) > 0, true) + assert.strictEqual(naturalSort(-Infinity, -1000000) < 0, true) + }) + + // Numeric equivalence + it('should treat scientific notation as equivalent number', function () { + assert.strictEqual(naturalSort('1e2', '100'), 0) + }) + + it('should handle large numbers', function () { + assert.strictEqual(naturalSort('999999999999', '1000000000000') < 0, true) + }) + + // Global regex stability (repeated calls must be consistent) + it('should produce stable results across repeated calls', function () { + for (let i = 0; i < 5; i++) { + assert.strictEqual(naturalSort('abc123', 'abc45') > 0, true) + } + }) +})