diff --git a/src/engine.ts b/src/engine.ts index 00929e4c..2b876878 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -1356,7 +1356,10 @@ function sortName(value) { return name; } -const natural = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'}).compare; +// The same order everywhere: a fixed locale, as Node follows the locale of the user and Bun does not, +// and ascii for the names natural sees as the same, like file1 and file01, so the order of the glob does not decide +const collator = new Intl.Collator('en', {numeric: true, sensitivity: 'base'}).compare; +const natural = (a, b) => collator(a, b) || ascii(a, b); const ascii = (a, b) => (a < b ? -1 : a > b ? 1 : 0); function sortFiles(files, sort, conf) { diff --git a/test/cli/run.sh b/test/cli/run.sh index b694b8f3..88777bdf 100644 --- a/test/cli/run.sh +++ b/test/cli/run.sh @@ -275,6 +275,12 @@ assert_raises "rexreplace 'oo' 'x' nope.file --expect-files" 1 reset assert "rexreplace 'b' 'x' my.file your.file --sort-reverse --name-delimiter ','" 'your.file,my.file,' +# The natural order is the same on Node and Bun, and in every locale. In Danish aa sorts after z. +reset +echo 'foo' > aab.file +echo 'foo' > b.file +assert "LC_ALL=da_DK.UTF-8 rexreplace 'foo' 'x' b.file aab.file --sort natural --simulate --name-delimiter ','" 'aab.file,b.file,' + # --full-path prints the full path of each name reset diff --git a/test/fix/820.test.js b/test/fix/820.test.js new file mode 100644 index 00000000..a4b4f512 --- /dev/null +++ b/test/fix/820.test.js @@ -0,0 +1,42 @@ +// https://github.com/mathiasrw/rexreplace/issues/820 +// Names that natural sees as the same kept the order of the glob, which differs between Node and Bun, +// and natural followed the locale of the user on Node, but not on Bun. The locale is checked in test/cli/run.sh. +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {rr} from '../helper.js'; + +async function sorted(names, config = {}) { + names.forEach(name => fs.writeFileSync(name, 'foo')); + const result = await rr('foo', 'bar', { + includeGlob: ['*.md'], + simulate: true, + sort: 'natural', + ...config, + }); + return result.out.split('\n').filter(Boolean); +} + +describe('#820 the natural order', () => { + test.each([ + ['numbers as numbers', ['file10.md', 'file9.md'], ['file9.md', 'file10.md']], + ['case ignored', ['B.md', 'a.md'], ['a.md', 'B.md']], + ['accents ignored', ['cafe2.md', 'café1.md'], ['café1.md', 'cafe2.md']], + ['aa as two a, like in English', ['b.md', 'aab.md'], ['aab.md', 'b.md']], + [ + 'ascii for the names it sees as the same', + ['file1.md', 'file01.md', 'file001.md', 'cafe.md', 'café.md'], + ['cafe.md', 'café.md', 'file001.md', 'file01.md', 'file1.md'], + ], + ])('has %s', async (name, names, order) => { + expect(await sorted(names)).toEqual(order); + }); + + test('is turned around with --sort-reverse, also for the names it sees as the same', async () => { + expect(await sorted(['file1.md', 'file01.md', 'B.md', 'a.md'], {sortReverse: true})).toEqual([ + 'file1.md', + 'file01.md', + 'B.md', + 'a.md', + ]); + }); +});