diff --git a/src/engine.ts b/src/engine.ts index a034e554..7db8b255 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -33,6 +33,13 @@ const re = { // The numbers of #791 that count what came before: $i, $c, $m and $M with their cousins, like $i00 or $M_ runNumbers: /\$[icmM](?![A-Za-z])/, matchNumbers: /\$[mM](?![A-Za-z])/, + // A path that is not clear as it is printed: a space at the start or the end, or a char that can not be seen, + // like a line break from echo or a zero-width space + unclearPath: /^\s|\s$|[\x00-\x1f\x7f-\x9f\u200b-\u200f\u2028-\u202e\u2060-\u2064\ufeff]/, + // The chars of unclearPath that JSON.stringify does not escape + invisibleChars: /[\x7f-\x9f\u200b-\u200f\u2028-\u202e\u2060-\u2064\ufeff]/g, + // The end of the message of a failed file call, like `, open 'nope.txt'` + fileCallInMessage: /, \w+( '.*')?$/s, }; // Only the version ends up in the bundle @@ -696,13 +703,27 @@ function getFinalReplacement(replacement, conf: any) { try { return readReplacementFile(replacement, conf); } catch (e) { - return panic('The replacement file can not be read:', replacement); + return panic('The replacement file can not be read:', pathAndReason(replacement, e)); } } return replacement; } +// The path and why it can not be read, like `nope.txt (ENOENT: no such file or directory)`. +// A path that is not clear as it is printed is shown as JSON, with each char that can not be seen escaped. +function pathAndReason(filepath, e) { + let shownPath = filepath; + if (re.unclearPath.test(filepath)) { + shownPath = JSON.stringify(filepath).replace( + re.invisibleChars, + char => '\\u' + char.charCodeAt(0).toString(16).padStart(4, '0') + ); + } + // The call and the path at the end of the message are told already + return shownPath + ' (' + e.message.replace(re.fileCallInMessage, '') + ')'; +} + // Each replacement file is read once, also when the code of --js gives the same path for many matches function readReplacementFile(filepath, conf) { conf.replacementFiles ??= new Map(); @@ -720,16 +741,16 @@ function codeReplacement(file, config, text, numbers) { if (!config.replacementFile) { return code; } - // A path that can not be read is an error once, and its matches are left as they are - const failed = new Set(); + // A path that can not be read is an error once in the run, and its matches are left as they are + config.unreadableFiles ??= new Set(); return (...args) => { const filepath = String(code(...args)); - if (!failed.has(filepath)) { + if (!config.unreadableFiles.has(filepath)) { try { return readReplacementFile(filepath, config); } catch (e) { - failed.add(filepath); - error('The replacement file can not be read:', filepath); + config.unreadableFiles.add(filepath); + error('The replacement file can not be read:', pathAndReason(filepath, e)); } } return args[0]; diff --git a/test/engine/replacement-file.test.js b/test/engine/replacement-file.test.js index 121bccd2..19abca35 100644 --- a/test/engine/replacement-file.test.js +++ b/test/engine/replacement-file.test.js @@ -59,7 +59,9 @@ describe('--replacement-file', () => { test('that can not be read stops the run before any file is changed', async () => { fs.writeFileSync('my.file', 'foo'); const result = await rr('foo', 'nope.txt', {includeGlob: ['my.file'], replacementFile: true}); - expect(result.err).toStartWith(' ❌ The replacement file can not be read: nope.txt'); + expect(result.err).toStartWith( + ' ❌ The replacement file can not be read: nope.txt (ENOENT: no such file or directory)' + ); expect(result.exit).toBe(3); expect(fs.readFileSync('my.file', 'utf8')).toBe('foo'); }); @@ -68,7 +70,7 @@ describe('--replacement-file', () => { const config = {pipedData: 'foo', replacementJs: true, replacementFile: true}; expect(await rr('o', '"nope.txt"', config)).toEqual({ out: 'foo', - err: ' ❌ The replacement file can not be read: nope.txt\n\n', + err: ' ❌ The replacement file can not be read: nope.txt (ENOENT: no such file or directory)\n\n', exit: 2, }); }); diff --git a/test/fix/818.test.js b/test/fix/818.test.js new file mode 100644 index 00000000..c4029dc5 --- /dev/null +++ b/test/fix/818.test.js @@ -0,0 +1,43 @@ +// https://github.com/mathiasrw/rexreplace/issues/818 +// A replacement file that could not be read gave no reason, and with --js the error came once for each file. +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {rr, rrStatus} from '../helper.js'; + +describe('#818 a replacement file that can not be read', () => { + test.each([ + ['is not there', 'nope.txt', 'nope.txt (ENOENT: no such file or directory)'], + ['is a folder', 'adir', 'adir (EISDIR: illegal operation on a directory)'], + [ + 'has a zero-width space', + 'no\u200bpe.txt', + '"no\\u200bpe.txt" (ENOENT: no such file or directory)', + ], + ['has a C1 char', 'no\u0085pe.txt', '"no\\u0085pe.txt" (ENOENT: no such file or directory)'], + [ + 'has a line break at the end, like from echo', + 'a.txt\n', + '"a.txt\\n" (ENOENT: no such file or directory)', + ], + [ + 'has a backslash, like a Windows path', + 'my\\dir/nope.txt', + 'my\\dir/nope.txt (ENOENT: no such file or directory)', + ], + ])('tells the reason when the path %s', async (name, filepath, text) => { + fs.mkdirSync('adir'); + fs.writeFileSync('a.txt', 'X'); + const result = await rr('X', filepath, {includeGlob: ['a.txt'], replacementFile: true}); + expect(result.err).toStartWith(' ❌ The replacement file can not be read: ' + text); + }); + + test('with --js is told once in the run, not once for each file', async () => { + fs.writeFileSync('a.txt', 'X'); + fs.writeFileSync('b.txt', 'X'); + const config = {includeGlob: ['*.txt'], replacementJs: true, replacementFile: true}; + const status = await rrStatus('X', '"nope.txt"', config); + expect(status.errors).toEqual([ + 'The replacement file can not be read: nope.txt (ENOENT: no such file or directory)', + ]); + }); +});