From 0e1634262d34cfa589b0dceff917a6eac2a399dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:31:52 +0000 Subject: [PATCH 1/5] Initial plan From b8a7b49d6b81f9ea3ff16902ebbfaf979958a25a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:40:13 +0000 Subject: [PATCH 2/5] Print changed file names to stdout; add --null and --name-delimiter Co-authored-by: mathiasrw <1063454+mathiasrw@users.noreply.github.com> --- src/cli.ts | 32 ++++++++ src/engine.ts | 8 +- src/output.ts | 30 ++++++++ test/cli/run.sh | 35 +++++++++ test/fuzzy/crossover.test.js | 8 +- test/js/basics.test.js | 9 ++- test/js/changed-names-cli.test.js | 121 ++++++++++++++++++++++++++++++ test/js/crossover.test.js | 4 +- test/js/debug.test.js | 2 +- test/js/fix/705.test.js | 5 +- test/js/halt.test.js | 2 +- test/js/name-delimiter.test.js | 53 +++++++++++++ test/js/null.test.js | 32 ++++++++ test/js/output.test.js | 4 +- test/js/quiet.test.js | 4 +- test/js/verbose.test.js | 6 +- test/js/void-async.test.js | 14 ++-- test/js/void-backup.test.js | 3 +- 18 files changed, 345 insertions(+), 27 deletions(-) create mode 100644 test/js/changed-names-cli.test.js create mode 100644 test/js/name-delimiter.test.js create mode 100644 test/js/null.test.js diff --git a/src/cli.ts b/src/cli.ts index a2e99bad..d3803464 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -110,6 +110,20 @@ const yargs = require('yargs') .describe('Q', 'Never display errors or info') .alias('Q', 'quiet-total') + .boolean('null') + .alias('null', '0') + .describe( + 'null', + 'End each changed file name with a NUL byte instead of a line break (safe for paths with spaces or newlines). Conflicts with --name-delimiter.' + ) + + .string('name-delimiter') + .describe( + 'name-delimiter', + 'Put this string between changed file names instead of a line break. No delimiter after the last name. Escape sequences and €/§ aliases work like in the replacement. Conflicts with --null.' + ) + .conflicts('null', 'name-delimiter') + .boolean('H') .describe('H', 'Halt on first error') .alias('H', 'halt') @@ -421,6 +435,24 @@ function unescapeString(str = '') { } } + if (undefined !== config.nameDelimiter && null !== config.nameDelimiter) { + const rawDelimiter = config.nameDelimiter; + try { + let delimiter = rawDelimiter; + if (!config.voidSection) { + delimiter = delimiter.replace(/§/g, '\\'); + } + delimiter = unescapeString(delimiter); + if (!config.voidEuro) { + delimiter = delimiter.replace(/€/g, '$'); + } + config.nameDelimiter = delimiter; + } catch (e) { + outputConfig(config); + return die('Invalid escape sequence in --name-delimiter:', rawDelimiter); + } + } + /*if(Boolean(process.stdout.isTTY)){ config.output = true; }*/ diff --git a/src/engine.ts b/src/engine.ts index 73b04528..7d06bf8d 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -6,7 +6,7 @@ const globs = require('fast-glob'); const now = new Date(); -import {outputConfig, step, debug, chat, info, error, die} from './output.ts'; +import {outputConfig, step, debug, chat, printChangedName, error, die} from './output.ts'; const re = { euro: /€/g, @@ -124,7 +124,7 @@ function doReplacement(_file_rr: string, _config_rr: any, _data_rr: string) { if (_config_rr.voidBackup) { return fs.promises .writeFile(_file_rr, result, _config_rr.encoding) - .then(() => info(_file_rr), error); + .then(() => printChangedName(_file_rr), error); } //Make sure data is always on disk @@ -142,14 +142,14 @@ function doReplacement(_file_rr: string, _config_rr: any, _data_rr: string) { } catch (e) { return error(e); } - return info(_file_rr); + return printChangedName(_file_rr); } return fs.promises .rename(oriFile, backupFile) .then(() => fs.promises.writeFile(oriFile, result, _config_rr.encoding)) .then(() => _config_rr.keepBackup || fs.promises.unlink(backupFile)) - .then(() => info(_file_rr), error); + .then(() => printChangedName(_file_rr), error); } function handlePipedData(config) { diff --git a/src/output.ts b/src/output.ts index e3f8d6ab..91992f33 100644 --- a/src/output.ts +++ b/src/output.ts @@ -5,10 +5,16 @@ font.red = font.green = font.gray = (str) => str; let config: any = null; +// Tracks whether a changed-file name has already been written, so --name-delimiter +// can separate names without a trailing delimiter after the last one. +let namePrinted = false; + export const outputConfig = function (_config) { config = _config; + namePrinted = false; }; +// Verbose / informational messages stay on stderr (errors, --verbose, --debug). export const info = function (msg, data = '') { if (config.quiet || config.quietTotal) { return; @@ -16,6 +22,30 @@ export const info = function (msg, data = '') { console.error(font.gray(msg), data); }; +// Names of files changed in place go to stdout (like grep -l / gofmt -l / prettier --write). +// Not used together with --output, --output-match or piped data (those paths never call this). +export const printChangedName = function (name) { + if (config.quiet || config.quietTotal) { + return; + } + + if (config.null) { + process.stdout.write(name + '\0'); + return; + } + + if (undefined !== config.nameDelimiter && null !== config.nameDelimiter) { + if (namePrinted) { + process.stdout.write(config.nameDelimiter); + } + process.stdout.write(name); + namePrinted = true; + return; + } + + process.stdout.write(name + '\n'); +}; + export const chat = function (msg, data = '') { if (config.verbose) { info(msg, data); diff --git a/test/cli/run.sh b/test/cli/run.sh index 1795005e..67853316 100644 --- a/test/cli/run.sh +++ b/test/cli/run.sh @@ -48,6 +48,41 @@ rexreplace "b" "\n" my.file assert "cat my.file" "foo\nar" +# Changed file names go to stdout; stderr stays clean (unless verbose/debug/errors) +reset +rexreplace o x my.file > stdout.log 2> stderr.log +assert "cat my.file" "fxxbar" +assert "cat stdout.log" "my.file" +assert "cat stderr.log" "" + +reset +rexreplace o x my.file --output > stdout.log 2> stderr.log +assert "cat my.file" "foobar" +assert "cat stdout.log" "fxxbar" +assert "cat stderr.log" "" + +reset +rexreplace o x my.file --verbose > stdout.log 2> stderr.log +assert "cat my.file" "fxxbar" +assert "cat stdout.log" "my.file" +assert_contains "cat stderr.log" "files found" + +reset +assert "rexreplace a x my.file your.file --name-delimiter ', ' --void-async" "my.file, your.file" +assert "cat my.file" "foobxr" +assert "cat your.file" "xbc123" + +reset +rexreplace o x my.file -0 > stdout.log 2> stderr.log +assert "cat my.file" "fxxbar" +assert "cat stderr.log" "" +# assert() cannot keep NUL bytes; check size: "my.file" (7) + NUL (1) +assert "wc -c < stdout.log | tr -d ' '" "8" + +reset +assert_failure "rexreplace o x my.file --null --name-delimiter ','" + + # Replacement with a real line break, an escaped single quote or a lone backslash at the end (#691) reset rexreplace "b" $'\n' my.file diff --git a/test/fuzzy/crossover.test.js b/test/fuzzy/crossover.test.js index b7b1b4a0..538982af 100644 --- a/test/fuzzy/crossover.test.js +++ b/test/fuzzy/crossover.test.js @@ -70,7 +70,13 @@ describe('crossover', () => { expect(result.exit, note).toBe(null); expect(result.out, note).toBe( - config.outputMatch ? matches : config.output ? replaced : '' + config.outputMatch + ? matches + : config.output + ? replaced + : written && replaced !== text && !config.quiet && !config.quietTotal + ? file + '\n' + : '' ); const expected = written ? replaced : text; diff --git a/test/js/basics.test.js b/test/js/basics.test.js index cb3f4548..feead796 100644 --- a/test/js/basics.test.js +++ b/test/js/basics.test.js @@ -82,11 +82,11 @@ describe('replacement', () => { }); describe('files', () => { - test('are changed in place and named on stderr', async () => { + test('are changed in place and named on stdout', async () => { fs.writeFileSync('my.file', 'foobar'); const result = await rr('o', 'x', {includeGlob: ['my.file']}); expect(fs.readFileSync('my.file', 'utf8')).toBe('fxxbar'); - expect(result).toEqual({out: '', err: 'my.file \n', exit: null}); + expect(result).toEqual({out: 'my.file\n', err: '', exit: null}); expect(fs.readdirSync('.')).toEqual(['my.file']); }); @@ -129,7 +129,8 @@ describe('files', () => { includeGlob: ['my.file', '*.file', './my.file', 'my.*'], }); expect(fs.readFileSync('my.file', 'utf8')).toBe('foooobar'); - expect(result.err).toBe('my.file \n'); + expect(result.out).toBe('my.file\n'); + expect(result.err).toBe(''); }); test('are handled in alphabetical order', async () => { @@ -207,7 +208,7 @@ describe('piped data', () => { test('is ignored if files are given', async () => { fs.writeFileSync('my.file', 'foobar'); const result = await rr('o', 'x', {pipedData: 'boo', includeGlob: ['my.file']}); - expect(result.out).toBe(''); + expect(result.out).toBe('my.file\n'); expect(fs.readFileSync('my.file', 'utf8')).toBe('fxxbar'); }); diff --git a/test/js/changed-names-cli.test.js b/test/js/changed-names-cli.test.js new file mode 100644 index 00000000..ce57b916 --- /dev/null +++ b/test/js/changed-names-cli.test.js @@ -0,0 +1,121 @@ +// CLI-level coverage for changed file names on stdout, --null/-0 and --name-delimiter. +import {describe, test, expect} from 'bun:test'; +import {spawnSync} from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +const cli = path.join(import.meta.dir, '../../src/cli.ts'); + +function run(...args) { + const {stdout, stderr, status} = spawnSync(process.execPath, [cli, ...args], { + input: '', + encoding: 'utf8', + }); + return {out: stdout, err: stderr, exit: status}; +} + +describe('changed file names on stdout (CLI)', () => { + test('prints each changed file name on stdout', () => { + fs.writeFileSync('my.file', 'foobar'); + expect(run('o', 'x', 'my.file')).toEqual({out: 'my.file\n', err: '', exit: 0}); + expect(fs.readFileSync('my.file', 'utf8')).toBe('fxxbar'); + }); + + test('keeps verbose chat on stderr and names on stdout', () => { + fs.writeFileSync('my.file', 'foobar'); + const result = run('o', 'x', 'my.file', '--verbose'); + expect(result.out).toBe('my.file\n'); + expect(result.err).toContain('files found'); + expect(result.err).toContain('Open'); + expect(result.err).not.toContain('my.file\n'); + expect(result.exit).toBe(0); + }); + + test('--output keeps the result on stdout and no file name', () => { + fs.writeFileSync('my.file', 'foobar'); + expect(run('o', 'x', 'my.file', '--output')).toEqual({ + out: 'fxxbar', + err: '', + exit: 0, + }); + expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar'); + }); + + test('--quiet suppresses the names', () => { + fs.writeFileSync('my.file', 'foobar'); + expect(run('o', 'x', 'my.file', '--quiet')).toEqual({out: '', err: '', exit: 0}); + }); +}); + +describe('--null / -0 (CLI)', () => { + test('ends names with NUL', () => { + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + // --void-async keeps alphabetical name order + expect(run('o', 'x', '*.file', '--null', '--void-async')).toEqual({ + out: 'a.file\0b.file\0', + err: '', + exit: 0, + }); + }); + + test('-0 is an alias for --null', () => { + fs.writeFileSync('my.file', 'foobar'); + expect(run('o', 'x', 'my.file', '-0')).toEqual({out: 'my.file\0', err: '', exit: 0}); + }); +}); + +describe('--name-delimiter (CLI)', () => { + test('puts the delimiter between names', () => { + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + expect(run('o', 'x', '*.file', '--name-delimiter', ', ', '--void-async')).toEqual({ + out: 'a.file, b.file', + err: '', + exit: 0, + }); + }); + + test('understands escape sequences and § like the replacement', () => { + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + expect(run('o', 'x', '*.file', '--name-delimiter', '\\t', '--void-async').out).toBe( + 'a.file\tb.file' + ); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + expect(run('o', 'x', '*.file', '--name-delimiter', '§t', '--void-async').out).toBe( + 'a.file\tb.file' + ); + }); + + test('understands € as $ unless --void-euro', () => { + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + expect(run('o', 'x', '*.file', '--name-delimiter', '€', '--void-async').out).toBe( + 'a.file$b.file' + ); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + expect( + run('o', 'x', '*.file', '--name-delimiter', '€', '--void-euro', '--void-async').out + ).toBe('a.file€b.file'); + }); + + test('rejects an invalid escape sequence', () => { + fs.writeFileSync('my.file', 'foobar'); + const result = run('o', 'x', 'my.file', '--name-delimiter', '\\x4'); + expect(result.out).toBe(''); + expect(result.err).toContain('Invalid escape sequence in --name-delimiter'); + expect(result.exit).toBe(1); + expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar'); + }); + + test('conflicts with --null', () => { + fs.writeFileSync('my.file', 'foobar'); + const result = run('o', 'x', 'my.file', '--null', '--name-delimiter', ','); + expect(result.exit).not.toBe(0); + expect(result.err.length).toBeGreaterThan(0); + expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar'); + }); +}); diff --git a/test/js/crossover.test.js b/test/js/crossover.test.js index 25358595..9d8642c6 100644 --- a/test/js/crossover.test.js +++ b/test/js/crossover.test.js @@ -77,7 +77,9 @@ describe('every pair of flags', () => { : 'o\no\n' : config.output ? replaced - : '' + : written && !config.quiet && !config.quietTotal + ? 'my.file\n' + : '' ); expect(fs.readFileSync('my.file', 'utf8')).toBe(written ? replaced : 'foo'); diff --git a/test/js/debug.test.js b/test/js/debug.test.js index 0ea41136..ba8781c0 100644 --- a/test/js/debug.test.js +++ b/test/js/debug.test.js @@ -6,13 +6,13 @@ describe('--debug', () => { test('prints what happens to a file', async () => { fs.writeFileSync('my.file', 'foobar'); const result = await rr('o', 'x', {includeGlob: ['my.file'], debug: true}); + expect(result.out).toBe('my.file\n'); expect(result.err).toBe( [ '"1 files found "', '"Open sync: my.file "', '"Work on content from: my.file"', '"Write new content to: my.file"', - 'my.file ', '', ].join('\n') ); diff --git a/test/js/fix/705.test.js b/test/js/fix/705.test.js index 17511b45..5dd71057 100644 --- a/test/js/fix/705.test.js +++ b/test/js/fix/705.test.js @@ -25,7 +25,7 @@ describe('#705 the engine returns a promise that resolves when all files are don ])('nothing is left to do with %s', async (name, flags, files) => { fs.writeFileSync('my.file', 'foobar'); const result = await rr('o', 'x', {includeGlob: ['my.file'], ...flags}); - expect(result).toEqual({out: '', err: 'my.file \n', exit: null}); + expect(result).toEqual({out: 'my.file\n', err: '', exit: null}); expect(fs.readFileSync('my.file', 'utf8')).toBe('fxxbar'); expect(fs.readdirSync('.')).toHaveLength(files); }); @@ -37,7 +37,8 @@ describe('#705 the engine returns a promise that resolves when all files are don const result = await rr('o', 'x', {includeGlob: ['*.file'], voidAsync: false}); expect(content(names)).toEqual(names.map(() => 'fxx')); - expect(result.err.trim().split(' \n').sort()).toEqual(names.sort()); + expect(result.out.trim().split('\n').sort()).toEqual(names.sort()); + expect(result.err).toBe(''); expect(fs.readdirSync('.')).toHaveLength(names.length); }); diff --git a/test/js/halt.test.js b/test/js/halt.test.js index f1f90713..31fed6e8 100644 --- a/test/js/halt.test.js +++ b/test/js/halt.test.js @@ -18,7 +18,7 @@ describe('--halt', () => { test('changes nothing when all goes well', async () => { fs.writeFileSync('my.file', 'foobar'); const result = await rr('o', 'x', {includeGlob: ['my.file'], halt: true}); - expect(result).toEqual({out: '', err: 'my.file \n', exit: null}); + expect(result).toEqual({out: 'my.file\n', err: '', exit: null}); expect(fs.readFileSync('my.file', 'utf8')).toBe('fxxbar'); }); diff --git a/test/js/name-delimiter.test.js b/test/js/name-delimiter.test.js new file mode 100644 index 00000000..f8041915 --- /dev/null +++ b/test/js/name-delimiter.test.js @@ -0,0 +1,53 @@ +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {rr} from './helper.js'; + +describe('--name-delimiter', () => { + test('puts the delimiter between names and not after the last', async () => { + ['a', 'b', 'c'].forEach((name) => fs.writeFileSync(name + '.file', 'foo')); + const result = await rr('o', 'x', { + includeGlob: ['*.file'], + nameDelimiter: ', ', + }); + expect(result.out).toBe('a.file, b.file, c.file'); + expect(result.err).toBe(''); + }); + + test('prints a single name with no delimiter or line break', async () => { + fs.writeFileSync('my.file', 'foobar'); + const result = await rr('o', 'x', { + includeGlob: ['my.file'], + nameDelimiter: ', ', + }); + expect(result.out).toBe('my.file'); + expect(result.err).toBe(''); + }); + + test('joins names with the given delimiter string', async () => { + ['a', 'b'].forEach((name) => fs.writeFileSync(name + '.file', 'foo')); + // Engine path gets the already-unescaped delimiter (CLI does unescaping). + const result = await rr('o', 'x', { + includeGlob: ['*.file'], + nameDelimiter: '\t', + }); + expect(result.out).toBe('a.file\tb.file'); + }); + + test('still respects --quiet', async () => { + fs.writeFileSync('my.file', 'foobar'); + expect( + await rr('o', 'x', {includeGlob: ['my.file'], nameDelimiter: ',', quiet: true}) + ).toEqual({out: '', err: '', exit: null}); + }); + + test('does not print names when using --output', async () => { + fs.writeFileSync('my.file', 'foobar'); + const result = await rr('o', 'x', { + includeGlob: ['my.file'], + nameDelimiter: ', ', + output: true, + }); + expect(result.out).toBe('fxxbar'); + expect(result.err).toBe(''); + }); +}); diff --git a/test/js/null.test.js b/test/js/null.test.js new file mode 100644 index 00000000..813ddc59 --- /dev/null +++ b/test/js/null.test.js @@ -0,0 +1,32 @@ +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {rr} from './helper.js'; + +describe('--null', () => { + test('ends each changed file name with a NUL byte', async () => { + ['a', 'b'].forEach((name) => fs.writeFileSync(name + '.file', 'foo')); + const result = await rr('o', 'x', {includeGlob: ['*.file'], null: true}); + expect(result.out).toBe('a.file\0b.file\0'); + expect(result.err).toBe(''); + }); + + test('still respects --quiet', async () => { + fs.writeFileSync('my.file', 'foobar'); + expect(await rr('o', 'x', {includeGlob: ['my.file'], null: true, quiet: true})).toEqual({ + out: '', + err: '', + exit: null, + }); + }); + + test('is not used when nothing is written to disk', async () => { + fs.writeFileSync('my.file', 'foobar'); + const result = await rr('o', 'x', { + includeGlob: ['my.file'], + null: true, + output: true, + }); + expect(result.out).toBe('fxxbar'); + expect(result.err).toBe(''); + }); +}); diff --git a/test/js/output.test.js b/test/js/output.test.js index f427b340..5c2d6cd2 100644 --- a/test/js/output.test.js +++ b/test/js/output.test.js @@ -12,7 +12,9 @@ describe('--output', () => { test('is not the default for files', async () => { fs.writeFileSync('my.file', 'foobar'); - expect((await rr('o', 'x', {includeGlob: ['my.file']})).out).toBe(''); + // Default in-place edit prints the changed file name on stdout (not the content) + expect((await rr('o', 'x', {includeGlob: ['my.file']})).out).toBe('my.file\n'); + expect(fs.readFileSync('my.file', 'utf8')).toBe('fxxbar'); }); test('sends the content even if nothing was replaced', async () => { diff --git a/test/js/quiet.test.js b/test/js/quiet.test.js index f053c531..93878e3d 100644 --- a/test/js/quiet.test.js +++ b/test/js/quiet.test.js @@ -12,7 +12,7 @@ describe('--quiet', () => { test('is not the default', async () => { fs.writeFileSync('my.file', 'foobar'); - expect((await rr('o', 'x', {includeGlob: ['my.file']})).err).toBe('my.file \n'); + expect((await rr('o', 'x', {includeGlob: ['my.file']})).out).toBe('my.file\n'); }); test('still stops on an invalid pattern', async () => { @@ -42,7 +42,7 @@ describe('--quiet', () => { fs.writeFileSync('my.file', 'foobar'); const result = await rr('o', 'x', {includeGlob: ['my.file'], quiet: true, debug: true}); expect(result.err).toContain('Work on content from: my.file'); - expect(result.err).not.toContain('my.file \n'); + expect(result.out).toBe(''); }); test.each(['output', 'outputMatch'])('with --%s stdout is not affected', async (flag) => { diff --git a/test/js/verbose.test.js b/test/js/verbose.test.js index 8a5ad54d..efb449c9 100644 --- a/test/js/verbose.test.js +++ b/test/js/verbose.test.js @@ -6,13 +6,13 @@ describe('--verbose', () => { test('tells what is going on', async () => { fs.writeFileSync('my.file', 'foobar'); const result = await rr('o', 'x', {includeGlob: ['my.file'], verbose: true}); - expect(result.err).toBe('1 files found \nOpen sync: my.file \nmy.file \n'); - expect(result.out).toBe(''); + expect(result.err).toBe('1 files found \nOpen sync: my.file \n'); + expect(result.out).toBe('my.file\n'); }); test('is not the default', async () => { fs.writeFileSync('my.file', 'foobar'); - expect((await rr('o', 'x', {includeGlob: ['my.file']})).err).toBe('my.file \n'); + expect((await rr('o', 'x', {includeGlob: ['my.file']})).out).toBe('my.file\n'); }); test('tells when nothing was changed in a file', async () => { diff --git a/test/js/void-async.test.js b/test/js/void-async.test.js index e8ed9739..b3001b27 100644 --- a/test/js/void-async.test.js +++ b/test/js/void-async.test.js @@ -8,21 +8,22 @@ describe('--void-async', () => { test('is done with a file before the next one is opened', async () => { ['a', 'b'].forEach((name) => fs.writeFileSync(name + '.file', 'foo')); const config = {includeGlob: ['*.file'], voidAsync: true, verbose: true}; - expect((await rr('o', 'x', config)).err).toBe( - '2 files found \nOpen sync: a.file \na.file \nOpen sync: b.file \nb.file \n' - ); + const result = await rr('o', 'x', config); + expect(result.err).toBe('2 files found \nOpen sync: a.file \nOpen sync: b.file \n'); + expect(result.out).toBe('a.file\nb.file\n'); }); test('is not the default: all files are opened before the first one is done', async () => { ['a', 'b'].forEach((name) => fs.writeFileSync(name + '.file', 'foo')); const config = {includeGlob: ['*.file'], voidAsync: false, verbose: true}; - const lines = (await rr('o', 'x', config)).err.split(' \n'); + const result = await rr('o', 'x', config); + const lines = result.err.split(' \n'); expect(lines.slice(0, 3)).toEqual([ '2 files found', 'Open async: a.file', 'Open async: b.file', ]); - expect(lines.slice(3).sort()).toEqual(['', 'a.file', 'b.file']); + expect(result.out.trim().split('\n').sort()).toEqual(['a.file', 'b.file']); expect(['a', 'b'].map((name) => fs.readFileSync(name + '.file', 'utf8'))).toEqual([ 'fxx', 'fxx', @@ -32,7 +33,8 @@ describe('--void-async', () => { test('handles the files one by one in alphabetical order', async () => { ['c', 'a', 'd', 'b'].forEach((name) => fs.writeFileSync(name + '.file', 'foo')); const result = await rr('o', 'x', {includeGlob: ['*.file'], voidAsync: true}); - expect(result.err).toBe('a.file \nb.file \nc.file \nd.file \n'); + expect(result.out).toBe('a.file\nb.file\nc.file\nd.file\n'); + expect(result.err).toBe(''); }); test('leaves no temporary files behind', async () => { diff --git a/test/js/void-backup.test.js b/test/js/void-backup.test.js index 30584c5c..619fedc9 100644 --- a/test/js/void-backup.test.js +++ b/test/js/void-backup.test.js @@ -8,7 +8,8 @@ describe('--void-backup', () => { const result = await rr('o', 'x', {includeGlob: ['my.file'], voidBackup: true}); expect(fs.readFileSync('my.file', 'utf8')).toBe('fxxbar'); expect(fs.readdirSync('.')).toEqual(['my.file']); - expect(result.err).toBe('my.file \n'); + expect(result.out).toBe('my.file\n'); + expect(result.err).toBe(''); }); test('does not write a file where nothing was replaced', async () => { From bd04fb50c8149e4db4522e60fa388d69e20a6ccd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:46:21 +0000 Subject: [PATCH 3/5] =?UTF-8?q?Align=20--name-delimiter=20=C2=A7=20handlin?= =?UTF-8?q?g=20with=20replacement=20after=20unescape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: mathiasrw <1063454+mathiasrw@users.noreply.github.com> --- src/cli.ts | 5 +++++ test/js/changed-names-cli.test.js | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/src/cli.ts b/src/cli.ts index d3803464..1e8613d2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -438,6 +438,8 @@ function unescapeString(str = '') { if (undefined !== config.nameDelimiter && null !== config.nameDelimiter) { const rawDelimiter = config.nameDelimiter; try { + // Same treatment as the replacement: § → \ before unescaping (so §n is a newline), + // then after unescaping apply the €/$ and §/\ aliases again (so \u00a7 becomes \). let delimiter = rawDelimiter; if (!config.voidSection) { delimiter = delimiter.replace(/§/g, '\\'); @@ -446,6 +448,9 @@ function unescapeString(str = '') { if (!config.voidEuro) { delimiter = delimiter.replace(/€/g, '$'); } + if (!config.voidSection) { + delimiter = delimiter.replace(/§/g, '\\'); + } config.nameDelimiter = delimiter; } catch (e) { outputConfig(config); diff --git a/test/js/changed-names-cli.test.js b/test/js/changed-names-cli.test.js index ce57b916..6f944342 100644 --- a/test/js/changed-names-cli.test.js +++ b/test/js/changed-names-cli.test.js @@ -87,6 +87,12 @@ describe('--name-delimiter (CLI)', () => { expect(run('o', 'x', '*.file', '--name-delimiter', '§t', '--void-async').out).toBe( 'a.file\tb.file' ); + // § from a unicode escape is turned into \ after unescaping (same as replacement) + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + expect(run('o', 'x', '*.file', '--name-delimiter', '\\u00a7', '--void-async').out).toBe( + 'a.file\\b.file' + ); }); test('understands € as $ unless --void-euro', () => { From 565b92fab2406fcb2edf0d7666f06a38f13112a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:21:59 +0000 Subject: [PATCH 4/5] Add --absolute-path (-a) for full paths of changed files Co-authored-by: mathiasrw <1063454+mathiasrw@users.noreply.github.com> --- src/cli.ts | 7 ++++ src/engine.ts | 11 ++++-- test/cli/run.sh | 23 +++++++++++++ test/js/absolute-path.test.js | 56 +++++++++++++++++++++++++++++++ test/js/changed-names-cli.test.js | 25 ++++++++++++++ 5 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 test/js/absolute-path.test.js diff --git a/src/cli.ts b/src/cli.ts index 1e8613d2..67d8a27e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -124,6 +124,13 @@ const yargs = require('yargs') ) .conflicts('null', 'name-delimiter') + .boolean('a') + .alias('a', 'absolute-path') + .describe( + 'a', + 'Print each changed file name as a full path from `/`. Default is to print the path the way it was given.' + ) + .boolean('H') .describe('H', 'Halt on first error') .alias('H', 'halt') diff --git a/src/engine.ts b/src/engine.ts index 7d06bf8d..46ee95ad 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -120,11 +120,16 @@ function doReplacement(_file_rr: string, _config_rr: any, _data_rr: string) { debug('Write new content to: ' + _file_rr); + // Name as given, or full path from `/` with --absolute-path (same as `file` in --replacement-js) + const nameToPrint = _config_rr.absolutePath + ? path.normalize(path.join(process.cwd(), _file_rr)) + : _file_rr; + // Write directly to the same file (if the process is killed all new and old data is lost) if (_config_rr.voidBackup) { return fs.promises .writeFile(_file_rr, result, _config_rr.encoding) - .then(() => printChangedName(_file_rr), error); + .then(() => printChangedName(nameToPrint), error); } //Make sure data is always on disk @@ -142,14 +147,14 @@ function doReplacement(_file_rr: string, _config_rr: any, _data_rr: string) { } catch (e) { return error(e); } - return printChangedName(_file_rr); + return printChangedName(nameToPrint); } return fs.promises .rename(oriFile, backupFile) .then(() => fs.promises.writeFile(oriFile, result, _config_rr.encoding)) .then(() => _config_rr.keepBackup || fs.promises.unlink(backupFile)) - .then(() => printChangedName(_file_rr), error); + .then(() => printChangedName(nameToPrint), error); } function handlePipedData(config) { diff --git a/test/cli/run.sh b/test/cli/run.sh index 67853316..4b531ebe 100644 --- a/test/cli/run.sh +++ b/test/cli/run.sh @@ -82,6 +82,29 @@ assert "wc -c < stdout.log | tr -d ' '" "8" reset assert_failure "rexreplace o x my.file --null --name-delimiter ','" +# --absolute-path / -a prints the full path; default keeps the path as given +reset +mkdir -p docs +echo foo > docs/a.md +rexreplace o x 'docs/*.md' > stdout.log 2> stderr.log +assert "cat stdout.log" "docs/a.md" +assert "cat stderr.log" "" +assert "cat docs/a.md" "fxx" + +reset +mkdir -p docs +echo foo > docs/a.md +rexreplace o x 'docs/*.md' --absolute-path > stdout.log 2> stderr.log +assert_startswith "cat stdout.log" "/" +assert_endswith "cat stdout.log" "/docs/a.md" +assert "cat stderr.log" "" + +reset +echo foobar > my.file +rexreplace o x my.file -a > stdout.log 2> stderr.log +assert_startswith "cat stdout.log" "/" +assert_endswith "cat stdout.log" "/my.file" + # Replacement with a real line break, an escaped single quote or a lone backslash at the end (#691) reset diff --git a/test/js/absolute-path.test.js b/test/js/absolute-path.test.js new file mode 100644 index 00000000..e26cc181 --- /dev/null +++ b/test/js/absolute-path.test.js @@ -0,0 +1,56 @@ +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import path from 'path'; +import {rr} from './helper.js'; + +describe('--absolute-path', () => { + test('prints the full path of each changed file', async () => { + fs.mkdirSync('docs'); + fs.writeFileSync('docs/a.md', 'foo'); + const result = await rr('o', 'x', { + includeGlob: ['docs/*.md'], + absolutePath: true, + }); + expect(result.out).toBe(path.normalize(path.join(process.cwd(), 'docs/a.md')) + '\n'); + expect(result.err).toBe(''); + expect(fs.readFileSync('docs/a.md', 'utf8')).toBe('fxx'); + }); + + test('is not the default: the path is printed the way it was given', async () => { + fs.mkdirSync('docs'); + fs.writeFileSync('docs/a.md', 'foo'); + const result = await rr('o', 'x', {includeGlob: ['docs/*.md']}); + expect(result.out).toBe('docs/a.md\n'); + expect(result.err).toBe(''); + }); + + test('still respects --quiet', async () => { + fs.writeFileSync('my.file', 'foobar'); + expect( + await rr('o', 'x', {includeGlob: ['my.file'], absolutePath: true, quiet: true}) + ).toEqual({out: '', err: '', exit: null}); + }); + + test('works with --null', async () => { + fs.writeFileSync('my.file', 'foobar'); + const full = path.normalize(path.join(process.cwd(), 'my.file')); + const result = await rr('o', 'x', { + includeGlob: ['my.file'], + absolutePath: true, + null: true, + }); + expect(result.out).toBe(full + '\0'); + }); + + test('works with --name-delimiter on several files', async () => { + ['a', 'b'].forEach((name) => fs.writeFileSync(name + '.file', 'foo')); + const a = path.normalize(path.join(process.cwd(), 'a.file')); + const b = path.normalize(path.join(process.cwd(), 'b.file')); + const result = await rr('o', 'x', { + includeGlob: ['*.file'], + absolutePath: true, + nameDelimiter: '|', + }); + expect(result.out).toBe(a + '|' + b); + }); +}); diff --git a/test/js/changed-names-cli.test.js b/test/js/changed-names-cli.test.js index 6f944342..50f51f86 100644 --- a/test/js/changed-names-cli.test.js +++ b/test/js/changed-names-cli.test.js @@ -47,6 +47,31 @@ describe('changed file names on stdout (CLI)', () => { }); }); +describe('--absolute-path / -a (CLI)', () => { + test('prints the full path', () => { + fs.mkdirSync('docs'); + fs.writeFileSync('docs/a.md', 'foo'); + const full = path.normalize(path.join(process.cwd(), 'docs/a.md')); + expect(run('o', 'x', 'docs/*.md', '--absolute-path')).toEqual({ + out: full + '\n', + err: '', + exit: 0, + }); + }); + + test('-a is an alias', () => { + fs.writeFileSync('my.file', 'foobar'); + const full = path.normalize(path.join(process.cwd(), 'my.file')); + expect(run('o', 'x', 'my.file', '-a')).toEqual({out: full + '\n', err: '', exit: 0}); + }); + + test('default still prints the path as given', () => { + fs.mkdirSync('docs'); + fs.writeFileSync('docs/a.md', 'foo'); + expect(run('o', 'x', 'docs/*.md').out).toBe('docs/a.md\n'); + }); +}); + describe('--null / -0 (CLI)', () => { test('ends names with NUL', () => { fs.writeFileSync('a.file', 'foo'); From 8150175be528342b9a11549730a87f2157d48772 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:38:59 +0000 Subject: [PATCH 5/5] Address review: keep info(), simplify name delimiter, dedupe files Co-authored-by: mathiasrw <1063454+mathiasrw@users.noreply.github.com> --- src/cli.ts | 33 ++++++++++++++++----------------- src/engine.ts | 27 ++++++++++++++++++++++----- src/output.ts | 50 ++++++++++++++++++++++++++++---------------------- 3 files changed, 66 insertions(+), 44 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 67d8a27e..4095263b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -428,6 +428,12 @@ function unescapeString(str = '') { config.excludeRe = [].concat(yargs.argv.excludeRe).filter(Boolean); + // Ending after each changed name: NUL with --null, custom --name-delimiter, else newline + const customNameDelimiter = + !config.null && undefined !== config.nameDelimiter && null !== config.nameDelimiter; + config.nameDelimiterBetween = !!customNameDelimiter; + const rawNameDelimiter = customNameDelimiter ? config.nameDelimiter : null; + if (config.replacementJs) { config.replacement = replacement; } else { @@ -442,27 +448,20 @@ function unescapeString(str = '') { } } - if (undefined !== config.nameDelimiter && null !== config.nameDelimiter) { - const rawDelimiter = config.nameDelimiter; + if (config.null) { + config.nameDelimiter = '\0'; + } else if (customNameDelimiter) { + // Same treatment as the replacement (§ before unescape; €/§ again in the engine) try { - // Same treatment as the replacement: § → \ before unescaping (so §n is a newline), - // then after unescaping apply the €/$ and §/\ aliases again (so \u00a7 becomes \). - let delimiter = rawDelimiter; - if (!config.voidSection) { - delimiter = delimiter.replace(/§/g, '\\'); - } - delimiter = unescapeString(delimiter); - if (!config.voidEuro) { - delimiter = delimiter.replace(/€/g, '$'); - } - if (!config.voidSection) { - delimiter = delimiter.replace(/§/g, '\\'); - } - config.nameDelimiter = delimiter; + config.nameDelimiter = unescapeString( + config.voidSection ? rawNameDelimiter : String(rawNameDelimiter).replace(/§/g, '\\') + ); } catch (e) { outputConfig(config); - return die('Invalid escape sequence in --name-delimiter:', rawDelimiter); + return die('Invalid escape sequence in --name-delimiter:', rawNameDelimiter); } + } else { + config.nameDelimiter = '\n'; } /*if(Boolean(process.stdout.isTTY)){ diff --git a/src/engine.ts b/src/engine.ts index 46ee95ad..be530194 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -6,7 +6,7 @@ const globs = require('fast-glob'); const now = new Date(); -import {outputConfig, step, debug, chat, printChangedName, error, die} from './output.ts'; +import {outputConfig, step, debug, chat, info, flushChangedNames, error, die} from './output.ts'; const re = { euro: /€/g, @@ -28,6 +28,18 @@ const runtimeEngine = process.versions.bun ? 'JSC' : 'V8'; // Returns a promise that resolves when all files are done export async function engine(config: any = {engine: 'default'}) { + // Ending after each changed name (CLI may already have set these) + if (config.null) { + config.nameDelimiter = '\0'; + config.nameDelimiterBetween = false; + } else if (null == config.nameDelimiter || undefined === config.nameDelimiter) { + config.nameDelimiter = '\n'; + config.nameDelimiterBetween = false; + } else if (undefined === config.nameDelimiterBetween) { + // Engine called directly with a custom delimiter + config.nameDelimiterBetween = true; + } + outputConfig(config); step('Displaying steps for:'); @@ -41,6 +53,9 @@ export async function engine(config: any = {engine: 'default'}) { config.regex = getFinalRegex(config.pattern, config) || ''; + // nameDelimiter is treated like the replacement (€ / § aliases) + config.nameDelimiter = replacePlaceholders(config.nameDelimiter, config); + step(config); if (handlePipedData(config)) { @@ -72,6 +87,7 @@ export async function engine(config: any = {engine: 'default'}) { .map((filepath) => openFile(filepath, config)); await Promise.all(work); + flushChangedNames(); } function openFile(file, config) { @@ -129,7 +145,7 @@ function doReplacement(_file_rr: string, _config_rr: any, _data_rr: string) { if (_config_rr.voidBackup) { return fs.promises .writeFile(_file_rr, result, _config_rr.encoding) - .then(() => printChangedName(nameToPrint), error); + .then(() => info(nameToPrint), error); } //Make sure data is always on disk @@ -147,14 +163,14 @@ function doReplacement(_file_rr: string, _config_rr: any, _data_rr: string) { } catch (e) { return error(e); } - return printChangedName(nameToPrint); + return info(nameToPrint); } return fs.promises .rename(oriFile, backupFile) .then(() => fs.promises.writeFile(oriFile, result, _config_rr.encoding)) .then(() => _config_rr.keepBackup || fs.promises.unlink(backupFile)) - .then(() => printChangedName(nameToPrint), error); + .then(() => info(nameToPrint), error); } function handlePipedData(config) { @@ -656,5 +672,6 @@ function getFilePaths(conf) { filesToInclude = filesToInclude.filter((el) => !filesToExclude.includes(el)); } - return filesToInclude.sort(); + // Do not process the same file multiple times (overlapping globs) + return [...new Set(filesToInclude)].sort(); } diff --git a/src/output.ts b/src/output.ts index 91992f33..0bc971d9 100644 --- a/src/output.ts +++ b/src/output.ts @@ -5,50 +5,56 @@ font.red = font.green = font.gray = (str) => str; let config: any = null; -// Tracks whether a changed-file name has already been written, so --name-delimiter -// can separate names without a trailing delimiter after the last one. -let namePrinted = false; +// Same path must not be reported twice (e.g. overlapping globs) +const reported = new Set(); export const outputConfig = function (_config) { config = _config; - namePrinted = false; + reported.clear(); + if (config) { + config.changedNames = []; + } }; -// Verbose / informational messages stay on stderr (errors, --verbose, --debug). +// Names of files changed in place go to stdout (like grep -l / gofmt -l / prettier --write). +// config.nameDelimiter is '\n' by default, '\0' with --null, or the custom --name-delimiter. +// A custom delimiter is joined between names only (no trailing delimiter); see flushChangedNames. export const info = function (msg, data = '') { if (config.quiet || config.quietTotal) { return; } - console.error(font.gray(msg), data); -}; -// Names of files changed in place go to stdout (like grep -l / gofmt -l / prettier --write). -// Not used together with --output, --output-match or piped data (those paths never call this). -export const printChangedName = function (name) { - if (config.quiet || config.quietTotal) { + const name = + '' === data || undefined === data || null === data ? String(msg) : String(msg) + data; + if (reported.has(name)) { return; } + reported.add(name); - if (config.null) { - process.stdout.write(name + '\0'); + if (config.nameDelimiterBetween) { + config.changedNames.push(name); return; } - if (undefined !== config.nameDelimiter && null !== config.nameDelimiter) { - if (namePrinted) { - process.stdout.write(config.nameDelimiter); - } - process.stdout.write(name); - namePrinted = true; + process.stdout.write(name + config.nameDelimiter); +}; + +// Write custom --name-delimiter names in one go (no delimiter after the last name) +export const flushChangedNames = function () { + if (config.quiet || config.quietTotal || !config.nameDelimiterBetween) { return; } - - process.stdout.write(name + '\n'); + if (config.changedNames && config.changedNames.length) { + process.stdout.write(config.changedNames.join(config.nameDelimiter)); + } }; +// Verbose chatter stays on stderr (errors, --verbose, --debug) export const chat = function (msg, data = '') { if (config.verbose) { - info(msg, data); + if (!(config.quiet || config.quietTotal)) { + console.error(font.gray(msg), data); + } } else { debug(msg + ' ' + data); }