From 7cad08227e9a2cb947f707a156014cf1badceb6c Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 23:32:14 +1100 Subject: [PATCH 1/4] Tell why a replacement file can not be read, and only once --- src/engine.ts | 17 +++++++++++------ test/engine/replacement-file.test.js | 4 ++-- test/fix/818.test.js | 26 ++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 test/fix/818.test.js diff --git a/src/engine.ts b/src/engine.ts index a034e554..b4d823d1 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -696,13 +696,18 @@ 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:', unreadable(replacement, e)); } } return replacement; } +// The path as JSON, so a line break or a space at the end can be seen, and the reason, like ENOENT +function unreadable(filepath, e) { + return JSON.stringify(filepath) + ' (' + (e.code || e.message) + ')'; +} + // 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 +725,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:', unreadable(filepath, e)); } } return args[0]; diff --git a/test/engine/replacement-file.test.js b/test/engine/replacement-file.test.js index 121bccd2..9d3c9854 100644 --- a/test/engine/replacement-file.test.js +++ b/test/engine/replacement-file.test.js @@ -59,7 +59,7 @@ 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)'); expect(result.exit).toBe(3); expect(fs.readFileSync('my.file', 'utf8')).toBe('foo'); }); @@ -68,7 +68,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)\n\n', exit: 2, }); }); diff --git a/test/fix/818.test.js b/test/fix/818.test.js new file mode 100644 index 00000000..160ca3a6 --- /dev/null +++ b/test/fix/818.test.js @@ -0,0 +1,26 @@ +// 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)'], + ['is a folder', 'adir', '"adir" (EISDIR)'], + ['has a line break at the end, like from echo', 'a.txt\n', '"a.txt\\n" (ENOENT)'], + ])('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)']); + }); +}); From b7eb570b6645ecbdba0d43fb4c3b541073384791 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Sat, 24 Oct 2026 00:21:08 +1100 Subject: [PATCH 2/4] Show the reason in words, and the path as it is unless it hides a char --- src/engine.ts | 8 ++++++-- test/engine/replacement-file.test.js | 6 ++++-- test/fix/818.test.js | 19 +++++++++++++++---- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index b4d823d1..d09771e2 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -703,9 +703,13 @@ function getFinalReplacement(replacement, conf: any) { return replacement; } -// The path as JSON, so a line break or a space at the end can be seen, and the reason, like ENOENT +// The path as it is, and the reason, like `nope.txt (ENOENT: no such file or directory)`. +// A path that starts or ends with a space, or has a control char, like a line break from echo, is shown as JSON, so it can be seen. function unreadable(filepath, e) { - return JSON.stringify(filepath) + ' (' + (e.code || e.message) + ')'; + const shown = /^\s|\s$|[\x00-\x1f\x7f]/.test(filepath) ? JSON.stringify(filepath) : filepath; + // The message ends with the call and the path, like `, open 'nope.txt'`, which is already told + const reason = String(e.message || e.code).replace(/, \w+ '.*'$/s, ''); + return shown + ' (' + reason + ')'; } // Each replacement file is read once, also when the code of --js gives the same path for many matches diff --git a/test/engine/replacement-file.test.js b/test/engine/replacement-file.test.js index 9d3c9854..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" (ENOENT)'); + 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" (ENOENT)\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 index 160ca3a6..a5b97e98 100644 --- a/test/fix/818.test.js +++ b/test/fix/818.test.js @@ -6,9 +6,18 @@ 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)'], - ['is a folder', 'adir', '"adir" (EISDIR)'], - ['has a line break at the end, like from echo', 'a.txt\n', '"a.txt\\n" (ENOENT)'], + ['is not there', 'nope.txt', 'nope.txt (ENOENT: no such file or directory)'], + ['is a folder', 'adir', 'adir (EISDIR: illegal operation on a directory, read)'], + [ + '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'); @@ -21,6 +30,8 @@ describe('#818 a replacement file that can not be read', () => { 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)']); + expect(status.errors).toEqual([ + 'The replacement file can not be read: nope.txt (ENOENT: no such file or directory)', + ]); }); }); From 84c0138fe3521ddeb1d6d55dedb5ebca2e66dcb2 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Sat, 24 Oct 2026 01:24:10 +1100 Subject: [PATCH 3/4] Take off the bare call from the reason, and show chars that can not be seen in a path --- src/engine.ts | 14 +++++++++++--- test/fix/818.test.js | 8 +++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index d09771e2..88dbdf5c 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -704,11 +704,19 @@ function getFinalReplacement(replacement, conf: any) { } // The path as it is, and the reason, like `nope.txt (ENOENT: no such file or directory)`. -// A path that starts or ends with a space, or has a control char, like a line break from echo, is shown as JSON, so it can be seen. +// A path that starts or ends with a space, or has a char that can not be seen, like a line break from echo or a zero-width space, +// is shown as JSON, so the char can be seen. function unreadable(filepath, e) { - const shown = /^\s|\s$|[\x00-\x1f\x7f]/.test(filepath) ? JSON.stringify(filepath) : filepath; + const unseen = /[\x7f-\x9f\u200b-\u200f\u2028-\u202e\u2060-\u2064\ufeff]/g; + const shown = + /^\s|\s$|[\x00-\x1f]/.test(filepath) || unseen.test(filepath) + ? JSON.stringify(filepath).replace( + unseen, + char => '\\u' + char.charCodeAt(0).toString(16).padStart(4, '0') + ) + : filepath; // The message ends with the call and the path, like `, open 'nope.txt'`, which is already told - const reason = String(e.message || e.code).replace(/, \w+ '.*'$/s, ''); + const reason = String(e.message || e.code).replace(/, \w+( '.*')?$/s, ''); return shown + ' (' + reason + ')'; } diff --git a/test/fix/818.test.js b/test/fix/818.test.js index a5b97e98..c4029dc5 100644 --- a/test/fix/818.test.js +++ b/test/fix/818.test.js @@ -7,7 +7,13 @@ 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, read)'], + ['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', From e29cbb15588db1fab50f262ab3e9b1cad4eab162 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Sat, 24 Oct 2026 02:20:58 +1100 Subject: [PATCH 4/4] Keep the regexes of the unreadable path in re, and name the helper by what it gives --- src/engine.ts | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index 88dbdf5c..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,28 +703,25 @@ function getFinalReplacement(replacement, conf: any) { try { return readReplacementFile(replacement, conf); } catch (e) { - return panic('The replacement file can not be read:', unreadable(replacement, e)); + return panic('The replacement file can not be read:', pathAndReason(replacement, e)); } } return replacement; } -// The path as it is, and the reason, like `nope.txt (ENOENT: no such file or directory)`. -// A path that starts or ends with a space, or has a char that can not be seen, like a line break from echo or a zero-width space, -// is shown as JSON, so the char can be seen. -function unreadable(filepath, e) { - const unseen = /[\x7f-\x9f\u200b-\u200f\u2028-\u202e\u2060-\u2064\ufeff]/g; - const shown = - /^\s|\s$|[\x00-\x1f]/.test(filepath) || unseen.test(filepath) - ? JSON.stringify(filepath).replace( - unseen, - char => '\\u' + char.charCodeAt(0).toString(16).padStart(4, '0') - ) - : filepath; - // The message ends with the call and the path, like `, open 'nope.txt'`, which is already told - const reason = String(e.message || e.code).replace(/, \w+( '.*')?$/s, ''); - return shown + ' (' + reason + ')'; +// 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 @@ -746,7 +750,7 @@ function codeReplacement(file, config, text, numbers) { return readReplacementFile(filepath, config); } catch (e) { config.unreadableFiles.add(filepath); - error('The replacement file can not be read:', unreadable(filepath, e)); + error('The replacement file can not be read:', pathAndReason(filepath, e)); } } return args[0];