From 918855de9c0d31d7ed3862646fdeb52fe59c0bce Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 22:03:28 +1100 Subject: [PATCH 01/28] Give a panic and not a stack trace when a path can not be looked at --- src/engine.ts | 24 +++++++++++++++++++++--- test/fix/788.test.js | 31 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 test/fix/788.test.js diff --git a/src/engine.ts b/src/engine.ts index 49c83ad9..39ca4fa2 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -1135,7 +1135,7 @@ function getFilePaths(conf) { // --exclude-glob is always globs, also for the name of a file. They are used together, and the files they find are taken away. if (excludeGlob.length) { - const excluded = new Set(globs.sync(excludeGlob, globOptions).map(file => path.resolve(file))); + const excluded = new Set(glob(excludeGlob, globOptions).map(file => path.resolve(file))); files = files.filter(file => !excluded.has(path.resolve(file))); } @@ -1200,13 +1200,31 @@ function sortFiles(files, sort, conf) { return files.sort((a, b) => key(stats.get(a)) - key(stats.get(b)) || ascii(a, b)); } +// A path that can not be looked at (like a name that is too long) is not a file, so it is globbed +function isFile(file) { + try { + return Boolean(fs.statSync(file, {throwIfNoEntry: false})?.isFile()); + } catch (e) { + return false; + } +} + +// When the file system gives an error, the files can not be known, so the run can not go on +function glob(patterns, globOptions) { + try { + return globs.sync(patterns, globOptions); + } catch (e) { + return panic('The files can not be found:', e.message); + } +} + // A path that is a file is used as it is, so a name with glob characters, like `a[1].md` or `my (1).md`, gives only that file. // The other paths are globbed together, so a pattern that starts with `!` can take files away from the other patterns. function findFiles(paths, globOptions, sort) { const files = []; const patterns = []; paths.forEach(file => { - if (fs.statSync(file, {throwIfNoEntry: false})?.isFile()) { + if (isFile(file)) { files.push(file); } else { patterns.push(file); @@ -1216,7 +1234,7 @@ function findFiles(paths, globOptions, sort) { // A file given by its name and also found by a glob is only handled once const seen = new Set(); // A glob has no order of its own, so with --sort none its files come after the paths, sorted with natural - const found = patterns.length ? globs.sync(patterns, globOptions) : []; + const found = patterns.length ? glob(patterns, globOptions) : []; return files.concat('none' === sort ? found.sort(natural) : found).filter(file => { const fullPath = path.resolve(file); if (seen.has(fullPath)) { diff --git a/test/fix/788.test.js b/test/fix/788.test.js new file mode 100644 index 00000000..b572225c --- /dev/null +++ b/test/fix/788.test.js @@ -0,0 +1,31 @@ +// https://github.com/mathiasrw/rexreplace/issues/788 +// A path the file system could not look at, like a name that is too long, crashed the run with a stack trace. +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {rr} from '../helper.js'; + +describe('#788 a path that can not be looked at', () => { + test('is not a file, so it is globbed', async () => { + fs.writeFileSync('a.md', 'foo'); + const long = `{a,${'x'.repeat(300)}}.md`; + expect(await rr('foo', 'bar', {includeGlob: [long]})).toEqual({ + out: 'a.md\n', + err: '', + exit: null, + }); + expect(fs.readFileSync('a.md', 'utf8')).toBe('bar'); + }); + + test.each([ + ['as an argument', {includeGlob: ['a.md/x']}], + ['from --glob-pipe', {pipedData: 'a.md/x', globPipe: true}], + ['in --exclude-glob', {includeGlob: ['a.md'], excludeGlob: ['a.md/x']}], + ])('gives a panic when the glob can not be used, %s', async (name, config) => { + fs.writeFileSync('a.md', 'foo'); + const result = await rr('foo', 'bar', config); + expect(result.err).toStartWith(' ❌ The files can not be found: ENOTDIR'); + expect(result.err).toEndWith('\n No further processing.\n\n'); + expect(result.exit).toBe(3); + expect(fs.readFileSync('a.md', 'utf8')).toBe('foo'); + }); +}); From e366dd68d2879781af6b87a81b1cbd9120edb757 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 22:05:23 +1100 Subject: [PATCH 02/28] Stop starting new files after a panic with --halt --- src/engine.ts | 11 +++++++++-- test/fix/789.test.js | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 test/fix/789.test.js diff --git a/src/engine.ts b/src/engine.ts index 39ca4fa2..de4447cb 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -230,10 +230,17 @@ async function run(config) { ? files.length : Math.max(16, os.availableParallelism() * 2); let next = 0; + // After a panic no worker starts a new file. A file that is already in work is finished. + let stopped = false; const worker = async () => { - while (next < files.length) { + while (next < files.length && !stopped) { const index = next++; - await openFile(files[index], config, index + 1, files.length); + try { + await openFile(files[index], config, index + 1, files.length); + } catch (e) { + stopped = true; + throw e; + } } }; diff --git a/test/fix/789.test.js b/test/fix/789.test.js new file mode 100644 index 00000000..ca8c6159 --- /dev/null +++ b/test/fix/789.test.js @@ -0,0 +1,22 @@ +// https://github.com/mathiasrw/rexreplace/issues/789 +// With --halt and the async flow, the engine threw the panic, but the workers went on taking new files. +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import os from 'os'; +import {rr} from '../helper.js'; + +const workers = Math.max(16, os.availableParallelism() * 2); + +describe('#789 a panic with --halt', () => { + test('stops the workers from starting new files', async () => { + const names = Array.from({length: workers * 10}, (_, i) => `${String(i).padStart(4, '0')}.txt`); + names.forEach(name => fs.writeFileSync(name, 'foo')); + fs.chmodSync(names[0], 0); + const result = await rr('foo', 'bar', {includeGlob: ['*.txt'], voidAsync: false, halt: true}); + expect(result.exit).toBe(3); + // The files that were already in work when the panic came are finished, but no new file is started + await Bun.sleep(300); + const changed = names.slice(1).filter(name => 'bar' === fs.readFileSync(name, 'utf8')); + expect(changed.length).toBeLessThan(workers); + }); +}); From ff4c0e51444ac7b4601122acbbdab4e76dc1de31 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 22:07:07 +1100 Subject: [PATCH 03/28] Read the piped paths of --glob-pipe as UTF-8 --- src/cli.ts | 3 ++- test/fix/790.test.js | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 test/fix/790.test.js diff --git a/src/cli.ts b/src/cli.ts index 9d170219..bee07621 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -665,7 +665,8 @@ if (!pipeDataExpected || Boolean(process.stdin.isTTY)) { // Stays null when nothing comes through the pipe let pipeData = null; - process.stdin.setEncoding(config.encoding); + // With --glob-pipe the piped data is paths, and they are UTF-8 and not in the encoding of the files + process.stdin.setEncoding(config.globPipe ? 'utf8' : config.encoding); process.stdin.on('data', chunk => { pipeData = (pipeData ?? '') + chunk; diff --git a/test/fix/790.test.js b/test/fix/790.test.js new file mode 100644 index 00000000..c467024d --- /dev/null +++ b/test/fix/790.test.js @@ -0,0 +1,21 @@ +// https://github.com/mathiasrw/rexreplace/issues/790 +// With --glob-pipe the piped paths were decoded with --encoding, so with -e latin1 a name that is not ASCII found no file. +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {$} from 'bun'; +import {cli, sh} from '../helper.js'; + +describe('#790 the paths piped in with --glob-pipe', () => { + test.each([ + ['line breaks', 'æ.file\nb.file\n', ''], + ['null bytes', 'æ.file\0b.file\0', '--null-in'], + ])('are read as UTF-8 with -e latin1, split by %s', async (name, paths, flag) => { + fs.writeFileSync('æ.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + const result = await sh( + $`bun ${cli} foo bar -g -e latin1 -A ${flag || []} < ${new Response(paths)}` + ); + expect(result).toEqual({out: 'æ.file\nb.file\n', err: '', exit: 0}); + expect(fs.readFileSync('æ.file', 'utf8')).toBe('bar'); + }); +}); From 9b6aec073f114e73c996d4ca227cda26d96c41e8 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 23:06:58 +1100 Subject: [PATCH 04/28] Remove control characters from the new names of --rename --- README.md | 2 +- src/cli.ts | 2 +- src/engine.ts | 11 ++++++++++- test/fix/826.test.js | 24 ++++++++++++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 test/fix/826.test.js diff --git a/README.md b/README.md index efb9389c..ad9f9239 100644 --- a/README.md +++ b/README.md @@ -355,7 +355,7 @@ If planned renames clash, the process is halted and no change is applied to any | `-z` | **`--expect-diff`** Check that at least one file is changed. The exit code is 1 if not. [boolean] | | `-Z` | **`--expect-zero-diff`** Check that no file is changed. The exit code is 1 if one is. [boolean] | | | **`--expect-diff-all`** Check that all files found are changed. The exit code is 1 if not. [boolean] | -| `-r` | **`--rename`** Search and replace in the name of each file, and rename the file to the result. The folder and the content are not touched. If planned renames clash the process is halted and no change is applied to any file. [boolean] | +| `-r` | **`--rename`** Search and replace in the name of each file, and rename the file to the result. The folder and the content are not touched. If planned renames clash the process is halted and no change is applied to any file. Control characters, like a line break, are removed from the new name. [boolean] | | | **`--rename-path`** Like --rename, but on the whole path of each file, so a / in the replacement moves the file. Folders that are not there are made. [boolean] | | | **`--rename-force`** With --rename or --rename-path, write over a file that is there already. [boolean] | | `-m` | **`--output-match`** Output each match on a new line. Will not replace any content but you still need to provide a dummy value (like `_`) as replacement parameter. The replacement is not used, so --js code is not run. If search pattern does not contain matching groups the full match will be outputted. If search pattern does contain matching groups only matching groups will be outputted (same line with no delimiter). [boolean] | diff --git a/src/cli.ts b/src/cli.ts index bee07621..6d49a484 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -300,7 +300,7 @@ const yargs = require('yargs') .boolean('r') .describe( 'r', - 'Search and replace in the name of each file, and rename the file to the result. The folder and the content are not touched. If planned renames clash the process is halted and no change is applied to any file.' + 'Search and replace in the name of each file, and rename the file to the result. The folder and the content are not touched. If planned renames clash the process is halted and no change is applied to any file. Control characters, like a line break, are removed from the new name.' ) .alias('r', 'rename') diff --git a/src/engine.ts b/src/engine.ts index de4447cb..ba3419be 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -377,7 +377,7 @@ function renameFiles(files, config) { config.replacementJs && !config.outputMatch ? codeReplacement(file, config, text, fileNumbers(config, index + 1, files.length)) : config.replacement; - const result = text.replace(config.regex, replacement); + const result = withoutControlChars(text.replace(config.regex, replacement), file); countFile(config, text, result); if (result === text) { return {old: file, new: file}; @@ -418,6 +418,15 @@ function renameFiles(files, config) { }); } +// No name should have the chars below space in ASCII (like a line break, a tab or a null byte), or DEL +function withoutControlChars(name, file) { + const clean = name.replace(/[\x00-\x1f\x7f]/g, ''); + if (clean !== name) { + chat('Removed control characters from the new name of: ' + file); + } + return clean; +} + // With --simulate and --output the old name goes to stderr and the new one to stdout, so they come in pairs in a terminal function nameRename(item, config) { if (config.simulate || config.output) { diff --git a/test/fix/826.test.js b/test/fix/826.test.js new file mode 100644 index 00000000..36989218 --- /dev/null +++ b/test/fix/826.test.js @@ -0,0 +1,24 @@ +// https://github.com/mathiasrw/rexreplace/issues/826 +// --rename put control characters into the new name, like the line break at the end of a replacement file. +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {rr} from '../helper.js'; + +describe('#826 control characters in a new name', () => { + test('are removed, like the line break at the end of a replacement file', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('name.txt', 'b\n'); + const config = {includeGlob: ['a.md'], rename: true, replacementFile: true, verbose: true}; + const result = await rr('^a', 'name.txt', config); + expect(result.out).toBe('b.md\n'); + expect(result.err).toContain('Removed control characters from the new name of: a.md'); + expect(fs.readdirSync('.').sort()).toEqual(['b.md', 'name.txt']); + }); + + test('are removed with --rename-path too, also a tab, a null byte and DEL', async () => { + fs.writeFileSync('a.md', 'foo'); + const config = {includeGlob: ['a.md'], renamePath: true}; + expect((await rr('^a', 'b\t\0\x7f\x01c', config)).out).toBe('bc.md\n'); + expect(fs.readdirSync('.')).toEqual(['bc.md']); + }); +}); From a2c62df1dda41e22801876f61769ba7846f8ee3d Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 23:16:52 +1100 Subject: [PATCH 05/28] Give a message and not a crash when --js code throws something that is not an error --- src/engine.ts | 21 ++++++++++++++++++++- test/fix/822.test.js | 22 ++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 test/fix/822.test.js diff --git a/src/engine.ts b/src/engine.ts index ba3419be..54f9ddd8 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -819,6 +819,25 @@ function numbersCode(numbers) { .join(''); } +// The text of what code threw. Code can throw anything, also a value that can not be made into text. +function thrownText(thrown) { + let text = ''; + try { + text = String(thrown); + } catch (e) {} + if (text && '[object Object]' !== text) { + return text; + } + if ('object' === typeof thrown && null !== thrown) { + let json = ''; + try { + json = JSON.stringify(thrown); + } catch (e) {} + return 'an object was thrown' + (json && '{}' !== json ? ': ' + json : ''); + } + return 'an empty ' + typeof thrown + ' was thrown'; +} + function dynamicReplacement( _file_rr, _config_rr, @@ -927,7 +946,7 @@ function dynamicReplacement( return dynamicContent(...args); } catch (e) { const where = _config_rr.dataIsPiped ? 'the piped data' : _file_rr; - return panic('The code of --js failed for ' + where + ':', String(e)); + return panic('The code of --js failed for ' + where + ':', thrownText(e)); } }; diff --git a/test/fix/822.test.js b/test/fix/822.test.js new file mode 100644 index 00000000..27f047d6 --- /dev/null +++ b/test/fix/822.test.js @@ -0,0 +1,22 @@ +// https://github.com/mathiasrw/rexreplace/issues/822 +// --js code that threw something that is not an error crashed the error handling, or gave an empty message. +import {describe, test, expect} from 'bun:test'; +import {rr} from '../helper.js'; + +const failed = text => + ` ❌ The code of --js failed for the piped data: ${text}\n No further processing.\n\n`; + +describe('#822 code that throws something that is not an error', () => { + test.each([ + ['throw Object.create(null)', 'an object was thrown'], + ['throw {code: 42}', 'an object was thrown: {"code":42}'], + ['throw ""', 'an empty string was thrown'], + ['throw 7', '7'], + ])('%s gives a message, and not a crash', async (code, text) => { + expect(await rr('o', code, {pipedData: 'foo', replacementJs: true})).toEqual({ + out: '', + err: failed(text), + exit: 3, + }); + }); +}); From 8be509baacc61bb5995a6cac2686b9fa062a94d5 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Sat, 24 Oct 2026 00:11:38 +1100 Subject: [PATCH 06/28] Name a thrown error whose message can not be read, and cut a big thrown object short --- src/engine.ts | 12 +++++++++++- test/fix/822.test.js | 13 +++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/engine.ts b/src/engine.ts index 54f9ddd8..d5725ccc 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -829,11 +829,21 @@ function thrownText(thrown) { return text; } if ('object' === typeof thrown && null !== thrown) { + // Like an Error whose message can not be read: its name is still known + let name = ''; + try { + name = thrown.constructor?.name; + } catch (e) {} + if (name && 'Object' !== name) { + return 'a ' + name + ' was thrown'; + } let json = ''; try { json = JSON.stringify(thrown); } catch (e) {} - return 'an object was thrown' + (json && '{}' !== json ? ': ' + json : ''); + // The object can hold a whole file, so only the start of it is told + const start = json.length > 300 ? json.slice(0, 300) + '…' : json; + return 'an object was thrown' + (json && '{}' !== json ? ': ' + start : ''); } return 'an empty ' + typeof thrown + ' was thrown'; } diff --git a/test/fix/822.test.js b/test/fix/822.test.js index 27f047d6..cfa1b874 100644 --- a/test/fix/822.test.js +++ b/test/fix/822.test.js @@ -12,6 +12,10 @@ describe('#822 code that throws something that is not an error', () => { ['throw {code: 42}', 'an object was thrown: {"code":42}'], ['throw ""', 'an empty string was thrown'], ['throw 7', '7'], + [ + 'const e = new TypeError("x"); Object.defineProperty(e, "message", {get() { throw 1; }}); throw e', + 'a TypeError was thrown', + ], ])('%s gives a message, and not a crash', async (code, text) => { expect(await rr('o', code, {pipedData: 'foo', replacementJs: true})).toEqual({ out: '', @@ -19,4 +23,13 @@ describe('#822 code that throws something that is not an error', () => { exit: 3, }); }); + + test('only tells the start of a big object', async () => { + const result = await rr('o', 'throw {text: "y".repeat(1000)}', { + pipedData: 'foo', + replacementJs: true, + }); + expect(result.err).toContain('an object was thrown: {"text":"' + 'y'.repeat(291) + '…'); + expect(result.err.length).toBeLessThan(400); + }); }); From eab2c2b8e9256aacc26e4d0fd6997e8f2dce42f3 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 23:20:44 +1100 Subject: [PATCH 07/28] Tell where --js code failed in a module with --verbose --- src/engine.ts | 31 ++++++++++++++++++++++++++++++- test/fix/824.test.js | 22 ++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 test/fix/824.test.js diff --git a/src/engine.ts b/src/engine.ts index d5725ccc..87df4410 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -819,6 +819,30 @@ function numbersCode(numbers) { .join(''); } +// A place in a stack, like `at fn (/path/file.js:2:15)`, also with `file://` in front +const stackPlaces = /\(?(?:file:\/\/)?((?:\/|[A-Za-z]:\\)[^()]+?)(:\d+:\d+)\)?\s*$/gm; + +// The folder of RexReplace itself, from the first place in a stack made here +const ownFolder = (() => { + const [place] = String(new Error().stack).matchAll(stackPlaces); + return place ? path.dirname(place[1]) + path.sep : null; +})(); + +// The first place in the stack of an error that is in a file of the user, like `helper.js:2:15`. +// Only the places above the code of RexReplace are from the code of the user, so the search stops there. +function stackPlace(thrown) { + const stack = 'object' === typeof thrown && null !== thrown ? thrown.stack : ''; + for (const [, file, place] of String(stack ?? '').matchAll(stackPlaces)) { + if (ownFolder && file.startsWith(ownFolder)) { + return ''; + } + if (!file.includes('node:')) { + return path.relative(process.cwd(), file) + place; + } + } + return ''; +} + // The text of what code threw. Code can throw anything, also a value that can not be made into text. function thrownText(thrown) { let text = ''; @@ -956,7 +980,12 @@ function dynamicReplacement( return dynamicContent(...args); } catch (e) { const where = _config_rr.dataIsPiped ? 'the piped data' : _file_rr; - return panic('The code of --js failed for ' + where + ':', thrownText(e)); + // With --verbose it also tells where the code failed, when that is in a file of the user, like a module loaded with r() + const at = _config_rr.verbose ? stackPlace(e) : ''; + return panic( + 'The code of --js failed for ' + where + ':', + thrownText(e) + (at ? '\n at ' + at : '') + ); } }; diff --git a/test/fix/824.test.js b/test/fix/824.test.js new file mode 100644 index 00000000..02f74383 --- /dev/null +++ b/test/fix/824.test.js @@ -0,0 +1,22 @@ +// https://github.com/mathiasrw/rexreplace/issues/824 +// Since #808 --js code that failed only gave the message, also with --verbose, so a module loaded with r() did not tell where. +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {rr} from '../helper.js'; + +describe('#824 --js code that fails in a module', () => { + test('tells the file and the line with --verbose', async () => { + fs.writeFileSync('helper.js', 'module.exports = value => {\n\treturn value.nope.deeper;\n};\n'); + const config = {pipedData: 'foo', replacementJs: true, verbose: true}; + const result = await rr('o', 'r("helper")($0)', config); + expect(result.err).toMatch(/\n {4}at helper\.js:2:\d+\n {4}No further processing\.\n/); + expect(result.exit).toBe(3); + }); + + test('does not tell where without --verbose, or when the code in the command fails', async () => { + fs.writeFileSync('helper.js', 'module.exports = value => {\n\treturn value.nope.deeper;\n};\n'); + const config = {pipedData: 'foo', replacementJs: true}; + expect((await rr('o', 'r("helper")($0)', config)).err).not.toContain(' at '); + expect((await rr('o', 'nope()', {...config, verbose: true})).err).not.toContain(' at '); + }); +}); From e22597762c09fb3f4dceabf2a3bb527a03b1b99d Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Sat, 24 Oct 2026 00:13:46 +1100 Subject: [PATCH 08/28] Read each line of the stack, skip what is not the code of the user, and never crash on it --- src/engine.ts | 34 ++++++++++++++++++++++------------ test/fix/824.test.js | 26 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index 87df4410..d6945541 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -819,27 +819,37 @@ function numbersCode(numbers) { .join(''); } -// A place in a stack, like `at fn (/path/file.js:2:15)`, also with `file://` in front -const stackPlaces = /\(?(?:file:\/\/)?((?:\/|[A-Za-z]:\\)[^()]+?)(:\d+:\d+)\)?\s*$/gm; +// The places in a stack, one for each line, like `at fn (/path/file.js:2:15)` or `at /path/file.js:2:15`. +// The place is at the end of the line, so a path with parentheses is read as a whole. +function stackPlaces(stack) { + return String(stack) + .split('\n') + .map(line => line.match(/^\s*at (?:.*? \()?(?:file:\/\/)?(.+?)(:\d+:\d+)\)?\s*$/)) + .filter(Boolean) + .map(([, file, place]) => ({file, place})); +} // The folder of RexReplace itself, from the first place in a stack made here const ownFolder = (() => { - const [place] = String(new Error().stack).matchAll(stackPlaces); - return place ? path.dirname(place[1]) + path.sep : null; + const [own] = stackPlaces(new Error().stack); + return own ? path.dirname(own.file) + path.sep : null; })(); // The first place in the stack of an error that is in a file of the user, like `helper.js:2:15`. // Only the places above the code of RexReplace are from the code of the user, so the search stops there. +// Places in Node itself, in eval and in node_modules are skipped. Reading the stack can throw, so it is tried. function stackPlace(thrown) { - const stack = 'object' === typeof thrown && null !== thrown ? thrown.stack : ''; - for (const [, file, place] of String(stack ?? '').matchAll(stackPlaces)) { - if (ownFolder && file.startsWith(ownFolder)) { - return ''; - } - if (!file.includes('node:')) { - return path.relative(process.cwd(), file) + place; + try { + for (const {file, place} of stackPlaces(thrown?.stack ?? '')) { + if (ownFolder && file.startsWith(ownFolder)) { + return ''; + } + // A file is an absolute path, so the frames of code run with eval are skipped too + if (/^(\/|[A-Za-z]:[\\/])/.test(file) && !file.includes('node_modules')) { + return path.relative(process.cwd(), file) + place; + } } - } + } catch (e) {} return ''; } diff --git a/test/fix/824.test.js b/test/fix/824.test.js index 02f74383..ae2d4bc9 100644 --- a/test/fix/824.test.js +++ b/test/fix/824.test.js @@ -19,4 +19,30 @@ describe('#824 --js code that fails in a module', () => { expect((await rr('o', 'r("helper")($0)', config)).err).not.toContain(' at '); expect((await rr('o', 'nope()', {...config, verbose: true})).err).not.toContain(' at '); }); + + test('reads a path with parentheses as a whole', async () => { + fs.mkdirSync('a (copy)'); + fs.writeFileSync( + 'a (copy)/helper.js', + 'module.exports = value => {\n\treturn value.nope.deeper;\n};\n' + ); + const config = {pipedData: 'foo', replacementJs: true, verbose: true}; + expect((await rr('o', 'r("a (copy)/helper.js")($0)', config)).err).toMatch( + /\n {4}at a \(copy\)\/helper\.js:2:\d+\n/ + ); + }); + + test('does not tell places in the runtime itself', async () => { + const config = {pipedData: 'foo', replacementJs: true, verbose: true}; + expect((await rr('o', 'new URL("x")', config)).err).not.toContain(' at '); + }); + + test('does not crash when the stack can not be read', async () => { + const config = {pipedData: 'foo', replacementJs: true, verbose: true}; + const result = await rr('o', 'throw new Proxy({}, {get() { throw 1; }})', config); + expect(result.err).toContain( + 'The code of --js failed for the piped data: an object was thrown' + ); + expect(result.exit).toBe(3); + }); }); From 2ad9cd282d30f5a2350aed5e5819a297bf5aafca Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 23:23:04 +1100 Subject: [PATCH 09/28] Only make the --js numbers the code uses, and only count files for $i and $c --- src/engine.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index d6945541..bbf5edb6 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -132,6 +132,9 @@ async function run(config) { config.replacementOri = config.replacement; + // $i and $c need one more search of each file, so the files are only counted when the code uses them + config.countFiles = config.replacementJs && /\$[ic](?![A-Za-z])/.test(config.replacementOri); + // The most specific delimiter wins. Without one there is a line break after each item, unless it already ends with one. const delimiter = config.delimiter ?? (config.nullOut ? '\0' : null); config.nameDelimiter = config.nameDelimiter ?? delimiter; @@ -784,8 +787,12 @@ function fileNumbers(config, number, total) { return {n: number, t: total, i: config.numbers.matched + 1, c: config.numbers.changed + 1}; } -// When a file is done, it counts for $i if the pattern found something, and for $c if it was changed +// When a file is done, it counts for $i if the pattern found something, and for $c if it was changed. +// It takes one more search of the file, so it is only done when the code uses $i or $c. function countFile(config, text, result) { + if (!config.countFiles) { + return; + } if (-1 !== text.search(config.regex)) { config.numbers.matched++; } @@ -805,8 +812,10 @@ function usesNamedGroups(config) { // Code that makes the numbers and their cousins, put in front of the code of --js. // $n0 is $n with zeros in front to 2 digits, $n00 to 3 and so on to 10, and the ones with _ at the end have a space at the end. -function numbersCode(numbers) { +// Only the numbers the code uses are made, as each one costs time when the code runs once per match. +function numbersCode(numbers, code) { return Object.entries(numbers) + .filter(([name]) => new RegExp('\\$' + name + '(?![A-Za-z])').test(code)) .map(([name, value]) => { const cousins = [`$${name} = ${value}`, `$${name}_ = '${value} '`]; for (let width = 2; width <= 10; width++) { @@ -893,7 +902,7 @@ function dynamicReplacement( const _pipe = _config_rr.pipedData, _text = _data_rr, _find = _config_rr.pattern, - code_rr = numbersCode(_numbers_rr) + _config_rr.replacementOri, + code_rr = numbersCode(_numbers_rr, _config_rr.replacementOri) + _config_rr.replacementOri, _cwd = process.cwd(), _now = _time, _ = ' ', @@ -1122,7 +1131,10 @@ function dynamicReplacement( _match_rr++; _config_rr.numbers.matches++; - var capturedGroups = numbersCode({m: _match_rr, M: _config_rr.numbers.matches}); + var capturedGroups = numbersCode( + {m: _match_rr, M: _config_rr.numbers.matches}, + _config_rr.replacementOri + ); // The match and the groups are followed by the offset and the full text, and by the named groups if the pattern has any const named = arguments[arguments.length - 1]; const hasNamed = 'object' === typeof named && null !== named; From 71b1a5141414b6394eedc0a673858769fd3c3c73 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Sat, 24 Oct 2026 00:15:17 +1100 Subject: [PATCH 10/28] Tell in the help that only the numbers the code names are made --- README.md | 2 +- src/cli.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ad9f9239..0d9ac876 100644 --- a/README.md +++ b/README.md @@ -366,7 +366,7 @@ If planned renames clash, the process is halted and no change is applied to any | `-g` | **`--glob-pipe`** Each line of the piped data is a path or a glob of the files to work on. They are used together with the ones given as arguments. [boolean] | | | **`--null-in`** The piped paths and globs of --glob-pipe are separated by null bytes, like from `find -print0` or --null-out. [boolean] | | `-R` | **`--replacement-pipe`** Replacement will be piped in. You still need to provide a dummy value (like `_`) as replacement parameter. [boolean] | -| `-j` | **`--replacement-js`**, **`--js`** Treat replacement as javascript source code. The statement from the last expression will become the replacement string. Purposefully implemented the most insecure way possible to remove _any_ incentive to consider running code from an untrusted part. The full match will be available as a javascript variable named $0 while each captured group will be available as $1, $2, $3, ... and so on. At some point, the $ char _will_ give you a headache when used from the command line, so use €0, €1, €2, €3... instead. If the javascript source code references to the full match or a captured group the code will run once per match. Otherwise, it will run once per file. The code has access to the following variables: `r` as an alias for `require` with both expanded to understand a relative path even if it is not starting with `./`, `fs` from node, `path` from node, `globs` from npm, `pipe`: the data piped into the command with --js-pipe (null if no piped data, and `pipe_` is `❌ `), `find`: pattern searched for (the needle), `text`: full text being searched i.e. file content or piped data (the haystack), `bytes`: total size of the haystack in bytes, `size`: human-friendly representation of the total size of the haystack, `time`: String representing the local time when the command was invoked, `time_obj`: date object representing `time`, `now`: alias for `time`, `cwd`: current process working dir, `nl`: a new-line char, `_`: a single space char (for easy string concatenation). The following values defaults to `❌` if haystack does not originate from a file: `file`: contains the full path of the active file being searched (including full filename), `file_rel`: contains `file` relative to current process working dir, `dirpath`: contains the full path without filename of the active file being searched, `dirpath_rel`: contains `dirpath` relative to current process working dir, `filename`: is the full filename of the active file being searched without path, `name`: filename of the active file being searched with no extension, `ext`: extension of the filename including leading dot, `mtime`: ISO inspired representation of the last local modification time of the current file, `ctime`: ISO representation of the local creation time of the current file. `mtime_obj`: date object representing `mtime`, `ctime_obj`: date object representing `ctime`. The numbers: `$n`: the number of the file, `$t`: the number of files, `$i`: the number of the file among the files with a match so far, `$c`: among the files changed so far, `$m`: the number of the match in the file, `$M`: across all files. Each has cousins with zeros in front (`$n0` has 2 digits, `$n00` 3, and so on to 10) and with a space at the end (`$n_`). A named group is a variable with `_` in front: `_year` for `(?...)`, and `_year_` has a space at the end. All variables, except from module, date objects, `nl` and `_`, has a corresponding variable name followed by `_` where the content has an extra space at the end (for easy concatenation). [boolean] | +| `-j` | **`--replacement-js`**, **`--js`** Treat replacement as javascript source code. The statement from the last expression will become the replacement string. Purposefully implemented the most insecure way possible to remove _any_ incentive to consider running code from an untrusted part. The full match will be available as a javascript variable named $0 while each captured group will be available as $1, $2, $3, ... and so on. At some point, the $ char _will_ give you a headache when used from the command line, so use €0, €1, €2, €3... instead. If the javascript source code references to the full match or a captured group the code will run once per match. Otherwise, it will run once per file. The code has access to the following variables: `r` as an alias for `require` with both expanded to understand a relative path even if it is not starting with `./`, `fs` from node, `path` from node, `globs` from npm, `pipe`: the data piped into the command with --js-pipe (null if no piped data, and `pipe_` is `❌ `), `find`: pattern searched for (the needle), `text`: full text being searched i.e. file content or piped data (the haystack), `bytes`: total size of the haystack in bytes, `size`: human-friendly representation of the total size of the haystack, `time`: String representing the local time when the command was invoked, `time_obj`: date object representing `time`, `now`: alias for `time`, `cwd`: current process working dir, `nl`: a new-line char, `_`: a single space char (for easy string concatenation). The following values defaults to `❌` if haystack does not originate from a file: `file`: contains the full path of the active file being searched (including full filename), `file_rel`: contains `file` relative to current process working dir, `dirpath`: contains the full path without filename of the active file being searched, `dirpath_rel`: contains `dirpath` relative to current process working dir, `filename`: is the full filename of the active file being searched without path, `name`: filename of the active file being searched with no extension, `ext`: extension of the filename including leading dot, `mtime`: ISO inspired representation of the last local modification time of the current file, `ctime`: ISO representation of the local creation time of the current file. `mtime_obj`: date object representing `mtime`, `ctime_obj`: date object representing `ctime`. The numbers: `$n`: the number of the file, `$t`: the number of files, `$i`: the number of the file among the files with a match so far, `$c`: among the files changed so far, `$m`: the number of the match in the file, `$M`: across all files. Each has cousins with zeros in front (`$n0` has 2 digits, `$n00` 3, and so on to 10) and with a space at the end (`$n_`). A number is only made when the code names it, so a name built when the code runs, like `eval('$' + 'n')`, is not there. A named group is a variable with `_` in front: `_year` for `(?...)`, and `_year_` has a space at the end. All variables, except from module, date objects, `nl` and `_`, has a corresponding variable name followed by `_` where the content has an extra space at the end (for easy concatenation). [boolean] | | `-x` | **`--exclude-re`** Files with a path that matches this regular expression will be excluded from processing. Can be provided multiple times. [string] | | `-X` | **`--exclude-glob`** Files found with this glob will be excluded from being processed. Can be provided multiple times and will act as one group of globs, meaning you can use ! notation to negate. [string] | | `-h` | **`--help`** Display help. [boolean] | diff --git a/src/cli.ts b/src/cli.ts index 6d49a484..5dd977a2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -409,7 +409,7 @@ The following values defaults to \`❌\` if haystack does not originate from a f \`mtime_obj\`: date object representing \`mtime\`, \`ctime_obj\`: date object representing \`ctime\`. -The numbers: \`$n\`: the number of the file, \`$t\`: the number of files, \`$i\`: the number of the file among the files with a match so far, \`$c\`: among the files changed so far, \`$m\`: the number of the match in the file, \`$M\`: across all files. Each has cousins with zeros in front (\`$n0\` has 2 digits, \`$n00\` 3, and so on to 10) and with a space at the end (\`$n_\`). +The numbers: \`$n\`: the number of the file, \`$t\`: the number of files, \`$i\`: the number of the file among the files with a match so far, \`$c\`: among the files changed so far, \`$m\`: the number of the match in the file, \`$M\`: across all files. Each has cousins with zeros in front (\`$n0\` has 2 digits, \`$n00\` 3, and so on to 10) and with a space at the end (\`$n_\`). A number is only made when the code names it, so a name built when the code runs, like \`eval('$' + 'n')\`, is not there. A named group is a variable with \`_\` in front: \`_year\` for \`(?...)\`, and \`_year_\` has a space at the end. From 1b890fe89ddf344214fa9c1adf7dd1e7bc7b6502 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Sat, 24 Oct 2026 00:26:25 +1100 Subject: [PATCH 11/28] Leave the README line of --js to #838, so the stack can be rebased without a conflict --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0d9ac876..ad9f9239 100644 --- a/README.md +++ b/README.md @@ -366,7 +366,7 @@ If planned renames clash, the process is halted and no change is applied to any | `-g` | **`--glob-pipe`** Each line of the piped data is a path or a glob of the files to work on. They are used together with the ones given as arguments. [boolean] | | | **`--null-in`** The piped paths and globs of --glob-pipe are separated by null bytes, like from `find -print0` or --null-out. [boolean] | | `-R` | **`--replacement-pipe`** Replacement will be piped in. You still need to provide a dummy value (like `_`) as replacement parameter. [boolean] | -| `-j` | **`--replacement-js`**, **`--js`** Treat replacement as javascript source code. The statement from the last expression will become the replacement string. Purposefully implemented the most insecure way possible to remove _any_ incentive to consider running code from an untrusted part. The full match will be available as a javascript variable named $0 while each captured group will be available as $1, $2, $3, ... and so on. At some point, the $ char _will_ give you a headache when used from the command line, so use €0, €1, €2, €3... instead. If the javascript source code references to the full match or a captured group the code will run once per match. Otherwise, it will run once per file. The code has access to the following variables: `r` as an alias for `require` with both expanded to understand a relative path even if it is not starting with `./`, `fs` from node, `path` from node, `globs` from npm, `pipe`: the data piped into the command with --js-pipe (null if no piped data, and `pipe_` is `❌ `), `find`: pattern searched for (the needle), `text`: full text being searched i.e. file content or piped data (the haystack), `bytes`: total size of the haystack in bytes, `size`: human-friendly representation of the total size of the haystack, `time`: String representing the local time when the command was invoked, `time_obj`: date object representing `time`, `now`: alias for `time`, `cwd`: current process working dir, `nl`: a new-line char, `_`: a single space char (for easy string concatenation). The following values defaults to `❌` if haystack does not originate from a file: `file`: contains the full path of the active file being searched (including full filename), `file_rel`: contains `file` relative to current process working dir, `dirpath`: contains the full path without filename of the active file being searched, `dirpath_rel`: contains `dirpath` relative to current process working dir, `filename`: is the full filename of the active file being searched without path, `name`: filename of the active file being searched with no extension, `ext`: extension of the filename including leading dot, `mtime`: ISO inspired representation of the last local modification time of the current file, `ctime`: ISO representation of the local creation time of the current file. `mtime_obj`: date object representing `mtime`, `ctime_obj`: date object representing `ctime`. The numbers: `$n`: the number of the file, `$t`: the number of files, `$i`: the number of the file among the files with a match so far, `$c`: among the files changed so far, `$m`: the number of the match in the file, `$M`: across all files. Each has cousins with zeros in front (`$n0` has 2 digits, `$n00` 3, and so on to 10) and with a space at the end (`$n_`). A number is only made when the code names it, so a name built when the code runs, like `eval('$' + 'n')`, is not there. A named group is a variable with `_` in front: `_year` for `(?...)`, and `_year_` has a space at the end. All variables, except from module, date objects, `nl` and `_`, has a corresponding variable name followed by `_` where the content has an extra space at the end (for easy concatenation). [boolean] | +| `-j` | **`--replacement-js`**, **`--js`** Treat replacement as javascript source code. The statement from the last expression will become the replacement string. Purposefully implemented the most insecure way possible to remove _any_ incentive to consider running code from an untrusted part. The full match will be available as a javascript variable named $0 while each captured group will be available as $1, $2, $3, ... and so on. At some point, the $ char _will_ give you a headache when used from the command line, so use €0, €1, €2, €3... instead. If the javascript source code references to the full match or a captured group the code will run once per match. Otherwise, it will run once per file. The code has access to the following variables: `r` as an alias for `require` with both expanded to understand a relative path even if it is not starting with `./`, `fs` from node, `path` from node, `globs` from npm, `pipe`: the data piped into the command with --js-pipe (null if no piped data, and `pipe_` is `❌ `), `find`: pattern searched for (the needle), `text`: full text being searched i.e. file content or piped data (the haystack), `bytes`: total size of the haystack in bytes, `size`: human-friendly representation of the total size of the haystack, `time`: String representing the local time when the command was invoked, `time_obj`: date object representing `time`, `now`: alias for `time`, `cwd`: current process working dir, `nl`: a new-line char, `_`: a single space char (for easy string concatenation). The following values defaults to `❌` if haystack does not originate from a file: `file`: contains the full path of the active file being searched (including full filename), `file_rel`: contains `file` relative to current process working dir, `dirpath`: contains the full path without filename of the active file being searched, `dirpath_rel`: contains `dirpath` relative to current process working dir, `filename`: is the full filename of the active file being searched without path, `name`: filename of the active file being searched with no extension, `ext`: extension of the filename including leading dot, `mtime`: ISO inspired representation of the last local modification time of the current file, `ctime`: ISO representation of the local creation time of the current file. `mtime_obj`: date object representing `mtime`, `ctime_obj`: date object representing `ctime`. The numbers: `$n`: the number of the file, `$t`: the number of files, `$i`: the number of the file among the files with a match so far, `$c`: among the files changed so far, `$m`: the number of the match in the file, `$M`: across all files. Each has cousins with zeros in front (`$n0` has 2 digits, `$n00` 3, and so on to 10) and with a space at the end (`$n_`). A named group is a variable with `_` in front: `_year` for `(?...)`, and `_year_` has a space at the end. All variables, except from module, date objects, `nl` and `_`, has a corresponding variable name followed by `_` where the content has an extra space at the end (for easy concatenation). [boolean] | | `-x` | **`--exclude-re`** Files with a path that matches this regular expression will be excluded from processing. Can be provided multiple times. [string] | | `-X` | **`--exclude-glob`** Files found with this glob will be excluded from being processed. Can be provided multiple times and will act as one group of globs, meaning you can use ! notation to negate. [string] | | `-h` | **`--help`** Display help. [boolean] | From 07dbd4d23faf5e6900d6a9c882cc37d493ba1069 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 23:25:00 +1100 Subject: [PATCH 12/28] Let --js code declare the names of the numbers and groups with let and const --- src/engine.ts | 5 ++++- test/fix/815.test.js | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 test/fix/815.test.js diff --git a/src/engine.ts b/src/engine.ts index bbf5edb6..1ebacddf 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -902,7 +902,10 @@ function dynamicReplacement( const _pipe = _config_rr.pipedData, _text = _data_rr, _find = _config_rr.pattern, - code_rr = numbersCode(_numbers_rr, _config_rr.replacementOri) + _config_rr.replacementOri, + // The code of the user is in a block of its own, so it can declare the same names with let and const. + // The value of the block is the value of its last statement, like before. The line break ends a comment at the end. + code_rr = + numbersCode(_numbers_rr, _config_rr.replacementOri) + '{' + _config_rr.replacementOri + '\n}', _cwd = process.cwd(), _now = _time, _ = ' ', diff --git a/test/fix/815.test.js b/test/fix/815.test.js new file mode 100644 index 00000000..95941206 --- /dev/null +++ b/test/fix/815.test.js @@ -0,0 +1,20 @@ +// https://github.com/mathiasrw/rexreplace/issues/815 +// The numbers and the named groups were made with var in front of the code, so code that declared the same name with let or const did not run. +import {describe, test, expect} from 'bun:test'; +import {rr} from '../helper.js'; + +describe('#815 --js code can declare its own variables', () => { + test.each([ + ['a number with let', 'o', 'let $t = "T"; $t', 'fTT'], + ['a group with const', '(o)', 'const $1 = "X"; $1', 'fXX'], + ['a named group with let', '(?o)', 'let _o = "N"; _o', 'fNN'], + ])('with the name of %s', async (name, pattern, code, out) => { + expect((await rr(pattern, code, {pipedData: 'foo', replacementJs: true})).out).toBe(out); + }); + + test('the value is still the value of the last statement, also with a comment at the end', async () => { + expect( + (await rr('o', 'const a = 1; a + 1 // two', {pipedData: 'foo', replacementJs: true})).out + ).toBe('f22'); + }); +}); From f7b634f5f31194ab5d14735711d8da0bbcfe3480 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 23:26:59 +1100 Subject: [PATCH 13/28] Use the prefix rr_ for named groups in --js, and panic on a name that ends with _ --- README.md | 2 +- src/cli.ts | 2 +- src/engine.ts | 25 ++++++++++++++++++++----- test/fix/799.test.js | 8 ++++---- test/fix/815.test.js | 2 +- test/fix/825.test.js | 26 ++++++++++++++++++++++++++ 6 files changed, 53 insertions(+), 12 deletions(-) create mode 100644 test/fix/825.test.js diff --git a/README.md b/README.md index ad9f9239..d5311a9e 100644 --- a/README.md +++ b/README.md @@ -366,7 +366,7 @@ If planned renames clash, the process is halted and no change is applied to any | `-g` | **`--glob-pipe`** Each line of the piped data is a path or a glob of the files to work on. They are used together with the ones given as arguments. [boolean] | | | **`--null-in`** The piped paths and globs of --glob-pipe are separated by null bytes, like from `find -print0` or --null-out. [boolean] | | `-R` | **`--replacement-pipe`** Replacement will be piped in. You still need to provide a dummy value (like `_`) as replacement parameter. [boolean] | -| `-j` | **`--replacement-js`**, **`--js`** Treat replacement as javascript source code. The statement from the last expression will become the replacement string. Purposefully implemented the most insecure way possible to remove _any_ incentive to consider running code from an untrusted part. The full match will be available as a javascript variable named $0 while each captured group will be available as $1, $2, $3, ... and so on. At some point, the $ char _will_ give you a headache when used from the command line, so use €0, €1, €2, €3... instead. If the javascript source code references to the full match or a captured group the code will run once per match. Otherwise, it will run once per file. The code has access to the following variables: `r` as an alias for `require` with both expanded to understand a relative path even if it is not starting with `./`, `fs` from node, `path` from node, `globs` from npm, `pipe`: the data piped into the command with --js-pipe (null if no piped data, and `pipe_` is `❌ `), `find`: pattern searched for (the needle), `text`: full text being searched i.e. file content or piped data (the haystack), `bytes`: total size of the haystack in bytes, `size`: human-friendly representation of the total size of the haystack, `time`: String representing the local time when the command was invoked, `time_obj`: date object representing `time`, `now`: alias for `time`, `cwd`: current process working dir, `nl`: a new-line char, `_`: a single space char (for easy string concatenation). The following values defaults to `❌` if haystack does not originate from a file: `file`: contains the full path of the active file being searched (including full filename), `file_rel`: contains `file` relative to current process working dir, `dirpath`: contains the full path without filename of the active file being searched, `dirpath_rel`: contains `dirpath` relative to current process working dir, `filename`: is the full filename of the active file being searched without path, `name`: filename of the active file being searched with no extension, `ext`: extension of the filename including leading dot, `mtime`: ISO inspired representation of the last local modification time of the current file, `ctime`: ISO representation of the local creation time of the current file. `mtime_obj`: date object representing `mtime`, `ctime_obj`: date object representing `ctime`. The numbers: `$n`: the number of the file, `$t`: the number of files, `$i`: the number of the file among the files with a match so far, `$c`: among the files changed so far, `$m`: the number of the match in the file, `$M`: across all files. Each has cousins with zeros in front (`$n0` has 2 digits, `$n00` 3, and so on to 10) and with a space at the end (`$n_`). A named group is a variable with `_` in front: `_year` for `(?...)`, and `_year_` has a space at the end. All variables, except from module, date objects, `nl` and `_`, has a corresponding variable name followed by `_` where the content has an extra space at the end (for easy concatenation). [boolean] | +| `-j` | **`--replacement-js`**, **`--js`** Treat replacement as javascript source code. The statement from the last expression will become the replacement string. Purposefully implemented the most insecure way possible to remove _any_ incentive to consider running code from an untrusted part. The full match will be available as a javascript variable named $0 while each captured group will be available as $1, $2, $3, ... and so on. At some point, the $ char _will_ give you a headache when used from the command line, so use €0, €1, €2, €3... instead. If the javascript source code references to the full match or a captured group the code will run once per match. Otherwise, it will run once per file. The code has access to the following variables: `r` as an alias for `require` with both expanded to understand a relative path even if it is not starting with `./`, `fs` from node, `path` from node, `globs` from npm, `pipe`: the data piped into the command with --js-pipe (null if no piped data, and `pipe_` is `❌ `), `find`: pattern searched for (the needle), `text`: full text being searched i.e. file content or piped data (the haystack), `bytes`: total size of the haystack in bytes, `size`: human-friendly representation of the total size of the haystack, `time`: String representing the local time when the command was invoked, `time_obj`: date object representing `time`, `now`: alias for `time`, `cwd`: current process working dir, `nl`: a new-line char, `_`: a single space char (for easy string concatenation). The following values defaults to `❌` if haystack does not originate from a file: `file`: contains the full path of the active file being searched (including full filename), `file_rel`: contains `file` relative to current process working dir, `dirpath`: contains the full path without filename of the active file being searched, `dirpath_rel`: contains `dirpath` relative to current process working dir, `filename`: is the full filename of the active file being searched without path, `name`: filename of the active file being searched with no extension, `ext`: extension of the filename including leading dot, `mtime`: ISO inspired representation of the last local modification time of the current file, `ctime`: ISO representation of the local creation time of the current file. `mtime_obj`: date object representing `mtime`, `ctime_obj`: date object representing `ctime`. The numbers: `$n`: the number of the file, `$t`: the number of files, `$i`: the number of the file among the files with a match so far, `$c`: among the files changed so far, `$m`: the number of the match in the file, `$M`: across all files. Each has cousins with zeros in front (`$n0` has 2 digits, `$n00` 3, and so on to 10) and with a space at the end (`$n_`). A named group is a variable with `rr_` in front: `rr_year` for `(?...)`, and `rr_year_` has a space at the end. A named group can not end with `_`. All variables, except from module, date objects, `nl` and `_`, has a corresponding variable name followed by `_` where the content has an extra space at the end (for easy concatenation). [boolean] | | `-x` | **`--exclude-re`** Files with a path that matches this regular expression will be excluded from processing. Can be provided multiple times. [string] | | `-X` | **`--exclude-glob`** Files found with this glob will be excluded from being processed. Can be provided multiple times and will act as one group of globs, meaning you can use ! notation to negate. [string] | | `-h` | **`--help`** Display help. [boolean] | diff --git a/src/cli.ts b/src/cli.ts index 5dd977a2..ac0067e4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -411,7 +411,7 @@ The following values defaults to \`❌\` if haystack does not originate from a f The numbers: \`$n\`: the number of the file, \`$t\`: the number of files, \`$i\`: the number of the file among the files with a match so far, \`$c\`: among the files changed so far, \`$m\`: the number of the match in the file, \`$M\`: across all files. Each has cousins with zeros in front (\`$n0\` has 2 digits, \`$n00\` 3, and so on to 10) and with a space at the end (\`$n_\`). A number is only made when the code names it, so a name built when the code runs, like \`eval('$' + 'n')\`, is not there. -A named group is a variable with \`_\` in front: \`_year\` for \`(?...)\`, and \`_year_\` has a space at the end. +A named group is a variable with \`rr_\` in front: \`rr_year\` for \`(?...)\`, and \`rr_year_\` has a space at the end. A named group can not end with \`_\`. All variables, except from module, date objects, \`nl\` and \`_\`, has a corresponding variable name followed by \`_\` where the content has an extra space at the end (for easy concatenation). ` diff --git a/src/engine.ts b/src/engine.ts index 1ebacddf..d0ac52b7 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -145,6 +145,16 @@ async function run(config) { config.regex = getFinalRegex(config.pattern, config) || ''; + // In --js a named group is a variable like rr_year, and rr_year_ has a space at the end. + // A group that ends with _ would have the same name as another group with a space, so it can not be used. + const endsWith_ = + config.replacementJs && config.regex + ? namedGroups(config.regex).find(name => name.endsWith('_')) + : null; + if (endsWith_) { + panic('A named group can not end with _:', endsWith_); + } + // What the regex engine is asked to do. With --replacement-js and --output-match it gets a function. if (config.statusInfo.engine) { config.statusInfo.engine.execution = { @@ -801,15 +811,20 @@ function countFile(config, text, result) { } } -// The code uses a named group of the pattern, like _year for (?…) +// The code uses a named group of the pattern, like rr_year for (?…) function usesNamedGroups(config) { - return [...config.regex.source.matchAll(/\(\?<([A-Za-z_$][\w$]*)>/g)].some(([, name]) => - new RegExp('(^|[^\\w$])_' + name.replace(/\$/g, '\\$') + '(?![\\w$])').test( + return namedGroups(config.regex).some(name => + new RegExp('(^|[^\\w$])rr_' + name.replace(/\$/g, '\\$') + '(?![\\w$])').test( config.replacementOri ) ); } +// The names of the named groups of a regex, like year for (?…) +function namedGroups(regex) { + return [...regex.source.matchAll(/\(\?<([A-Za-z_$][\w$]*)>/g)].map(([, name]) => name); +} + // Code that makes the numbers and their cousins, put in front of the code of --js. // $n0 is $n with zeros in front to 2 digits, $n00 to 3 and so on to 10, and the ones with _ at the end have a space at the end. // Only the numbers the code uses are made, as each one costs time when the code runs once per match. @@ -1144,10 +1159,10 @@ function dynamicReplacement( for (var i = 0; i < arguments.length - (hasNamed ? 3 : 2); i++) { capturedGroups += 'var $' + i + '=' + JSON.stringify(arguments[i]) + '; '; } - // A named group is a variable with _ in front, like _year for (?…), and _year_ has a space at the end + // A named group is a variable with rr_ in front, like rr_year for (?…), and rr_year_ has a space at the end if (hasNamed) { for (const [name, value] of Object.entries(named)) { - capturedGroups += `var _${name} = ${JSON.stringify(value)}, _${name}_ = _${name} + ' '; `; + capturedGroups += `var rr_${name} = ${JSON.stringify(value)}, rr_${name}_ = rr_${name} + ' '; `; } } diff --git a/test/fix/799.test.js b/test/fix/799.test.js index f4f4f568..d7c4ddf8 100644 --- a/test/fix/799.test.js +++ b/test/fix/799.test.js @@ -9,16 +9,16 @@ describe('#799 groups in --js', () => { expect((await rr('(?a)(b)', '$1 + $2 + typeof $3', config)).out).toBe('abundefined'); }); - test('a named group is a variable with _ in front, and _ at the end has a space', async () => { + test('a named group is a variable with rr_ in front, and _ at the end has a space', async () => { const config = {pipedData: '2024-05 1999-12', replacementJs: true}; const pattern = '(?\\d{4})-(?\\d\\d)'; - expect((await rr(pattern, '_month + "/" + _year', config)).out).toBe('05/2024 12/1999'); - expect((await rr(pattern, '_month_ + _year', config)).out).toBe('05 2024 12 1999'); + expect((await rr(pattern, 'rr_month + "/" + rr_year', config)).out).toBe('05/2024 12/1999'); + expect((await rr(pattern, 'rr_month_ + rr_year', config)).out).toBe('05 2024 12 1999'); }); test('a named group that took no part in the match is undefined', async () => { const config = {pipedData: 'ab', replacementJs: true}; - expect((await rr('(?a)|(?b)', 'String(_x) + String(_y) + ","', config)).out).toBe( + expect((await rr('(?a)|(?b)', 'String(rr_x) + String(rr_y) + ","', config)).out).toBe( 'aundefined,undefinedb,' ); }); diff --git a/test/fix/815.test.js b/test/fix/815.test.js index 95941206..efb3413b 100644 --- a/test/fix/815.test.js +++ b/test/fix/815.test.js @@ -7,7 +7,7 @@ describe('#815 --js code can declare its own variables', () => { test.each([ ['a number with let', 'o', 'let $t = "T"; $t', 'fTT'], ['a group with const', '(o)', 'const $1 = "X"; $1', 'fXX'], - ['a named group with let', '(?o)', 'let _o = "N"; _o', 'fNN'], + ['a named group with let', '(?o)', 'let rr_o = "N"; rr_o', 'fNN'], ])('with the name of %s', async (name, pattern, code, out) => { expect((await rr(pattern, code, {pipedData: 'foo', replacementJs: true})).out).toBe(out); }); diff --git a/test/fix/825.test.js b/test/fix/825.test.js new file mode 100644 index 00000000..c7c1901c --- /dev/null +++ b/test/fix/825.test.js @@ -0,0 +1,26 @@ +// https://github.com/mathiasrw/rexreplace/issues/825 +// A named group was a variable like _year, which could clash: with groups a and a_, _a_ was both a_ and a with a space, +// and a group named _require_ broke r(). +import {describe, test, expect} from 'bun:test'; +import {rr} from '../helper.js'; + +describe('#825 named groups in --js', () => { + test('have rr_ in front, so they can not clash with what RexReplace uses', async () => { + const config = {pipedData: 'ab', replacementJs: true}; + expect((await rr('(?a)', 'rr_require + typeof r', config)).out).toBe('afunctionb'); + }); + + test('can not end with _, as that is the name of another group with a space', async () => { + expect(await rr('(?\\d)(?\\w)', 'rr_a', {pipedData: '1x', replacementJs: true})).toEqual( + { + out: '', + err: ' ❌ A named group can not end with _: a_\n No further processing.\n\n', + exit: 3, + } + ); + }); + + test('can end with _ without --js, as there are no variables then', async () => { + expect((await rr('(?o)', 'x', {pipedData: 'foo'})).out).toBe('fxx'); + }); +}); From c3fcc3604eb71c105045f09573bcffa3ee1c101d Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Sat, 24 Oct 2026 00:17:32 +1100 Subject: [PATCH 14/28] Do not panic on a group name that ends with _ under --output-match --- src/engine.ts | 3 ++- test/fix/825.test.js | 9 ++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index d0ac52b7..676d97d3 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -147,8 +147,9 @@ async function run(config) { // In --js a named group is a variable like rr_year, and rr_year_ has a space at the end. // A group that ends with _ would have the same name as another group with a space, so it can not be used. + // With --output-match the code is not run, so there are no variables. const endsWith_ = - config.replacementJs && config.regex + config.replacementJs && !config.outputMatch && config.regex ? namedGroups(config.regex).find(name => name.endsWith('_')) : null; if (endsWith_) { diff --git a/test/fix/825.test.js b/test/fix/825.test.js index c7c1901c..58bb3614 100644 --- a/test/fix/825.test.js +++ b/test/fix/825.test.js @@ -6,8 +6,10 @@ import {rr} from '../helper.js'; describe('#825 named groups in --js', () => { test('have rr_ in front, so they can not clash with what RexReplace uses', async () => { + // With _ in front, the group _require with a space was __require_, which r() uses itself. + // $1 makes the code run once per match, where the variables of the groups are made. const config = {pipedData: 'ab', replacementJs: true}; - expect((await rr('(?a)', 'rr_require + typeof r', config)).out).toBe('afunctionb'); + expect((await rr('(?<_require>a)', '$1 + r("path").sep', config)).out).toBe('a/b'); }); test('can not end with _, as that is the name of another group with a space', async () => { @@ -20,6 +22,11 @@ describe('#825 named groups in --js', () => { ); }); + test('can end with _ with --output-match, as the code is not run', async () => { + const config = {pipedData: 'ab', replacementJs: true, outputMatch: true}; + expect((await rr('(?a)', 'x', config)).exit).toBe(null); + }); + test('can end with _ without --js, as there are no variables then', async () => { expect((await rr('(?o)', 'x', {pipedData: 'foo'})).out).toBe('fxx'); }); From 9e521417978c15d6c934976910a8e1974fa8067b Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 23:28:37 +1100 Subject: [PATCH 15/28] Find named groups with letters that are not ASCII in --js --- src/engine.ts | 13 ++++++++----- test/fix/816.test.js | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) create mode 100644 test/fix/816.test.js diff --git a/src/engine.ts b/src/engine.ts index 676d97d3..ca19caaf 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -815,15 +815,18 @@ function countFile(config, text, result) { // The code uses a named group of the pattern, like rr_year for (?…) function usesNamedGroups(config) { return namedGroups(config.regex).some(name => - new RegExp('(^|[^\\w$])rr_' + name.replace(/\$/g, '\\$') + '(?![\\w$])').test( - config.replacementOri - ) + new RegExp( + '(^|[^\\p{ID_Continue}$])rr_' + name.replace(/\$/g, '\\$') + '(?![\\p{ID_Continue}$])', + 'u' + ).test(config.replacementOri) ); } -// The names of the named groups of a regex, like year for (?…) +// The names of the named groups of a regex, like year for (?…). A name can have any letter, like año. function namedGroups(regex) { - return [...regex.source.matchAll(/\(\?<([A-Za-z_$][\w$]*)>/g)].map(([, name]) => name); + return [ + ...regex.source.matchAll(/\(\?<([\p{ID_Start}$_][\p{ID_Continue}$\u200C\u200D]*)>/gu), + ].map(([, name]) => name); } // Code that makes the numbers and their cousins, put in front of the code of --js. diff --git a/test/fix/816.test.js b/test/fix/816.test.js new file mode 100644 index 00000000..286b6142 --- /dev/null +++ b/test/fix/816.test.js @@ -0,0 +1,19 @@ +// https://github.com/mathiasrw/rexreplace/issues/816 +// A named group with a letter that is not ASCII was not seen in the code, so the code ran once per file, where the group was not there. +import {describe, test, expect} from 'bun:test'; +import {rr} from '../helper.js'; + +describe('#816 named groups with letters that are not ASCII', () => { + test.each([ + ['año', '(?\\d)', '"[" + rr_año + "]"', 'a[1]b[2]'], + ['日', '(?<日>\\d)', 'rr_日 * 2', 'a2b4'], + ])('can be used, like %s', async (name, pattern, code, out) => { + expect((await rr(pattern, code, {pipedData: 'a1b2', replacementJs: true})).out).toBe(out); + }); + + test('can not end with _ either', async () => { + const result = await rr('(?x)', 'rr_año', {pipedData: 'x', replacementJs: true}); + expect(result.err).toStartWith(' ❌ A named group can not end with _: año_'); + expect(result.exit).toBe(3); + }); +}); From 6cb7e62a656390c8c154a8917c2d9904aeab26e3 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Sat, 24 Oct 2026 00:19:17 +1100 Subject: [PATCH 16/28] Read the named groups from the regex, and see rr_year_ on its own --- src/engine.ts | 10 ++++++---- test/fix/816.test.js | 11 +++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index ca19caaf..66edb195 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -816,17 +816,19 @@ function countFile(config, text, result) { function usesNamedGroups(config) { return namedGroups(config.regex).some(name => new RegExp( - '(^|[^\\p{ID_Continue}$])rr_' + name.replace(/\$/g, '\\$') + '(?![\\p{ID_Continue}$])', + // rr_year_ is the group with a space at the end, so the code uses the group then too + '(^|[^\\p{ID_Continue}$])rr_' + name.replace(/\$/g, '\\$') + '_?(?![\\p{ID_Continue}$])', 'u' ).test(config.replacementOri) ); } // The names of the named groups of a regex, like year for (?…). A name can have any letter, like año. +// They come from the regex itself, so text that only looks like a group, like \(? or [(?], is not one. +// With | at the end the regex matches an empty text, and the match has all the groups. function namedGroups(regex) { - return [ - ...regex.source.matchAll(/\(\?<([\p{ID_Start}$_][\p{ID_Continue}$\u200C\u200D]*)>/gu), - ].map(([, name]) => name); + const match = new RegExp(regex.source + '|', regex.flags.replace(/[gy]/g, '')).exec(''); + return Object.keys(match?.groups ?? {}); } // Code that makes the numbers and their cousins, put in front of the code of --js. diff --git a/test/fix/816.test.js b/test/fix/816.test.js index 286b6142..11cde209 100644 --- a/test/fix/816.test.js +++ b/test/fix/816.test.js @@ -16,4 +16,15 @@ describe('#816 named groups with letters that are not ASCII', () => { expect(result.err).toStartWith(' ❌ A named group can not end with _: año_'); expect(result.exit).toBe(3); }); + + test('are read from the regex, so text that only looks like a group is not one', async () => { + const config = {replacementJs: true}; + expect((await rr('\\(?b', '"z"', {...config, pipedData: 'x(?b'})).out).toBe('x(?z'); + expect((await rr('[(?]', '"z"', {...config, pipedData: 'ab'})).out).toBe('zb'); + }); + + test('can be used with a space at the end on its own, like rr_year_', async () => { + const config = {pipedData: '2024', replacementJs: true}; + expect((await rr('(?\\d+)', 'rr_year_ + "|"', config)).out).toBe('2024 |'); + }); }); From f736bf43c7e8abc3792bc85e99eb7126e55dabe4 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 23:30:22 +1100 Subject: [PATCH 17/28] Use the path of --replacement-file as it is, with no escapes and no aliases --- src/alias.ts | 5 ++++- src/cli.ts | 3 ++- test/fix/817.test.js | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 test/fix/817.test.js diff --git a/src/alias.ts b/src/alias.ts index e872b519..8eace72c 100644 --- a/src/alias.ts +++ b/src/alias.ts @@ -15,7 +15,10 @@ export function alias(config) { }; config.pattern = swap(config.pattern); - config.replacement = swap(config.replacement); + // With --replacement-file the replacement is the name of a file, unless --js code gives the name + if (!config.replacementFile || config.replacementJs) { + config.replacement = swap(config.replacement); + } config.excludeRe = config.excludeRe.map(swap); config.delimiter = swap(config.delimiter); config.nameDelimiter = swap(config.nameDelimiter); diff --git a/src/cli.ts b/src/cli.ts index ac0067e4..d9626a88 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -624,7 +624,8 @@ const unescape = (key, name) => { } }; -if (!config.replacementJs) { +// The path of --replacement-file is the name of a file, so it has no escapes +if (!config.replacementJs && !config.replacementFile) { unescape('replacement', 'replacement'); } diff --git a/test/fix/817.test.js b/test/fix/817.test.js new file mode 100644 index 00000000..35d54fdc --- /dev/null +++ b/test/fix/817.test.js @@ -0,0 +1,32 @@ +// https://github.com/mathiasrw/rexreplace/issues/817 +// The path of --replacement-file went through the escapes and the € and § aliases of a typed replacement, +// so a backslash in a Windows path was lost, and price€.txt was looked for as price$.txt. +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {$} from 'bun'; +import {cli, sh} from '../helper.js'; + +describe('#817 the path of --replacement-file', () => { + test('keeps a backslash', async () => { + fs.mkdirSync('my\\dir'); + fs.writeFileSync('my\\dir/new.txt', 'NEW'); + fs.writeFileSync('page.txt', 'X'); + const result = await sh($`bun ${cli} X ${'my\\dir/new.txt'} page.txt -o --replacement-file`); + expect(result).toEqual({out: 'NEW\n', err: 'page.txt\n', exit: 0}); + }); + + test('keeps € and §, as the aliases are not used in file names', async () => { + fs.writeFileSync('price€§.txt', 'PRICE'); + fs.writeFileSync('page.txt', 'X'); + const result = await sh($`bun ${cli} X ${'price€§.txt'} page.txt -o --replacement-file`); + expect(result.out).toBe('PRICE\n'); + }); + + test('still uses the aliases in the code of --js, which gives the path', async () => { + fs.writeFileSync('new.txt', 'NEW'); + fs.writeFileSync('page.txt', 'X'); + const code = '"new" + €0.replace("X", ".txt")'; + const result = await sh($`bun ${cli} X ${code} page.txt -o --replacement-file --js`); + expect(result.out).toBe('NEW\n'); + }); +}); From d74c21360883e712830446b8cbb4c0e123decc9c Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 23:32:14 +1100 Subject: [PATCH 18/28] 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 66edb195..0098b011 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -669,13 +669,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(); @@ -693,16 +698,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 02e974e6a17de1dfb98638ca2776c3ee2a5904fd Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Sat, 24 Oct 2026 00:21:08 +1100 Subject: [PATCH 19/28] 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 0098b011..895c378e 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -676,9 +676,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 24879e8300801af504ad4fac1682f183af39ba36 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 23:33:56 +1100 Subject: [PATCH 20/28] 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 895c378e..04e62eec 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -1310,8 +1310,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, @@ -1319,14 +1328,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)); } // A path that can not be looked at (like a name that is too long) is not a file, so it is globbed 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 870b6911cb4e856c0b5ff54edd6f71d5208cbd91 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Sat, 24 Oct 2026 00:22:50 +1100 Subject: [PATCH 21/28] 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 04e62eec..b691d475 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -1311,16 +1311,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 16161a482a4036b8b4bdda408cb073e7bf3df391 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 23:35:44 +1100 Subject: [PATCH 22/28] Make the natural order the same on Node and Bun, and in every locale --- src/engine.ts | 5 ++++- test/fix/820.test.js | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 test/fix/820.test.js diff --git a/src/engine.ts b/src/engine.ts index b691d475..3407cfd6 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -1295,7 +1295,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/fix/820.test.js b/test/fix/820.test.js new file mode 100644 index 00000000..989f14c9 --- /dev/null +++ b/test/fix/820.test.js @@ -0,0 +1,17 @@ +// 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. +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {rr} from '../helper.js'; + +describe('#820 the natural order', () => { + test('orders the names it sees as the same by ascii', async () => { + for (const name of ['file1.md', 'file01.md', 'file001.md', 'cafe.md', 'café.md']) { + fs.writeFileSync(name, 'foo'); + } + expect((await rr('foo', 'bar', {includeGlob: ['*.md'], simulate: true})).out).toBe( + 'cafe.md\ncafé.md\nfile001.md\nfile01.md\nfile1.md\n' + ); + }); +}); From 9c69a0d9ae8301bd3cf4b21a295806a450b4a919 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Sat, 24 Oct 2026 00:24:11 +1100 Subject: [PATCH 23/28] Run the test of the natural order with --sort --- test/fix/820.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/fix/820.test.js b/test/fix/820.test.js index 989f14c9..70a93b25 100644 --- a/test/fix/820.test.js +++ b/test/fix/820.test.js @@ -10,8 +10,8 @@ describe('#820 the natural order', () => { for (const name of ['file1.md', 'file01.md', 'file001.md', 'cafe.md', 'café.md']) { fs.writeFileSync(name, 'foo'); } - expect((await rr('foo', 'bar', {includeGlob: ['*.md'], simulate: true})).out).toBe( - 'cafe.md\ncafé.md\nfile001.md\nfile01.md\nfile1.md\n' - ); + expect( + (await rr('foo', 'bar', {includeGlob: ['*.md'], simulate: true, sort: 'natural'})).out + ).toBe('cafe.md\ncafé.md\nfile001.md\nfile01.md\nfile1.md\n'); }); }); From 29146e2eacf5c39bae87287a59bc77ced3645765 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 23:37:51 +1100 Subject: [PATCH 24/28] Make the README and the help true about --quiet, --debug, --simulate, --sort and more --- README.md | 16 ++++++++-------- src/cli.ts | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index d5311a9e..32da1395 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ Use --js-pipe to access the pipe data in your javascript code. #### Templates -With `--replacement-file` the replacement is the path to a file, and the content of the file goes in. `--trim-file` takes away the line break at the end of the file. +With `--replacement-file` the replacement is the path to a file, and the content of the file goes in. `--trim-file` trims the content, like the line break at the end of the file. ```bash > rexreplace '\{\{footer\}\}' partials/footer.html 'site/*.html' --replacement-file --trim-file @@ -316,7 +316,7 @@ If planned renames clash, the process is halted and no change is applied to any | `-v` | **`--version`** Print rexreplace version (can be given as only argument) [boolean] | | `-V` | **`--verbose`** More chatty output [boolean] | | `-L` | **`--literal`** Literal string search (no regex used when searching) [boolean] | -| `-l` | **`--literal-replacement`** Use the replacement as it is. Without it, `$1`, `$&` and the other `$` signs in the replacement mean the groups and the match, as in javascript. [boolean] | +| `-l` | **`--literal-replacement`** The `$` signs in the replacement have no meaning, so `$1` and `$&` are just text. Without it they mean the groups and the match, as in javascript. The `€` and `§` aliases and escapes like `\n` are part of what is typed, and are still used. [boolean] | | | **`--replacement-file`** The replacement is the path to a file, and the content of the file is used as the replacement. With --js the code gives the path. [boolean] | | `-I` | **`--void-ignore-case`** Void case insensitive search. Applies to the pattern and to all globs. [boolean] | | `-G` | **`--void-global`** Void global search (stop looking after the first match). [boolean] | @@ -348,8 +348,8 @@ If planned renames clash, the process is halted and no change is applied to any | `-A` | **`--void-async`** Handle files in a synchronous flow. Good to limit memory usage when handling large files. [boolean] | | `-B` | **`--void-backup`** Avoid temporary backing up file. Works async (independent of -A flag) and will speed up things but at one point data lives only in memory, and you will lose the content if the process is interrupted. [boolean] | | `-b` | **`--keep-backup`** Keep a backup file of the original content. With --rename the old file is kept. [boolean] | -| `-S` | **`--simulate`** Execute everything during a run except changes to the file system. Names of affected files and content are still printed as in a usual run. [boolean] | -| | **`--sort`** Process the files one at a time, in a sorted order. The order is chosen from 6 values: `natural` (numbers compared as numbers, and case ignored), `ascii` (alphabetic), `modified`, `created`, `size`, or `none` (the order of the input: the files given first, and then the files found by globs). Files that sort the same (like the same size) are ordered by ascii sort. Without --sort the files are processed asynchronously, so the order of the output can not be guaranteed, but the processing is started in natural order. [string] [choices: "natural", "ascii", "modified", "created", "size", "none"] | +| `-S` | **`--simulate`** Execute everything during a run except the changes RexReplace would normally make to the file system. Code from --js still runs, and can change data even with --simulate. [boolean] | +| | **`--sort`** Process the files one at a time, in a sorted order. The order is chosen from 6 values: `natural` (numbers compared as numbers, and case ignored), `ascii` (by char code, so `B` comes before `a`), `modified`, `created`, `size`, or `none` (the order of the input: the files given first, and then the files found by globs). Files that sort the same (like the same size) are ordered by ascii sort. Without --sort the files are processed asynchronously, so the order of the output can not be guaranteed, but the processing is started in natural order. [string] [choices: "natural", "ascii", "modified", "created", "size", "none"] | | | **`--sort-reverse`** Turn the order of the files around. [boolean] | | `-F` | **`--expect-files`** Check that at least one file is found. The exit code is 1 if not. [boolean] | | `-z` | **`--expect-diff`** Check that at least one file is changed. The exit code is 1 if not. [boolean] | @@ -473,7 +473,7 @@ a_b #### The replacement -1. The replacement as typed, with `€` and `§` as above. Or the piped data with `--replacement-pipe` (`-R`), used as it is. +1. The replacement as typed: `€` is `$`, `§` is `\`, and escapes like `\n` and `\t` are read. Or the piped data with `--replacement-pipe` (`-R`), used as it is. 2. `--js` (`-j`) runs it as code, and the value of the code goes on. `--js-pipe` also gives the code the piped data as `pipe`. 3. `--replacement-file`: what came so far is a path, and the content of that file goes on. 4. `--trim-file` trims it. @@ -538,14 +538,14 @@ b.file 3. The tails: the delimiter also goes after the last item, unless `--no-name-delimiter-tail`, `--no-content-delimiter-tail` or `--no-tail`. Piped data that is printed is left as it came in, with nothing after it. ```bash -> rexreplace o x a.file b.file --name-delimiter ', ' --no-name-delimiter-tail +> rexreplace o x a.file b.file --name-delimiter ', ' --no-name-delimiter-tail --sort natural a.file, b.file > rexreplace o x a.file b.file --null-out | xargs -0 ls -l ``` 4. `--nl` ends stdout and stderr with a line break, if they do not end with one. -5. How much: `--quiet` (`-q`) shows only the errors (❌), and no names or warnings (🟡). `--quiet-total` (`-Q`) shows nothing, and `--verbose` (`-V`) tells each step to stderr. An error that stops the run ends with `No further processing.` -6. The status report: `--status` and `--status-compact` (`-c`) print it as the last thing, also with `--quiet-total`. `--debug` (`-d`) prints only the status report, with what went into the engine and what it did. +5. How much: `--quiet` (`-q`) hides the names and the warnings (🟡), but not the errors (❌), and not the content of `--output` or the matches of `--output-match`. `--quiet-total` (`-Q`) shows nothing, and `--verbose` (`-V`) tells each step to stderr. An error that stops the run ends with `No further processing.` +6. The status report: `--status` and `--status-compact` (`-c`) print it as the last thing, also with `--quiet-total`. `--debug` (`-d`) prints the status report, with what went into the engine and what it did, and hides the rest, except the content of `--output` and the matches of `--output-match`. 7. The exit code, see [Exit codes](#exit-codes). ### Other details diff --git a/src/cli.ts b/src/cli.ts index d9626a88..65c23dfa 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -78,7 +78,7 @@ const yargs = require('yargs') .boolean('l') .describe( 'l', - 'Use the replacement as it is. Without it, `$1`, `$&` and the other `$` signs in the replacement mean the groups and the match, as in javascript.' + 'The `$` signs in the replacement have no meaning, so `$1` and `$&` are just text. Without it they mean the groups and the match, as in javascript. The `€` and `§` aliases and escapes like `\\n` are part of what is typed, and are still used.' ) .alias('l', 'literal-replacement') @@ -261,14 +261,14 @@ const yargs = require('yargs') .boolean('S') .describe( 'S', - 'Execute everything during a run except changes to the file system. Names of affected files and content are still printed as in a usual run.' + 'Execute everything during a run except the changes RexReplace would normally make to the file system. Code from --js still runs, and can change data even with --simulate.' ) .alias('S', 'simulate') .string('sort') .describe( 'sort', - 'Process the files one at a time, in a sorted order. The order is chosen from 6 values: `natural` (numbers compared as numbers, and case ignored), `ascii` (alphabetic), `modified`, `created`, `size`, or `none` (the order of the input: the files given first, and then the files found by globs). Files that sort the same (like the same size) are ordered by ascii sort. Without --sort the files are processed asynchronously, so the order of the output can not be guaranteed, but the processing is started in natural order.' + 'Process the files one at a time, in a sorted order. The order is chosen from 6 values: `natural` (numbers compared as numbers, and case ignored), `ascii` (by char code, so `B` comes before `a`), `modified`, `created`, `size`, or `none` (the order of the input: the files given first, and then the files found by globs). Files that sort the same (like the same size) are ordered by ascii sort. Without --sort the files are processed asynchronously, so the order of the output can not be guaranteed, but the processing is started in natural order.' ) // Any case, and true and yes are natural .coerce('sort', value => { From c200a428ea55f0afed1b85a8b783fb5ae82bc14a Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Sat, 24 Oct 2026 00:25:52 +1100 Subject: [PATCH 25/28] Make the README and help true about --quiet, --debug and the escapes --- README.md | 8 ++++---- devguide.md | 2 +- src/cli.ts | 5 ++++- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 32da1395..1b235f71 100644 --- a/README.md +++ b/README.md @@ -325,7 +325,7 @@ If planned renames clash, the process is halted and no change is applied to any | `-u` | **`--unicode`** Treat pattern as a sequence of unicode code points. [boolean] | | `-e` | **`--encoding`** Encoding of files/piped data. [default: "utf8"] | | `-E` | **`--engine`** What regex engine to use. `default` is the engine of the runtime: `V8` with Node and `JSC` (JavaScriptCore) with Bun. The engine of the other runtime can not be picked. [choices: "default", "V8", "JSC"] [default: "default"] | -| `-q` | **`--quiet`** Only display errors (no other info) [boolean] | +| `-q` | **`--quiet`** Hide the names of the files and the warnings. Errors are still shown, and so is the content of --output and the matches of --output-match. [boolean] | | `-Q` | **`--quiet-total`** Never display errors or info [boolean] | | `-f` | **`--full-path`** Print the full path of the named files instead of the relative path. [boolean] | | | **`--name-all`** Print the names of all files found by the globs, also the ones where nothing was changed. [boolean] | @@ -405,7 +405,7 @@ Files identified for potential processing are identified by: - Per default `§` is treated as an alias for `\` in the pattern, the replacement and `--exclude-re`. The main reason is for you not to worry about escaping the escape of an escape. Use the `-§` flag if you need to search or replace the actual section char. -- The `€` and `§` aliases apply to pattern, replacement, `--exclude-re` values and delimiters. File globs, `--exclude-glob` and piped data are used exactly as they are, so a file named `price€.txt` is found without any flags. +- The `€` and `§` aliases apply to pattern, replacement, `--exclude-re` values and delimiters. File globs, `--exclude-glob`, the path of `--replacement-file` and piped data are used exactly as they are, so a file named `price€.txt` is found without any flags. - The default delimiter is a magic newline ensuring that there is _at least_ one `\n` after printed file content, matches or names. This means that if the content already ends on a `\n` no additional `\n` is added. This behaviour can not be recreated with other delimiter values. @@ -473,9 +473,9 @@ a_b #### The replacement -1. The replacement as typed: `€` is `$`, `§` is `\`, and escapes like `\n` and `\t` are read. Or the piped data with `--replacement-pipe` (`-R`), used as it is. +1. The replacement as typed: `€` is `$`, `§` is `\`, and escapes like `\n` and `\t` are read. In `--js` code javascript reads the escapes, and the path of `--replacement-file` is used as it is, with no aliases or escapes. Or the piped data with `--replacement-pipe` (`-R`), used as it is. 2. `--js` (`-j`) runs it as code, and the value of the code goes on. `--js-pipe` also gives the code the piped data as `pipe`. -3. `--replacement-file`: what came so far is a path, and the content of that file goes on. +3. `--replacement-file`: what came so far is the path of a file, and the content of the file goes on. 4. `--trim-file` trims it. 5. It goes into the text. `$1` is group 1, `$&` the whole match, and `$$` one `$`, unless the value came from `--js` code, or `--literal-replacement` (`-l`) is given. diff --git a/devguide.md b/devguide.md index b27fc433..4ba7bae8 100644 --- a/devguide.md +++ b/devguide.md @@ -29,7 +29,7 @@ All output goes through `src/output.ts`. Pick the function by who the message is | `chat(msg)` | `--verbose`, also with `--quiet`, but not with `--quiet-total` | The user: what happens to each file. Only ever to stderr. Also kept in `engine.steps` of the status report. | `Open sync: a.md`, `Nothing changed in: a.md` | - What the run did is in the status report: the engine resolves with it, and `--status` prints it. So there is no extra debug output to print along the way. -- `--debug` prints only the status report, with what went into the engine and what it did. +- `--debug` prints the status report, with what went into the engine and what it did, and hides the rest, except the content of `--output` and the matches of `--output-match`. - `warn()`, `error()`, `checkFailed()` and `panic()` add to the status report as they go, also when the quiet flags hide what they print. - The exit code is 0 when the run is done, 1 when a check from `--expect-*` is not met, 2 when a file gave an error, and 3 for a panic or wrong flags. See Exit codes in the README. diff --git a/src/cli.ts b/src/cli.ts index 65c23dfa..8db8bca3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -146,7 +146,10 @@ const yargs = require('yargs') .default('E', 'default') .boolean('q') - .describe('q', 'Only display errors (no other info)') + .describe( + 'q', + 'Hide the names of the files and the warnings. Errors are still shown, and so is the content of --output and the matches of --output-match.' + ) .alias('q', 'quiet') .boolean('Q') From d618a25026ef1a1503e631b4e1a6b02f2d7fab12 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Sat, 24 Oct 2026 00:26:35 +1100 Subject: [PATCH 26/28] Tell in the README that only the --js numbers the code names are made --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1b235f71..15f265b0 100644 --- a/README.md +++ b/README.md @@ -366,7 +366,7 @@ If planned renames clash, the process is halted and no change is applied to any | `-g` | **`--glob-pipe`** Each line of the piped data is a path or a glob of the files to work on. They are used together with the ones given as arguments. [boolean] | | | **`--null-in`** The piped paths and globs of --glob-pipe are separated by null bytes, like from `find -print0` or --null-out. [boolean] | | `-R` | **`--replacement-pipe`** Replacement will be piped in. You still need to provide a dummy value (like `_`) as replacement parameter. [boolean] | -| `-j` | **`--replacement-js`**, **`--js`** Treat replacement as javascript source code. The statement from the last expression will become the replacement string. Purposefully implemented the most insecure way possible to remove _any_ incentive to consider running code from an untrusted part. The full match will be available as a javascript variable named $0 while each captured group will be available as $1, $2, $3, ... and so on. At some point, the $ char _will_ give you a headache when used from the command line, so use €0, €1, €2, €3... instead. If the javascript source code references to the full match or a captured group the code will run once per match. Otherwise, it will run once per file. The code has access to the following variables: `r` as an alias for `require` with both expanded to understand a relative path even if it is not starting with `./`, `fs` from node, `path` from node, `globs` from npm, `pipe`: the data piped into the command with --js-pipe (null if no piped data, and `pipe_` is `❌ `), `find`: pattern searched for (the needle), `text`: full text being searched i.e. file content or piped data (the haystack), `bytes`: total size of the haystack in bytes, `size`: human-friendly representation of the total size of the haystack, `time`: String representing the local time when the command was invoked, `time_obj`: date object representing `time`, `now`: alias for `time`, `cwd`: current process working dir, `nl`: a new-line char, `_`: a single space char (for easy string concatenation). The following values defaults to `❌` if haystack does not originate from a file: `file`: contains the full path of the active file being searched (including full filename), `file_rel`: contains `file` relative to current process working dir, `dirpath`: contains the full path without filename of the active file being searched, `dirpath_rel`: contains `dirpath` relative to current process working dir, `filename`: is the full filename of the active file being searched without path, `name`: filename of the active file being searched with no extension, `ext`: extension of the filename including leading dot, `mtime`: ISO inspired representation of the last local modification time of the current file, `ctime`: ISO representation of the local creation time of the current file. `mtime_obj`: date object representing `mtime`, `ctime_obj`: date object representing `ctime`. The numbers: `$n`: the number of the file, `$t`: the number of files, `$i`: the number of the file among the files with a match so far, `$c`: among the files changed so far, `$m`: the number of the match in the file, `$M`: across all files. Each has cousins with zeros in front (`$n0` has 2 digits, `$n00` 3, and so on to 10) and with a space at the end (`$n_`). A named group is a variable with `rr_` in front: `rr_year` for `(?...)`, and `rr_year_` has a space at the end. A named group can not end with `_`. All variables, except from module, date objects, `nl` and `_`, has a corresponding variable name followed by `_` where the content has an extra space at the end (for easy concatenation). [boolean] | +| `-j` | **`--replacement-js`**, **`--js`** Treat replacement as javascript source code. The statement from the last expression will become the replacement string. Purposefully implemented the most insecure way possible to remove _any_ incentive to consider running code from an untrusted part. The full match will be available as a javascript variable named $0 while each captured group will be available as $1, $2, $3, ... and so on. At some point, the $ char _will_ give you a headache when used from the command line, so use €0, €1, €2, €3... instead. If the javascript source code references to the full match or a captured group the code will run once per match. Otherwise, it will run once per file. The code has access to the following variables: `r` as an alias for `require` with both expanded to understand a relative path even if it is not starting with `./`, `fs` from node, `path` from node, `globs` from npm, `pipe`: the data piped into the command with --js-pipe (null if no piped data, and `pipe_` is `❌ `), `find`: pattern searched for (the needle), `text`: full text being searched i.e. file content or piped data (the haystack), `bytes`: total size of the haystack in bytes, `size`: human-friendly representation of the total size of the haystack, `time`: String representing the local time when the command was invoked, `time_obj`: date object representing `time`, `now`: alias for `time`, `cwd`: current process working dir, `nl`: a new-line char, `_`: a single space char (for easy string concatenation). The following values defaults to `❌` if haystack does not originate from a file: `file`: contains the full path of the active file being searched (including full filename), `file_rel`: contains `file` relative to current process working dir, `dirpath`: contains the full path without filename of the active file being searched, `dirpath_rel`: contains `dirpath` relative to current process working dir, `filename`: is the full filename of the active file being searched without path, `name`: filename of the active file being searched with no extension, `ext`: extension of the filename including leading dot, `mtime`: ISO inspired representation of the last local modification time of the current file, `ctime`: ISO representation of the local creation time of the current file. `mtime_obj`: date object representing `mtime`, `ctime_obj`: date object representing `ctime`. The numbers: `$n`: the number of the file, `$t`: the number of files, `$i`: the number of the file among the files with a match so far, `$c`: among the files changed so far, `$m`: the number of the match in the file, `$M`: across all files. Each has cousins with zeros in front (`$n0` has 2 digits, `$n00` 3, and so on to 10) and with a space at the end (`$n_`). A number is only made when the code names it, so a name built when the code runs, like `eval('$' + 'n')`, is not there. A named group is a variable with `rr_` in front: `rr_year` for `(?...)`, and `rr_year_` has a space at the end. A named group can not end with `_`. All variables, except from module, date objects, `nl` and `_`, has a corresponding variable name followed by `_` where the content has an extra space at the end (for easy concatenation). [boolean] | | `-x` | **`--exclude-re`** Files with a path that matches this regular expression will be excluded from processing. Can be provided multiple times. [string] | | `-X` | **`--exclude-glob`** Files found with this glob will be excluded from being processed. Can be provided multiple times and will act as one group of globs, meaning you can use ! notation to negate. [string] | | `-h` | **`--help`** Display help. [boolean] | From 2533c6cb81011e4900066e58a0a6b974a8bb3bac Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Sat, 24 Oct 2026 00:46:44 +1100 Subject: [PATCH 27/28] End the run as usual when the reader of a pipe stops early --- src/cli.ts | 6 +++--- src/output.ts | 25 ++++++++++++++++++++++++- test/fix/839.test.js | 24 ++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 test/fix/839.test.js diff --git a/src/cli.ts b/src/cli.ts index 8db8bca3..cf22585f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,7 @@ // CLI interface for rexreplace import * as rexreplace from './engine.ts'; -import {outputConfig, panic} from './output.ts'; +import {outputConfig, panic, writeTo} from './output.ts'; import {alias} from './alias.ts'; // Make it visible when Bun is the runtime (like with rexreplace-bun) as the regex engine is then not the one from Node. @@ -526,9 +526,9 @@ Z z // The CLI is the only part that acts on the process. function printStatus(config, status) { if (config.status) { - process.stdout.write('\n' + JSON.stringify(status, null, 2) + '\n'); + writeTo(process.stdout, '\n' + JSON.stringify(status, null, 2) + '\n'); } else if (config.statusCompact) { - process.stdout.write(JSON.stringify(status) + '\n'); + writeTo(process.stdout, JSON.stringify(status) + '\n'); } } diff --git a/src/output.ts b/src/output.ts index 3b77f490..147f46cf 100644 --- a/src/output.ts +++ b/src/output.ts @@ -23,9 +23,32 @@ export class Panic extends Error { status = null; } +// A pipe the reader has closed, like `| head`, can not take more. That is fine: nobody reads the rest. +// Bun throws when it is written to, and Node gives an error event, so both are caught. +const closed = new Set(); +const whenClosed = stream => e => { + if ('EPIPE' !== e?.code) { + throw e; + } + closed.add(stream); +}; +process.stdout.on?.('error', whenClosed(process.stdout)); +process.stderr.on?.('error', whenClosed(process.stderr)); + +export const writeTo = function (stream, text) { + if (closed.has(stream)) { + return; + } + try { + stream.write(text); + } catch (e) { + whenClosed(stream)(e); + } +}; + // Everything is printed from here, so the last character of each stream is known const write = function (text, stream) { - stream.write(text); + writeTo(stream, text); if (text) { lastPrintedChar.set(stream, text.charCodeAt(text.length - 1)); } diff --git a/test/fix/839.test.js b/test/fix/839.test.js new file mode 100644 index 00000000..23af8e68 --- /dev/null +++ b/test/fix/839.test.js @@ -0,0 +1,24 @@ +// https://github.com/mathiasrw/rexreplace/issues/839 +// When the reader of a pipe stopped early, like head, the run crashed with EPIPE and a stack trace. +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {$} from 'bun'; +import {cli, sh} from '../helper.js'; + +describe('#839 a pipe the reader closes early', () => { + test('ends the run as usual, with no error', async () => { + fs.writeFileSync('big.txt', Array.from({length: 200000}, (_, i) => `${i} foo`).join('\n')); + expect(await sh($`bun ${cli} foo bar big.txt -o | head -1`)).toEqual({ + out: '0 bar\n', + err: 'big.txt\n', + exit: 0, + }); + }); + + test('also when the status report comes after the reader is gone', async () => { + fs.writeFileSync('big.txt', Array.from({length: 200000}, (_, i) => `${i} foo`).join('\n')); + const result = await sh($`bun ${cli} foo bar big.txt -o -d | head -1`); + expect(result.out).toBe('0 bar\n'); + expect(result.err).toBe(''); + }); +}); From a142005c17f1926eef064527555bf2fe4dd8357a Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Sat, 24 Oct 2026 03:48:31 +1100 Subject: [PATCH 28/28] Put the detail of an error on its own line under the message --- devguide.md | 2 +- src/cli.ts | 2 +- src/engine.ts | 16 ++++++++-------- src/output.ts | 7 +++++-- test/engine/encoding.test.js | 2 +- test/engine/replacement-file.test.js | 4 ++-- test/fix/691.test.js | 2 +- test/fix/700.test.js | 2 +- test/fix/788.test.js | 2 +- test/fix/808.test.js | 8 +++++--- test/fix/816.test.js | 2 +- test/fix/818.test.js | 4 ++-- test/fix/819.test.js | 2 +- test/fix/822.test.js | 4 ++-- test/fix/824.test.js | 2 +- test/fix/825.test.js | 2 +- 16 files changed, 34 insertions(+), 29 deletions(-) diff --git a/devguide.md b/devguide.md index 4ba7bae8..01a820d6 100644 --- a/devguide.md +++ b/devguide.md @@ -23,7 +23,7 @@ All output goes through `src/output.ts`. Pick the function by who the message is | `printDone()` | At the end of a run, also when it is stopped | Ends stdout and stderr with a line break when `--nl` is set | | | `info(name)` | Unless `--quiet` | The name of an affected file. stdout, or stderr when stdout has content. | `a.md` | | `warn(msg)` | Unless `--quiet` | Something is likely a mistake, or there is nothing to do, but the run goes on. Starts with 🟡. Also added to `status.warnings`. | `0 files found` | -| `error(msg, data)` | Unless `--quiet-total` | Something went wrong. Starts with ❌. The run goes on, unless `--halt` is set: then a panic is thrown. Also added to `status.errors`, so the CLI gives the exit code 2. | `File not found: a.md` | +| `error(msg, data)` | Unless `--quiet-total` | Something went wrong. Starts with ❌, and the data, like the file, is on its own line under the message. The run goes on, unless `--halt` is set: then a panic is thrown. Also added to `status.errors`, so the CLI gives the exit code 2. | `File not found`, and `a.md` on the next line | | `checkFailed(text)` | Unless `--quiet-total` | A check from `--expect-*` that is not met. An error, also in `status.checks`, so the CLI gives the exit code 1 when the failed checks are the only errors. | `Expected a diff, but nothing was changed` | | `panic(msg, data, help)` | Unless `--quiet-total` | Something went wrong and the run can not go on. Starts with ❌, ends with `No further processing.`, and throws a panic, so the CLI exits with 3. Also added to `status.errors`. | `No data piped into replacement` | | `chat(msg)` | `--verbose`, also with `--quiet`, but not with `--quiet-total` | The user: what happens to each file. Only ever to stderr. Also kept in `engine.steps` of the status report. | `Open sync: a.md`, `Nothing changed in: a.md` | diff --git a/src/cli.ts b/src/cli.ts index cf22585f..434ac3d5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -620,7 +620,7 @@ const unescape = (key, name) => { } catch (e) { // The engine is not running yet, so the panic is caught here try { - panic('Invalid escape sequence in ' + name + ':', typed[key]); + panic('Invalid escape sequence in ' + name, typed[key]); } catch (e) { exitOnPanic(config, e); } diff --git a/src/engine.ts b/src/engine.ts index 3407cfd6..5b22df61 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -45,7 +45,7 @@ const runtimeEngine = process.versions.bun ? 'JSC' : 'V8'; function validateEncoding(encoding = 'utf8') { if (!Buffer.isEncoding(encoding)) { - panic('Unknown encoding:', encoding); + panic('Unknown encoding', encoding); } } @@ -153,7 +153,7 @@ async function run(config) { ? namedGroups(config.regex).find(name => name.endsWith('_')) : null; if (endsWith_) { - panic('A named group can not end with _:', endsWith_); + panic('A named group can not end with _', endsWith_); } // What the regex engine is asked to do. With --replacement-js and --output-match it gets a function. @@ -229,7 +229,7 @@ async function run(config) { (config.replacementJs && re.runNumbers.test(config.replacementOri)); const files = config.files.filter(filepath => - fs.existsSync(filepath) ? true : error('File not found:', filepath) + fs.existsSync(filepath) ? true : error('File not found', filepath) ); if (config.rename || config.renamePath) { @@ -669,7 +669,7 @@ 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', unreadable(replacement, e)); } } @@ -711,7 +711,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', unreadable(filepath, e)); } } return args[0]; @@ -1035,7 +1035,7 @@ function dynamicReplacement( // With --verbose it also tells where the code failed, when that is in a file of the user, like a module loaded with r() const at = _config_rr.verbose ? stackPlace(e) : ''; return panic( - 'The code of --js failed for ' + where + ':', + 'The code of --js failed for ' + where, thrownText(e) + (at ? '\n at ' + at : '') ); } @@ -1290,7 +1290,7 @@ function sortName(value) { return 'natural'; } if (!sorts.includes(name)) { - panic('--sort must be one of ' + sorts.join(', ') + ', not:', String(value)); + panic('--sort must be one of ' + sorts.join(', '), String(value)); } return name; } @@ -1360,7 +1360,7 @@ function glob(patterns, globOptions) { try { return globs.sync(patterns, globOptions); } catch (e) { - return panic('The files can not be found:', e.message); + return panic('The files can not be found', e.message); } } diff --git a/src/output.ts b/src/output.ts index 147f46cf..087eb949 100644 --- a/src/output.ts +++ b/src/output.ts @@ -127,12 +127,15 @@ const printError = function (text, stop) { } }; +// The detail, like the file, goes on a line of its own under the message +const errorText = (msg, data) => msg + (data ? '\n ' + data : ''); + // The run can not go on, so it stops here export const panic = function (msg = '', data = '', displayHelp = false) { if (displayHelp && !config.quietTotal) { config.showHelp(); } - const text = msg + (data ? ' ' + data : ''); + const text = errorText(msg, data); config.statusInfo?.errors.push(text); printError(text, true); throw new Panic(text); @@ -147,7 +150,7 @@ export const checkFailed = function (text) { // Something failed, and the run goes on. With --halt it is a panic. export const error = function (msg, data = '') { - const text = msg + (data ? ' ' + data : ''); + const text = errorText(msg, data); config.statusInfo?.errors.push(text); printError(text, config.halt); if (config.halt) { diff --git a/test/engine/encoding.test.js b/test/engine/encoding.test.js index a04f1234..b13d2e9f 100644 --- a/test/engine/encoding.test.js +++ b/test/engine/encoding.test.js @@ -50,7 +50,7 @@ describe('--encoding', () => { // The error message, and not the stack trace from deep inside node, when an encoding is unknown (#690) const unknownEncoding = { out: '', - err: ' ❌ Unknown encoding: nope\n No further processing.\n\n', + err: ' ❌ Unknown encoding\n nope\n No further processing.\n\n', exit: 3, }; diff --git a/test/engine/replacement-file.test.js b/test/engine/replacement-file.test.js index 19abca35..5614d18e 100644 --- a/test/engine/replacement-file.test.js +++ b/test/engine/replacement-file.test.js @@ -60,7 +60,7 @@ describe('--replacement-file', () => { 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: no such file or directory)' + ' ❌ The replacement file can not be read\n nope.txt (ENOENT: no such file or directory)' ); expect(result.exit).toBe(3); expect(fs.readFileSync('my.file', 'utf8')).toBe('foo'); @@ -70,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: no such file or directory)\n\n', + err: ' ❌ The replacement file can not be read\n nope.txt (ENOENT: no such file or directory)\n\n', exit: 2, }); }); diff --git a/test/fix/691.test.js b/test/fix/691.test.js index a97a9882..f98848fc 100644 --- a/test/fix/691.test.js +++ b/test/fix/691.test.js @@ -77,7 +77,7 @@ describe('#691 replacement that used to crash the CLI', () => { const result = await sh($`bun ${cli} o ${'\\x4'} my.file --output --no-content-delimiter-tail`); expect(result.out).toBe(''); expect(result.err).toBe( - ' ❌ Invalid escape sequence in replacement: \\x4\n No further processing.\n\n' + ' ❌ Invalid escape sequence in replacement\n \\x4\n No further processing.\n\n' ); expect(result.exit).toBe(3); expect( diff --git a/test/fix/700.test.js b/test/fix/700.test.js index 79cd47d8..686be3cf 100644 --- a/test/fix/700.test.js +++ b/test/fix/700.test.js @@ -39,7 +39,7 @@ describe('#700 § is a full substitute for \\ in the replacement', () => { test('an invalid escape sequence gives the same error as with a backslash', async () => { expect(await replaceB('§x4')).toEqual({ out: '', - err: ' ❌ Invalid escape sequence in replacement: §x4\n No further processing.\n\n', + err: ' ❌ Invalid escape sequence in replacement\n §x4\n No further processing.\n\n', exit: 3, }); }); diff --git a/test/fix/788.test.js b/test/fix/788.test.js index b572225c..44eef271 100644 --- a/test/fix/788.test.js +++ b/test/fix/788.test.js @@ -23,7 +23,7 @@ describe('#788 a path that can not be looked at', () => { ])('gives a panic when the glob can not be used, %s', async (name, config) => { fs.writeFileSync('a.md', 'foo'); const result = await rr('foo', 'bar', config); - expect(result.err).toStartWith(' ❌ The files can not be found: ENOTDIR'); + expect(result.err).toStartWith(' ❌ The files can not be found\n ENOTDIR'); expect(result.err).toEndWith('\n No further processing.\n\n'); expect(result.exit).toBe(3); expect(fs.readFileSync('a.md', 'utf8')).toBe('foo'); diff --git a/test/fix/808.test.js b/test/fix/808.test.js index 6836c92b..1b51ebd5 100644 --- a/test/fix/808.test.js +++ b/test/fix/808.test.js @@ -5,7 +5,7 @@ import fs from 'fs'; import {rr, rrStatus} from '../helper.js'; const failed = (where, message) => - ` ❌ The code of --js failed for ${where}: ${message}\n No further processing.\n\n`; + ` ❌ The code of --js failed for ${where}\n ${message}\n No further processing.\n\n`; describe('#808 code of --js that fails', () => { test('stops the run with only the message', async () => { @@ -18,7 +18,9 @@ describe('#808 code of --js that fails', () => { test('tells a syntax error the same way', async () => { const result = await rr('o', '1 +', {pipedData: 'foo', replacementJs: true}); - expect(result.err).toStartWith(' ❌ The code of --js failed for the piped data: SyntaxError'); + expect(result.err).toStartWith( + ' ❌ The code of --js failed for the piped data\n SyntaxError' + ); expect(result.exit).toBe(3); }); @@ -46,7 +48,7 @@ describe('#808 code of --js that fails', () => { test('is in the errors of the status report', async () => { const status = await rrStatus('o', 'nope()', {pipedData: 'foo', replacementJs: true}); expect(status.errors).toEqual([ - 'The code of --js failed for the piped data: ReferenceError: nope is not defined', + 'The code of --js failed for the piped data\n ReferenceError: nope is not defined', ]); }); }); diff --git a/test/fix/816.test.js b/test/fix/816.test.js index 11cde209..5a9928ea 100644 --- a/test/fix/816.test.js +++ b/test/fix/816.test.js @@ -13,7 +13,7 @@ describe('#816 named groups with letters that are not ASCII', () => { test('can not end with _ either', async () => { const result = await rr('(?x)', 'rr_año', {pipedData: 'x', replacementJs: true}); - expect(result.err).toStartWith(' ❌ A named group can not end with _: año_'); + expect(result.err).toStartWith(' ❌ A named group can not end with _\n año_'); expect(result.exit).toBe(3); }); diff --git a/test/fix/818.test.js b/test/fix/818.test.js index a5b97e98..d5238a18 100644 --- a/test/fix/818.test.js +++ b/test/fix/818.test.js @@ -22,7 +22,7 @@ describe('#818 a replacement file that can not be read', () => { 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); + expect(result.err).toStartWith(' ❌ The replacement file can not be read\n ' + text); }); test('with --js is told once in the run, not once for each file', async () => { @@ -31,7 +31,7 @@ describe('#818 a replacement file that can not be read', () => { 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)', + 'The replacement file can not be read\n nope.txt (ENOENT: no such file or directory)', ]); }); }); diff --git a/test/fix/819.test.js b/test/fix/819.test.js index 632d1d60..cc0ebc02 100644 --- a/test/fix/819.test.js +++ b/test/fix/819.test.js @@ -24,7 +24,7 @@ describe('#819 a file that can not be looked at in the sort', () => { try { expect(await rr('foo', 'bar', {includeGlob: ['*.md'], sort})).toEqual({ out: 'a.md\nz.md\n', - err: ' ❌ File not found: gone.md\n\n', + err: ' ❌ File not found\n gone.md\n\n', exit: 2, }); } finally { diff --git a/test/fix/822.test.js b/test/fix/822.test.js index cfa1b874..236ffbc4 100644 --- a/test/fix/822.test.js +++ b/test/fix/822.test.js @@ -4,7 +4,7 @@ import {describe, test, expect} from 'bun:test'; import {rr} from '../helper.js'; const failed = text => - ` ❌ The code of --js failed for the piped data: ${text}\n No further processing.\n\n`; + ` ❌ The code of --js failed for the piped data\n ${text}\n No further processing.\n\n`; describe('#822 code that throws something that is not an error', () => { test.each([ @@ -30,6 +30,6 @@ describe('#822 code that throws something that is not an error', () => { replacementJs: true, }); expect(result.err).toContain('an object was thrown: {"text":"' + 'y'.repeat(291) + '…'); - expect(result.err.length).toBeLessThan(400); + expect(result.err.length).toBeLessThan(450); }); }); diff --git a/test/fix/824.test.js b/test/fix/824.test.js index ae2d4bc9..2a52cc9b 100644 --- a/test/fix/824.test.js +++ b/test/fix/824.test.js @@ -41,7 +41,7 @@ describe('#824 --js code that fails in a module', () => { const config = {pipedData: 'foo', replacementJs: true, verbose: true}; const result = await rr('o', 'throw new Proxy({}, {get() { throw 1; }})', config); expect(result.err).toContain( - 'The code of --js failed for the piped data: an object was thrown' + 'The code of --js failed for the piped data\n an object was thrown' ); expect(result.exit).toBe(3); }); diff --git a/test/fix/825.test.js b/test/fix/825.test.js index 58bb3614..a0b5862c 100644 --- a/test/fix/825.test.js +++ b/test/fix/825.test.js @@ -16,7 +16,7 @@ describe('#825 named groups in --js', () => { expect(await rr('(?\\d)(?\\w)', 'rr_a', {pipedData: '1x', replacementJs: true})).toEqual( { out: '', - err: ' ❌ A named group can not end with _: a_\n No further processing.\n\n', + err: ' ❌ A named group can not end with _\n a_\n No further processing.\n\n', exit: 3, } );