diff --git a/src/engine.ts b/src/engine.ts index 7db8b255..00929e4c 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -1371,8 +1371,21 @@ 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 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, @@ -1380,14 +1393,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.' + 'This file system does not know when files were created. Sorting via modified time instead.' ); 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..5c0350aa --- /dev/null +++ b/test/fix/819.test.js @@ -0,0 +1,99 @@ +// 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'; + +// 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 (names.includes(file) && !options) { + fail(file); + } + return statSync(file, options); + }); +} + +// 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']])( + 'is told about as not found with --sort %s, and the other files are sorted', + async sort => { + 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: 'size', halt})).toEqual({ + out, + err: ' ❌ EACCES: permission denied' + end + '\n', + exit, + }); + expect(fs.readFileSync('b.md', 'utf8')).toBe('foo'); + } finally { + spy.mockRestore(); + } + } + ); +});