From 194b4e39f2fd01573556ee52a50f4883cd91c7ba Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 23:33:56 +1100 Subject: [PATCH 1/3] Sort a file that is gone before the sort last, and do not crash --- src/engine.ts | 18 ++++++++++++++---- test/fix/819.test.js | 31 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 test/fix/819.test.js diff --git a/src/engine.ts b/src/engine.ts index 7db8b255..fd665fbb 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -1371,8 +1371,17 @@ function sortFiles(files, sort, conf) { return files.sort(ascii); } - // By a number from each file, and when two are the same, by ascii - const stats = new Map(files.map(file => [file, fs.statSync(file)])); + // By a number from each file, and when two are the same, by ascii. + // A file that can not be looked at, like one deleted since it was found, is sorted last, and is told about when it is opened. + const stats = new Map( + files.map(file => { + try { + return [file, fs.statSync(file)]; + } catch (e) { + return [file, null]; + } + }) + ); let key = { modified: stat => stat.mtimeMs, created: stat => stat.birthtimeMs, @@ -1380,14 +1389,15 @@ function sortFiles(files, sort, conf) { }[sort]; // Not all file systems keep the time a file was made. Then Node gives 0. - if ('created' === sort && [...stats.values()].some(stat => !stat.birthtimeMs)) { + if ('created' === sort && [...stats.values()].some(stat => stat && !stat.birthtimeMs)) { warn( 'The file system does not keep the time a file was made, so --sort created uses the modified time.' ); key = stat => stat.mtimeMs; } - return files.sort((a, b) => key(stats.get(a)) - key(stats.get(b)) || ascii(a, b)); + const value = file => (stats.get(file) ? key(stats.get(file)) : Infinity); + return files.sort((a, b) => value(a) - value(b) || ascii(a, b)); } // Is the path a file (true), something to glob (false), or a path that can not be looked at (null, with an error)? diff --git a/test/fix/819.test.js b/test/fix/819.test.js new file mode 100644 index 00000000..9d4816d7 --- /dev/null +++ b/test/fix/819.test.js @@ -0,0 +1,31 @@ +// https://github.com/mathiasrw/rexreplace/issues/819 +// With --sort modified, created or size, a file that was gone between the glob and the sort crashed the run with a stack trace. +import {describe, test, expect, spyOn} from 'bun:test'; +import fs from 'fs'; +import {rr} from '../helper.js'; + +describe('#819 a file that is gone before the sort', () => { + test.each([['modified'], ['created'], ['size']])( + 'is sorted last with --sort %s, and the run goes on', + async sort => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('gone.md', 'foo'); + fs.writeFileSync('z.md', 'foo'); + // The sort looks at gone.md as if it was deleted after the glob found it + const statSync = fs.statSync; + const spy = spyOn(fs, 'statSync').mockImplementation((file, options) => { + if ('gone.md' === file && !options) { + throw Object.assign(new Error('ENOENT: no such file or directory'), {code: 'ENOENT'}); + } + return statSync(file, options); + }); + try { + const result = await rr('foo', 'bar', {includeGlob: ['*.md'], sort}); + expect(result.out).toBe('a.md\nz.md\ngone.md\n'); + expect(result.exit).toBe(null); + } finally { + spy.mockRestore(); + } + } + ); +}); From bc3ef82812b7acf28932a9a78efe64b6b1901ac9 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Sat, 24 Oct 2026 00:22:50 +1100 Subject: [PATCH 2/3] Only sort a deleted file last, and give other stat errors their reason --- src/engine.ts | 22 ++++++++++-------- test/fix/819.test.js | 53 +++++++++++++++++++++++++++++++------------- 2 files changed, 50 insertions(+), 25 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index fd665fbb..f803ea8c 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -1372,16 +1372,20 @@ function sortFiles(files, sort, conf) { } // By a number from each file, and when two are the same, by ascii. - // A file that can not be looked at, like one deleted since it was found, is sorted last, and is told about when it is opened. - const stats = new Map( - files.map(file => { - try { - return [file, fs.statSync(file)]; - } catch (e) { - return [file, null]; + // A file deleted since it was found is sorted last, and is told about as not found when it is opened. + // A file that can not be looked at for another reason is an error with that reason, and is left out. + const stats = new Map(); + files = files.filter(file => { + try { + stats.set(file, fs.statSync(file)); + } catch (e) { + if ('ENOENT' !== e.code) { + return error(e.message); } - }) - ); + stats.set(file, null); + } + return true; + }); let key = { modified: stat => stat.mtimeMs, created: stat => stat.birthtimeMs, diff --git a/test/fix/819.test.js b/test/fix/819.test.js index 9d4816d7..632d1d60 100644 --- a/test/fix/819.test.js +++ b/test/fix/819.test.js @@ -4,28 +4,49 @@ import {describe, test, expect, spyOn} from 'bun:test'; import fs from 'fs'; import {rr} from '../helper.js'; -describe('#819 a file that is gone before the sort', () => { +// The sort looks at each file with statSync and no options. When it looks at the given file, the file is made to fail. +function failInSort(name, fail) { + const statSync = fs.statSync; + return spyOn(fs, 'statSync').mockImplementation((file, options) => { + if (name === file && !options) { + fail(); + } + return statSync(file, options); + }); +} + +describe('#819 a file that can not be looked at in the sort', () => { test.each([['modified'], ['created'], ['size']])( - 'is sorted last with --sort %s, and the run goes on', + 'when deleted, is sorted last with --sort %s, and told about as not found', async sort => { - fs.writeFileSync('a.md', 'foo'); - fs.writeFileSync('gone.md', 'foo'); - fs.writeFileSync('z.md', 'foo'); - // The sort looks at gone.md as if it was deleted after the glob found it - const statSync = fs.statSync; - const spy = spyOn(fs, 'statSync').mockImplementation((file, options) => { - if ('gone.md' === file && !options) { - throw Object.assign(new Error('ENOENT: no such file or directory'), {code: 'ENOENT'}); - } - return statSync(file, options); - }); + ['a.md', 'gone.md', 'z.md'].forEach(name => fs.writeFileSync(name, 'foo')); + const spy = failInSort('gone.md', () => fs.unlinkSync('gone.md')); try { - const result = await rr('foo', 'bar', {includeGlob: ['*.md'], sort}); - expect(result.out).toBe('a.md\nz.md\ngone.md\n'); - expect(result.exit).toBe(null); + expect(await rr('foo', 'bar', {includeGlob: ['*.md'], sort})).toEqual({ + out: 'a.md\nz.md\n', + err: ' ❌ File not found: gone.md\n\n', + exit: 2, + }); } finally { spy.mockRestore(); } } ); + + test('for another reason, is an error with that reason, and is left out', async () => { + ['a.md', 'b.md'].forEach(name => fs.writeFileSync(name, 'foo')); + const spy = failInSort('b.md', () => { + throw Object.assign(new Error('EACCES: permission denied'), {code: 'EACCES'}); + }); + try { + expect(await rr('foo', 'bar', {includeGlob: ['*.md'], sort: 'size'})).toEqual({ + out: 'a.md\n', + err: ' ❌ EACCES: permission denied\n\n', + exit: 2, + }); + expect(fs.readFileSync('b.md', 'utf8')).toBe('foo'); + } finally { + spy.mockRestore(); + } + }); }); From 045b6230a7e489b9ff15479864cca1b8454fa898 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Sat, 24 Oct 2026 02:30:23 +1100 Subject: [PATCH 3/3] Test --sort with deleted files in a non-ascii order, with --sort-reverse and --halt, and say the created warning shorter --- src/engine.ts | 2 +- test/fix/819.test.js | 105 +++++++++++++++++++++++++++++++------------ 2 files changed, 77 insertions(+), 30 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index f803ea8c..00929e4c 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -1395,7 +1395,7 @@ function sortFiles(files, sort, conf) { // Not all file systems keep the time a file was made. Then Node gives 0. if ('created' === sort && [...stats.values()].some(stat => stat && !stat.birthtimeMs)) { warn( - 'The file system does not keep the time a file was made, so --sort created uses the modified time.' + 'This file system does not know when files were created. Sorting via modified time instead.' ); key = stat => stat.mtimeMs; } diff --git a/test/fix/819.test.js b/test/fix/819.test.js index 632d1d60..5c0350aa 100644 --- a/test/fix/819.test.js +++ b/test/fix/819.test.js @@ -4,49 +4,96 @@ import {describe, test, expect, spyOn} from 'bun:test'; import fs from 'fs'; import {rr} from '../helper.js'; -// The sort looks at each file with statSync and no options. When it looks at the given file, the file is made to fail. -function failInSort(name, fail) { +// The sort looks at each file with statSync and no options. When it looks at one of the given files, that file is made to fail. +function failInSort(names, fail) { const statSync = fs.statSync; return spyOn(fs, 'statSync').mockImplementation((file, options) => { - if (name === file && !options) { - fail(); + if (names.includes(file) && !options) { + fail(file); } return statSync(file, options); }); } -describe('#819 a file that can not be looked at in the sort', () => { +// Each sort puts z.md before a.md, so the order is not the ascii order. The files to delete would come first. +async function makeFiles(gone) { + for (const name of [...gone, 'z.md', 'a.md']) { + fs.writeFileSync(name, 'z.md' === name ? 'foo' : 'foo foo'); + await Bun.sleep(20); + } + gone.forEach(name => fs.utimesSync(name, 500, 500)); + fs.utimesSync('z.md', 1000, 1000); + fs.utimesSync('a.md', 3000, 3000); +} + +async function sortWithGone(gone, config) { + await makeFiles(gone); + const spy = failInSort(gone, file => fs.unlinkSync(file)); + try { + return await rr('foo', 'bar', {includeGlob: ['*.md'], ...config}); + } finally { + spy.mockRestore(); + } +} + +describe('#819 a file deleted before --sort looks at it', () => { test.each([['modified'], ['created'], ['size']])( - 'when deleted, is sorted last with --sort %s, and told about as not found', + 'is told about as not found with --sort %s, and the other files are sorted', async sort => { - ['a.md', 'gone.md', 'z.md'].forEach(name => fs.writeFileSync(name, 'foo')); - const spy = failInSort('gone.md', () => fs.unlinkSync('gone.md')); + const result = await sortWithGone(['gone.md'], {sort}); + expect(result.out).toBe('z.md\na.md\n'); + expect(result.err).toEndWith(' ❌ File not found: gone.md\n\n'); + expect(result.exit).toBe(2); + } + ); + + test('is told about for each file, when more than one is deleted', async () => { + expect(await sortWithGone(['gone.md', 'gone2.md'], {sort: 'size'})).toEqual({ + out: 'z.md\na.md\n', + err: ' ❌ File not found: gone.md\n\n ❌ File not found: gone2.md\n\n', + exit: 2, + }); + }); + + test('does not change the order of the others with --sort-reverse', async () => { + expect(await sortWithGone(['gone.md'], {sort: 'size', sortReverse: true})).toEqual({ + out: 'a.md\nz.md\n', + err: ' ❌ File not found: gone.md\n\n', + exit: 2, + }); + }); + + test('stops the run with --halt, before any file is changed', async () => { + expect(await sortWithGone(['gone.md'], {sort: 'size', halt: true})).toEqual({ + out: '', + err: ' ❌ File not found: gone.md\n No further processing.\n\n', + exit: 3, + }); + expect(fs.readFileSync('a.md', 'utf8')).toBe('foo foo'); + }); +}); + +describe('#819 a file that can not be looked at in the sort for another reason', () => { + test.each([ + [false, '\n', 2, 'a.md\n'], + [true, '\n No further processing.\n', 3, ''], + ])( + 'is an error with that reason, and is left out, with --halt %s', + async (halt, end, exit, out) => { + ['a.md', 'b.md'].forEach(name => fs.writeFileSync(name, 'foo')); + const spy = failInSort(['b.md'], () => { + throw Object.assign(new Error('EACCES: permission denied'), {code: 'EACCES'}); + }); try { - expect(await rr('foo', 'bar', {includeGlob: ['*.md'], sort})).toEqual({ - out: 'a.md\nz.md\n', - err: ' ❌ File not found: gone.md\n\n', - exit: 2, + expect(await rr('foo', 'bar', {includeGlob: ['*.md'], sort: 'size', halt})).toEqual({ + out, + err: ' ❌ EACCES: permission denied' + end + '\n', + exit, }); + expect(fs.readFileSync('b.md', 'utf8')).toBe('foo'); } finally { spy.mockRestore(); } } ); - - test('for another reason, is an error with that reason, and is left out', async () => { - ['a.md', 'b.md'].forEach(name => fs.writeFileSync(name, 'foo')); - const spy = failInSort('b.md', () => { - throw Object.assign(new Error('EACCES: permission denied'), {code: 'EACCES'}); - }); - try { - expect(await rr('foo', 'bar', {includeGlob: ['*.md'], sort: 'size'})).toEqual({ - out: 'a.md\n', - err: ' ❌ EACCES: permission denied\n\n', - exit: 2, - }); - expect(fs.readFileSync('b.md', 'utf8')).toBe('foo'); - } finally { - spy.mockRestore(); - } - }); });