├── benchmark ├── package.json ├── README.md └── run.js ├── .gitignore ├── .eslintrc.json ├── LICENSE.txt ├── package.json ├── .github └── workflows │ └── ci.yml ├── natural-compare.js ├── README.md └── test └── test.js /benchmark/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Dependencies for string-natural-compare bechmarking", 3 | "license": "UNLICENSED", 4 | "dependencies": { 5 | "benchmark": "^2.1.4", 6 | "string-natural-compare": "github:nwoltman/string-natural-compare" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Coverage directory used by tools like istanbul 11 | coverage 12 | 13 | # nyc test coverage 14 | .nyc_output 15 | 16 | # Dependency directory 17 | node_modules 18 | 19 | # Benchmark lock file 20 | benchmark/package-lock.json 21 | 22 | # IDE / Text Editor files 23 | .vscode 24 | *.sublime-* 25 | 26 | # Misc 27 | .DS_Store 28 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "@nwoltman/eslint-config", 4 | "overrides": [ 5 | { 6 | "files": ["test/*.js"], 7 | "env": { 8 | "mocha": true 9 | }, 10 | "rules": { 11 | "brace-style": 0, 12 | "max-len": 0, 13 | "max-nested-callbacks": 0, 14 | "padded-blocks": 0, 15 | "prefer-arrow-callback": 0 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /benchmark/README.md: -------------------------------------------------------------------------------- 1 | # String Natural Compare Benchmark Tests 2 | 3 | First update dependencies before running tests: 4 | 5 | ```sh 6 | npm install # or npm update if already installed 7 | ``` 8 | 9 | Run tests: 10 | 11 | ```sh 12 | node run 13 | ``` 14 | 15 | Run specific tests: 16 | 17 | ```sh 18 | node run 2 # runs test 2 19 | node run 1,2,3 # runs tests 1, 2, and 3 (make sure there are no spaces between the commas and numbers) 20 | ``` 21 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2016 Nathan Woltman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "string-natural-compare", 3 | "version": "3.0.1", 4 | "description": "Compare alphanumeric strings the same way a human would, using a natural order algorithm", 5 | "author": "Nathan Woltman ", 6 | "license": "MIT", 7 | "main": "natural-compare.js", 8 | "files": [ 9 | "natural-compare.js" 10 | ], 11 | "repository": "github:nwoltman/string-natural-compare", 12 | "homepage": "https://github.com/nwoltman/string-natural-compare", 13 | "bugs": "https://github.com/nwoltman/string-natural-compare/issues", 14 | "keywords": [ 15 | "string", 16 | "natural", 17 | "compare", 18 | "comparison", 19 | "order", 20 | "natcmp", 21 | "strnatcmp", 22 | "sort", 23 | "natsort", 24 | "alphanum", 25 | "alphanumeric" 26 | ], 27 | "eslintIgnore": [ 28 | "benchmark/node_modules/", 29 | "coverage/" 30 | ], 31 | "nyc": { 32 | "reporter": [ 33 | "html", 34 | "lcov", 35 | "text-summary" 36 | ], 37 | "check-coverage": true, 38 | "branches": 100, 39 | "lines": 100, 40 | "statements": 100 41 | }, 42 | "devDependencies": { 43 | "@nwoltman/eslint-config": "^0.6.0", 44 | "eslint": "^6.8.0", 45 | "mocha": "^7.0.0", 46 | "nyc": "^15.0.0", 47 | "should": "^13.2.3" 48 | }, 49 | "scripts": { 50 | "lint": "eslint .", 51 | "test": "eslint . && nyc mocha" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | FORCE_COLOR: 2 7 | 8 | jobs: 9 | 10 | test: 11 | name: Test - Node ${{ matrix.node-version }} 12 | strategy: 13 | matrix: 14 | node-version: [16, 18, 19] 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - run: npm install 24 | - run: npm test 25 | - name: Send Coverage 26 | uses: coverallsapp/github-action@master 27 | with: 28 | github-token: ${{ secrets.GITHUB_TOKEN }} 29 | flag-name: Node-${{ matrix.node-version }} 30 | parallel: true 31 | 32 | coveralls: 33 | name: Coveralls 34 | needs: test 35 | runs-on: ubuntu-latest 36 | steps: 37 | - name: Finish Coveralls 38 | uses: coverallsapp/github-action@master 39 | with: 40 | github-token: ${{ secrets.GITHUB_TOKEN }} 41 | parallel-finished: true 42 | 43 | publish: 44 | name: Publish 45 | if: startsWith(github.ref, 'refs/tags/v') 46 | needs: test 47 | runs-on: ubuntu-latest 48 | steps: 49 | - name: Checkout 50 | uses: actions/checkout@v3 51 | - name: Set up Node.js 52 | uses: actions/setup-node@v3 53 | with: 54 | node-version: 'lts/*' 55 | registry-url: 'https://registry.npmjs.org' 56 | - run: npm publish 57 | env: 58 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 59 | -------------------------------------------------------------------------------- /natural-compare.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const defaultAlphabetIndexMap = []; 4 | 5 | function isNumberCode(code) { 6 | return code >= 48/* '0' */ && code <= 57/* '9' */; 7 | } 8 | 9 | function naturalCompare(a, b, opts) { 10 | if (typeof a !== 'string') { 11 | throw new TypeError(`The first argument must be a string. Received type '${typeof a}'`); 12 | } 13 | if (typeof b !== 'string') { 14 | throw new TypeError(`The second argument must be a string. Received type '${typeof b}'`); 15 | } 16 | 17 | const lengthA = a.length; 18 | const lengthB = b.length; 19 | let indexA = 0; 20 | let indexB = 0; 21 | let alphabetIndexMap = defaultAlphabetIndexMap; 22 | let firstDifferenceInLeadingZeros = 0; 23 | 24 | if (opts) { 25 | if (opts.caseInsensitive) { 26 | a = a.toLowerCase(); 27 | b = b.toLowerCase(); 28 | } 29 | 30 | if (opts.alphabet) { 31 | alphabetIndexMap = buildAlphabetIndexMap(opts.alphabet); 32 | } 33 | } 34 | 35 | while (indexA < lengthA && indexB < lengthB) { 36 | let charCodeA = a.charCodeAt(indexA); 37 | let charCodeB = b.charCodeAt(indexB); 38 | 39 | if (isNumberCode(charCodeA)) { 40 | if (!isNumberCode(charCodeB)) { 41 | return charCodeA - charCodeB; 42 | } 43 | 44 | let numStartA = indexA; 45 | let numStartB = indexB; 46 | 47 | while (charCodeA === 48/* '0' */ && ++numStartA < lengthA) { 48 | charCodeA = a.charCodeAt(numStartA); 49 | } 50 | while (charCodeB === 48/* '0' */ && ++numStartB < lengthB) { 51 | charCodeB = b.charCodeAt(numStartB); 52 | } 53 | 54 | if (numStartA !== numStartB && firstDifferenceInLeadingZeros === 0) { 55 | firstDifferenceInLeadingZeros = numStartA - numStartB; 56 | } 57 | 58 | let numEndA = numStartA; 59 | let numEndB = numStartB; 60 | 61 | while (numEndA < lengthA && isNumberCode(a.charCodeAt(numEndA))) { 62 | ++numEndA; 63 | } 64 | while (numEndB < lengthB && isNumberCode(b.charCodeAt(numEndB))) { 65 | ++numEndB; 66 | } 67 | 68 | let difference = numEndA - numStartA - numEndB + numStartB; // numA length - numB length 69 | if (difference !== 0) { 70 | return difference; 71 | } 72 | 73 | while (numStartA < numEndA) { 74 | difference = a.charCodeAt(numStartA++) - b.charCodeAt(numStartB++); 75 | if (difference !== 0) { 76 | return difference; 77 | } 78 | } 79 | 80 | indexA = numEndA; 81 | indexB = numEndB; 82 | continue; 83 | } 84 | 85 | if (charCodeA !== charCodeB) { 86 | if ( 87 | charCodeA < alphabetIndexMap.length && 88 | charCodeB < alphabetIndexMap.length && 89 | alphabetIndexMap[charCodeA] !== -1 && 90 | alphabetIndexMap[charCodeB] !== -1 91 | ) { 92 | return alphabetIndexMap[charCodeA] - alphabetIndexMap[charCodeB]; 93 | } 94 | 95 | return charCodeA - charCodeB; 96 | } 97 | 98 | ++indexA; 99 | ++indexB; 100 | } 101 | 102 | if (indexA < lengthA) { // `b` is a substring of `a` 103 | return 1; 104 | } 105 | 106 | if (indexB < lengthB) { // `a` is a substring of `b` 107 | return -1; 108 | } 109 | 110 | return firstDifferenceInLeadingZeros; 111 | } 112 | 113 | const alphabetIndexMapCache = {}; 114 | 115 | function buildAlphabetIndexMap(alphabet) { 116 | const existingMap = alphabetIndexMapCache[alphabet]; 117 | if (existingMap !== undefined) { 118 | return existingMap; 119 | } 120 | 121 | const indexMap = []; 122 | const maxCharCode = alphabet.split('').reduce((maxCode, char) => { 123 | return Math.max(maxCode, char.charCodeAt(0)); 124 | }, 0); 125 | 126 | for (let i = 0; i <= maxCharCode; i++) { 127 | indexMap.push(-1); 128 | } 129 | 130 | for (let i = 0; i < alphabet.length; i++) { 131 | indexMap[alphabet.charCodeAt(i)] = i; 132 | } 133 | 134 | alphabetIndexMapCache[alphabet] = indexMap; 135 | 136 | return indexMap; 137 | } 138 | 139 | module.exports = naturalCompare; 140 | -------------------------------------------------------------------------------- /benchmark/run.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 'use strict'; 3 | 4 | const Benchmark = require('benchmark'); 5 | 6 | const naturalCompareMaster = require('string-natural-compare'); 7 | const naturalCompareLocal = require('../'); 8 | 9 | const config = new Set( 10 | process.argv.length > 2 ? process.argv[2].split(',') : '1234567' 11 | ); 12 | 13 | const suite = new Benchmark.Suite(); 14 | 15 | if (config.has('1')) { 16 | suite 17 | .add('1) no numbers master', () => { 18 | naturalCompareMaster('fileA.txt', 'fileB.txt'); 19 | naturalCompareMaster('fileB.txt', 'fileA.txt'); 20 | }) 21 | .add('1) no numbers local', () => { 22 | naturalCompareLocal('fileA.txt', 'fileB.txt'); 23 | naturalCompareLocal('fileB.txt', 'fileA.txt'); 24 | }); 25 | } 26 | 27 | if (config.has('2')) { 28 | suite 29 | .add('2) common numbers different lengths master', () => { 30 | naturalCompareMaster('2.txt', '10.txt'); 31 | naturalCompareMaster('10.txt', '2.txt'); 32 | }) 33 | .add('2) common numbers different lengths local', () => { 34 | naturalCompareLocal('2.txt', '10.txt'); 35 | naturalCompareLocal('10.txt', '2.txt'); 36 | }); 37 | } 38 | 39 | if (config.has('3')) { 40 | suite 41 | .add('3) common numbers same length master', () => { 42 | naturalCompareMaster('01.txt', '05.txt'); 43 | naturalCompareMaster('05.txt', '01.txt'); 44 | }) 45 | .add('3) common numbers same length local', () => { 46 | naturalCompareLocal('01.txt', '05.txt'); 47 | naturalCompareLocal('05.txt', '01.txt'); 48 | }); 49 | } 50 | 51 | if (config.has('4')) { 52 | suite 53 | .add('4) big numbers different lengths master', () => { 54 | naturalCompareMaster( 55 | '1165874568735487968325787328996865', 56 | '265812277985321589735871687040841' 57 | ); 58 | naturalCompareMaster( 59 | '265812277985321589735871687040841', 60 | '1165874568735487968325787328996865' 61 | ); 62 | }) 63 | .add('4) big numbers different lengths local', () => { 64 | naturalCompareLocal( 65 | '1165874568735487968325787328996865', 66 | '265812277985321589735871687040841' 67 | ); 68 | naturalCompareLocal( 69 | '265812277985321589735871687040841', 70 | '1165874568735487968325787328996865' 71 | ); 72 | }); 73 | } 74 | 75 | if (config.has('5')) { 76 | suite 77 | .add('5) big numbers same length master', () => { 78 | naturalCompareMaster( 79 | '1165874568735487968325787328996865', 80 | '1165874568735487989735871687040841' 81 | ); 82 | naturalCompareMaster( 83 | '1165874568735487989735871687040841', 84 | '1165874568735487968325787328996865' 85 | ); 86 | }) 87 | .add('5) big numbers same length local', () => { 88 | naturalCompareLocal( 89 | '1165874568735487968325787328996865', 90 | '1165874568735487989735871687040841' 91 | ); 92 | naturalCompareLocal( 93 | '1165874568735487989735871687040841', 94 | '1165874568735487968325787328996865' 95 | ); 96 | }); 97 | } 98 | 99 | if (suite.length) { 100 | suite 101 | .on('cycle', (event) => { 102 | console.log(String(event.target)); 103 | }) 104 | .run(); 105 | } 106 | 107 | const alphabetSuite = new Benchmark.Suite(); 108 | const opts = { 109 | alphabet: 'ABDEFGHIJKLMNOPRSŠZŽTUVÕÄÖÜXYabdefghijklmnoprsšzžtuvõäöüxy', 110 | }; 111 | 112 | if (config.has('6')) { 113 | alphabetSuite 114 | .add('6) custom alphabet included characters master', () => { 115 | naturalCompareMaster('š.txt', 'z.txt', opts); 116 | naturalCompareMaster('z.txt', 'š.txt', opts); 117 | }) 118 | .add('6) custom alphabet included characters local', () => { 119 | naturalCompareLocal('š.txt', 'z.txt', opts); 120 | naturalCompareLocal('z.txt', 'š.txt', opts); 121 | }); 122 | } 123 | 124 | if (config.has('7')) { 125 | alphabetSuite 126 | .add('7) custom alphabet missing characters master', () => { 127 | naturalCompareMaster('é.txt', 'à.txt', opts); 128 | naturalCompareMaster('à.txt', 'é.txt', opts); 129 | }) 130 | .add('7) custom alphabet missing characters local', () => { 131 | naturalCompareLocal('é.txt', 'à.txt', opts); 132 | naturalCompareLocal('à.txt', 'é.txt', opts); 133 | }); 134 | } 135 | 136 | if (alphabetSuite.length) { 137 | alphabetSuite 138 | .on('cycle', (event) => { 139 | console.log(String(event.target)); 140 | }) 141 | .run(); 142 | } 143 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # String Natural Compare 2 | 3 | [![NPM Version](https://img.shields.io/npm/v/string-natural-compare.svg)](https://www.npmjs.com/package/string-natural-compare) 4 | [![Build Status](https://img.shields.io/github/actions/workflow/status/nwoltman/string-natural-compare/ci.yml?branch=master)](https://github.com/nwoltman/string-natural-compare/actions/workflows/ci.yml?query=branch%3Amaster) 5 | [![Coverage Status](https://coveralls.io/repos/nwoltman/string-natural-compare/badge.svg?branch=master)](https://coveralls.io/r/nwoltman/string-natural-compare?branch=master) 6 | 7 | Compare alphanumeric strings the same way a human would, using a natural order algorithm (originally known as the [alphanum algorithm](http://davekoelle.com/alphanum.html)) where numeric characters are sorted based on their numeric values rather than their ASCII values. 8 | 9 | ``` 10 | Standard sorting: Natural order sorting: 11 | img1.png img1.png 12 | img10.png img2.png 13 | img12.png img10.png 14 | img2.png img12.png 15 | ``` 16 | 17 | This module exports a function that returns a number indicating whether one string should come before, after, or is the same as another string. 18 | It can be used directly with the native [`.sort()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort) array method. 19 | 20 | ### Fast and Robust 21 | 22 | This module can compare strings containing any size of number and is heavily tested with a custom [benchmark suite](https://github.com/nwoltman/string-natural-compare/tree/master/benchmark) to make sure that it is as fast as possible. 23 | 24 | 25 | ## Installation 26 | 27 | ```sh 28 | npm install string-natural-compare 29 | # or 30 | yarn add string-natural-compare 31 | ``` 32 | 33 | 34 | ## Usage 35 | 36 | #### `naturalCompare(strA, strB[, options])` 37 | 38 | + `strA` (_string_) 39 | + `strB` (_string_) 40 | + `options` (_object_) - Optional options object with the following options: 41 | + `caseInsensitive` (_boolean_) - Set to `true` to compare strings case-insensitively. Default: `false`. 42 | + `alphabet` (_string_) - A string of characters that define a custom character ordering. Default: `undefined`. 43 | 44 | ```js 45 | const naturalCompare = require('string-natural-compare'); 46 | 47 | // Simple, case-sensitive sorting 48 | const files = ['z1.doc', 'z10.doc', 'z17.doc', 'z2.doc', 'z23.doc', 'z3.doc']; 49 | files.sort(naturalCompare); 50 | // -> ['z1.doc', 'z2.doc', 'z3.doc', 'z10.doc', 'z17.doc', 'z23.doc'] 51 | 52 | 53 | // Case-insensitive sorting 54 | const chars = ['B', 'C', 'a', 'd']; 55 | const naturalCompareCI = (a, b) => naturalCompare(a, b, {caseInsensitive: true}); 56 | chars.sort(naturalCompareCI); 57 | // -> ['a', 'B', 'C', 'd'] 58 | 59 | // Note: 60 | ['a', 'A'].sort(naturalCompareCI); // -> ['a', 'A'] 61 | ['A', 'a'].sort(naturalCompareCI); // -> ['A', 'a'] 62 | 63 | 64 | // Compare strings containing large numbers 65 | naturalCompare( 66 | '1165874568735487968325787328996865', 67 | '265812277985321589735871687040841' 68 | ); 69 | // -> 1 70 | // (Other inputs with the same ordering as this example may yield a different number > 0) 71 | 72 | 73 | // Sorting an array of objects 74 | const hotelRooms = [ 75 | {street: '350 5th Ave', room: 'A-1021'}, 76 | {street: '350 5th Ave', room: 'A-21046-b'} 77 | ]; 78 | // Sort by street (case-insensitive), then by room (case-sensitive) 79 | hotelRooms.sort((a, b) => ( 80 | naturalCompare(a.street, b.street, {caseInsensitive: true}) || 81 | naturalCompare(a.room, b.room) 82 | )); 83 | 84 | 85 | // When text transformation is needed or when doing a case-insensitive sort on a 86 | // large array of objects, it is best for performance to pre-compute the 87 | // transformed text and store it on the object. This way, the text will not need 88 | // to be transformed for every comparison while sorting. 89 | const cars = [ 90 | {make: 'Audi', model: 'R8'}, 91 | {make: 'Porsche', model: '911 Turbo S'} 92 | ]; 93 | // Sort by make, then by model (both case-insensitive) 94 | for (const car of cars) { 95 | car.sortKey = (car.make + ' ' + car.model).toLowerCase(); 96 | } 97 | cars.sort((a, b) => naturalCompare(a.sortKey, b.sortKey)); 98 | 99 | 100 | // Using a custom alphabet (Russian alphabet) 101 | const russianOpts = { 102 | alphabet: 'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдеёжзийклмнопрстуфхцчшщъыьэюя', 103 | }; 104 | ['Ё', 'А', 'б', 'Б'].sort((a, b) => naturalCompare(a, b, russianOpts)); 105 | // -> ['А', 'Б', 'Ё', 'б'] 106 | ``` 107 | 108 | **Note:** Putting numbers in the custom alphabet can cause undefined behaviour. 109 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('should'); 4 | 5 | const assert = require('assert').strict; 6 | const naturalCompare = require('../'); 7 | 8 | function verify(testData) { 9 | const a = testData[0]; 10 | const b = testData[2]; 11 | const failMessage = `failure on input: [${testData.join(' ')}]`; 12 | 13 | switch (testData[1]) { 14 | case '=': 15 | naturalCompare(a, b).should.equal(0, failMessage); 16 | naturalCompare(a, b, {caseInsensitive: true}).should.equal(0, failMessage); 17 | break; 18 | case '>': 19 | naturalCompare(a, b).should.be.greaterThan(0, failMessage); 20 | naturalCompare(a, b, {caseInsensitive: true}).should.be.greaterThan(0, failMessage); 21 | break; 22 | case '<': 23 | naturalCompare(a, b).should.be.lessThan(0, failMessage); 24 | naturalCompare(a, b, {caseInsensitive: true}).should.be.lessThan(0, failMessage); 25 | break; 26 | default: 27 | throw new Error('Unknown comparison operator: ' + testData[1]); 28 | } 29 | } 30 | 31 | describe('naturalCompare()', () => { 32 | 33 | it('should throw if the first argument is not a string', () => { 34 | assert.throws( 35 | () => naturalCompare(undefined), 36 | new TypeError("The first argument must be a string. Received type 'undefined'") 37 | ); 38 | assert.throws( 39 | () => naturalCompare(null), 40 | new TypeError("The first argument must be a string. Received type 'object'") 41 | ); 42 | assert.throws( 43 | () => naturalCompare(1), 44 | new TypeError("The first argument must be a string. Received type 'number'") 45 | ); 46 | assert.throws( 47 | () => naturalCompare(false), 48 | new TypeError("The first argument must be a string. Received type 'boolean'") 49 | ); 50 | assert.throws( 51 | () => naturalCompare({}), 52 | new TypeError("The first argument must be a string. Received type 'object'") 53 | ); 54 | assert.throws( 55 | () => naturalCompare([]), 56 | new TypeError("The first argument must be a string. Received type 'object'") 57 | ); 58 | assert.throws( 59 | () => naturalCompare(Symbol('sym')), 60 | new TypeError("The first argument must be a string. Received type 'symbol'") 61 | ); 62 | }); 63 | 64 | it('should throw if the second argument is not a string', () => { 65 | assert.throws( 66 | () => naturalCompare('', undefined), 67 | new TypeError("The second argument must be a string. Received type 'undefined'") 68 | ); 69 | assert.throws( 70 | () => naturalCompare('', null), 71 | new TypeError("The second argument must be a string. Received type 'object'") 72 | ); 73 | assert.throws( 74 | () => naturalCompare('', 0), 75 | new TypeError("The second argument must be a string. Received type 'number'") 76 | ); 77 | assert.throws( 78 | () => naturalCompare('', true), 79 | new TypeError("The second argument must be a string. Received type 'boolean'") 80 | ); 81 | assert.throws( 82 | () => naturalCompare('', {}), 83 | new TypeError("The second argument must be a string. Received type 'object'") 84 | ); 85 | assert.throws( 86 | () => naturalCompare('', []), 87 | new TypeError("The second argument must be a string. Received type 'object'") 88 | ); 89 | assert.throws( 90 | () => naturalCompare('', Symbol('sym')), 91 | new TypeError("The second argument must be a string. Received type 'symbol'") 92 | ); 93 | }); 94 | 95 | it('should compare strings that do not contain numbers', () => { 96 | [ 97 | ['a', '=', 'a'], 98 | ['a', '<', 'b'], 99 | ['b', '>', 'a'], 100 | ['a', '<', 'aa'], 101 | ['aa', '>', 'a'], 102 | ['a', '<', 'ba'], 103 | ['ba', '>', 'a'], 104 | ['aa', '<', 'b'], 105 | ['b', '>', 'aa'], 106 | ['aa', '<', 'ba'], 107 | ['ba', '>', 'aa'], 108 | ].forEach(verify); 109 | }); 110 | 111 | it('should compare integer substrings by their numeric value', () => { 112 | [ 113 | ['1', '=', '1'], 114 | ['50', '=', '50'], 115 | ['11001', '>', '1102'], 116 | ['a', '<', 'a1'], 117 | ['a1', '>', 'a'], 118 | ['1', '<', 'a'], 119 | ['a', '>', '1'], 120 | ['2', '<', '3'], 121 | ['3', '>', '2'], 122 | ['2', '<', '10'], 123 | ['10', '>', '2'], 124 | ['a1', '=', 'a1'], 125 | ['a1', '<', 'a2'], 126 | ['a2', '>', 'a1'], 127 | ['a1', '<', 'a11'], 128 | ['a11', '>', 'a1'], 129 | ['a11', '<', 'a12'], 130 | ['a12', '>', 'a11'], 131 | ['a1', '<', 'a1a'], 132 | ['a1a', '>', 'a1'], 133 | ['a1a', '<', 'a11'], 134 | ['a11', '>', 'a1a'], 135 | ['a1a', '<', 'a11a'], 136 | ['a11a', '>', 'a1a'], 137 | ].forEach(verify); 138 | }); 139 | 140 | it('should work with 0 in the string', () => { 141 | [ 142 | ['a00', '<', 'a000'], 143 | ['a 0 a', '<', 'a 0 b'], 144 | ['a 0 a', '<', 'a 00 b'], 145 | ['a00', '<', 'a0a'], 146 | ['a0000', '<', 'a0a'], 147 | ['a0a', '>', 'a00'], 148 | ['a0a', '>', 'a000'], 149 | ['a0a', '<', 'a0b'], 150 | ['a0a', '<', 'a00a'], 151 | ['a0a', '<', 'a00b'], 152 | ['a00a', '<', 'a0b'], 153 | ['a00a0a', '<', 'a0a00b'], 154 | ['a0a00b', '<', 'a00a0b'], 155 | ['a00a0b', '>', 'a0a00b'], 156 | ].forEach(verify); 157 | }); 158 | 159 | it('should compare integer substrings with leading 0s by their numeric value', () => { 160 | [ 161 | ['000', '=', '000'], 162 | ['001', '=', '001'], 163 | ['00', '<', '1'], 164 | ['00', '<', '0001'], 165 | ['010', '>', '01'], 166 | ['010', '>', '001'], 167 | ].forEach(verify); 168 | }); 169 | 170 | it('should not consider a decimal point surrounded by integers as a floating point number', () => { 171 | [ 172 | ['0.01', '<', '0.001'], 173 | ['0.001', '>', '0.01'], 174 | ['1.01', '<', '1.001'], 175 | ['1.001', '>', '1.01'], 176 | ].forEach(verify); 177 | }); 178 | 179 | it('should not consider an integer preceeded by a minus sign as a negative number', () => { 180 | [ 181 | ['-1', '<', '-2'], 182 | ['-2', '<', '-10'], 183 | ['-11', '>', '-10'], 184 | ['-11', '<', '-100'], 185 | ['a-11', '<', 'a-100'], 186 | ].forEach(verify); 187 | }); 188 | 189 | it('should correctly compare strings containing very large numbers', () => { 190 | [ 191 | [ 192 | '1165874568735487968325787328996864', 193 | '=', 194 | '1165874568735487968325787328996864', 195 | ], 196 | [ 197 | '1165874568735487968325787328996864', 198 | '<', 199 | '1165874568735487968325787328996865', 200 | ], 201 | [ 202 | '1165874568735487968325787328996864', 203 | '>', 204 | '216587456873548796832578732899686', 205 | ], 206 | ].forEach(verify); 207 | }); 208 | 209 | it('should perform case-sensitive comparisons by default', () => { 210 | naturalCompare('a', 'A').should.be.greaterThan(0); 211 | naturalCompare('b', 'C').should.be.greaterThan(0); 212 | }); 213 | 214 | it('should function correctly as the callback to array.sort()', () => { 215 | ['a', 'c', 'b', 'd'] 216 | .sort(naturalCompare) 217 | .should.deepEqual(['a', 'b', 'c', 'd']); 218 | 219 | ['file-2.txt', 'file-1.txt', 'file-20.txt', 'file-3.txt'] 220 | .sort(naturalCompare) 221 | .should.deepEqual(['file-1.txt', 'file-2.txt', 'file-3.txt', 'file-20.txt']); 222 | 223 | [ 224 | 'a000', 225 | 'a000.html', 226 | 'a000a.html', 227 | 'a000b.html', 228 | 'a0', 229 | 'a00', 230 | 'a00.html', 231 | 'a00a.html', 232 | 'a0001a.html', 233 | 'a001a.html', 234 | 'a1a.html', 235 | 'a000000000', 236 | ].sort(naturalCompare).should.deepEqual([ 237 | 'a0', 238 | 'a00', 239 | 'a000', 240 | 'a000000000', 241 | 'a00.html', 242 | 'a000.html', 243 | 'a00a.html', 244 | 'a000a.html', 245 | 'a000b.html', 246 | 'a1a.html', 247 | 'a001a.html', 248 | 'a0001a.html', 249 | ]); 250 | }); 251 | 252 | it('should compare strings using the provided alphabet', () => { 253 | const opts = { 254 | alphabet: 'ABDEFGHIJKLMNOPRSŠZŽTUVÕÄÖÜXYabdefghijklmnoprsšzžtuvõäöüxy', 255 | }; 256 | 257 | ['Д', 'a', 'ä', 'B', 'Š', 'X', 'A', 'õ', 'u', 'z', '1', '2', '9', '10'] 258 | .sort((a, b) => naturalCompare(a, b, opts)) 259 | .should.deepEqual(['1', '2', '9', '10', 'A', 'B', 'Š', 'X', 'a', 'z', 'u', 'õ', 'ä', 'Д']); 260 | 261 | naturalCompare.alphabet = null; // Reset alphabet for other tests 262 | }); 263 | 264 | 265 | describe('with {caseInsensitive: true}', () => { 266 | 267 | it('should perform case-insensitive comparisons', () => { 268 | naturalCompare('a', 'A', {caseInsensitive: true}).should.equal(0); 269 | naturalCompare('b', 'C', {caseInsensitive: true}).should.be.lessThan(0); 270 | 271 | ['C', 'B', 'a', 'd'] 272 | .sort((a, b) => naturalCompare(a, b, {caseInsensitive: true})) 273 | .should.deepEqual(['a', 'B', 'C', 'd']); 274 | }); 275 | 276 | it('should compare strings using the provided alphabet', () => { 277 | const opts = { 278 | alphabet: 'ABDEFGHIJKLMNOPRSŠZŽTUVÕÄÖÜXYabdefghijklmnoprsšzžtuvõäöüxy', 279 | caseInsensitive: true, 280 | }; 281 | 282 | ['Д', 'a', 'ä', 'B', 'Š', 'X', 'Ü', 'õ', 'u', 'z', '1', '2', '9', '10'] 283 | .sort((a, b) => naturalCompare(a, b, opts)) 284 | .should.deepEqual(['1', '2', '9', '10', 'a', 'B', 'Š', 'z', 'u', 'õ', 'ä', 'Ü', 'X', 'Д']); 285 | }); 286 | 287 | }); 288 | 289 | }); 290 | --------------------------------------------------------------------------------