From e0d4cf99dfa3ac2e6259f1a03fd6f75ed27f24b2 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 14:00:09 +1100 Subject: [PATCH 01/27] Handle files in tests with the sync functions of fs --- devguide.md | 2 +- test/cli/debug.test.js | 9 +++--- test/cli/delimiter.test.js | 63 +++++++++++++++++++------------------ test/cli/encoding.test.js | 5 +-- test/cli/fix/691.test.js | 5 +-- test/cli/fix/733.test.js | 5 +-- test/cli/fix/736.test.js | 5 +-- test/cli/fix/742.test.js | 7 +++-- test/cli/glob-pipe.test.js | 13 ++++---- test/cli/names.test.js | 29 ++++++++--------- test/cli/nl.test.js | 19 +++++------ test/cli/null-bytes.test.js | 9 +++--- test/cli/pipe.test.js | 21 +++++++------ test/engine/fix/703.test.js | 52 ++++++++++++++++-------------- test/engine/fix/708.test.js | 18 +++++------ test/engine/fix/719.test.js | 11 ++++--- test/engine/fix/726.test.js | 29 ++++++++--------- test/engine/utf8.test.js | 41 ++++++++++++------------ test/fuzzy/output.test.js | 13 +++++--- 19 files changed, 188 insertions(+), 168 deletions(-) diff --git a/devguide.md b/devguide.md index ce4dd7c7..73b3a8e5 100644 --- a/devguide.md +++ b/devguide.md @@ -39,7 +39,7 @@ All output goes through `src/output.ts`. Pick the function by who the message is - The folder tells what is tested, so a file is named after the feature: `test/engine/.test.js` tests the engine with `rr()`, and `test/cli/.test.js` tests the CLI by running it with Bun's `$` shell, like ``sh($`bun ${cli} o x my.file`)``. - A test for an issue goes in `test/engine/fix/.test.js` or `test/cli/fix/.test.js`. - `test/cli/run.sh` is a demo of how RexReplace is used: a few clear examples for each feature. Checks of many combinations go in `test/cli/.test.js`. -- Use Bun native file IO (`Bun.write`, `Bun.file`) in new tests (#734). +- Handle files in tests with the sync functions of `fs`, like `fs.writeFileSync` and `fs.readFileSync`, so there is no `await` for files. Each test writes the files it uses itself, so you can see them next to the command. - Names in tests say what they hold, like `expectedOutput`, not `named`. - Async work can finish in any order. Do not expect an order the code does not promise. diff --git a/test/cli/debug.test.js b/test/cli/debug.test.js index 36950668..b2e48d55 100644 --- a/test/cli/debug.test.js +++ b/test/cli/debug.test.js @@ -1,4 +1,5 @@ import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; import {$} from 'bun'; import {cli, sh} from '../helper.js'; @@ -6,7 +7,7 @@ describe('--debug', () => { test.each(['--debug', '-d'])( '%s prints only the status report, with what went into the engine and what it did', async flag => { - await Bun.write('my.file', 'foobar'); + fs.writeFileSync('my.file', 'foobar'); const result = await sh($`bun ${cli} o x my.file ${flag}`); expect(result.err).toBe(''); expect(result.exit).toBe(0); @@ -26,12 +27,12 @@ describe('--debug', () => { 'Work on content from: my.file', 'Write new content to: my.file', ]); - expect(await Bun.file('my.file').text()).toBe('fxxbar'); + expect(fs.readFileSync('my.file', 'utf8')).toBe('fxxbar'); } ); test('still prints the result of --output before the status report', async () => { - await Bun.write('my.file', 'foobar'); + fs.writeFileSync('my.file', 'foobar'); const result = await sh($`bun ${cli} o x my.file --debug --output`); expect(result.out.startsWith('fxxbar\n\n{')).toBe(true); expect(JSON.parse(result.out.slice('fxxbar\n'.length)).files.updated).toEqual(['my.file']); @@ -45,7 +46,7 @@ describe('--debug', () => { }); test('gives the error of an invalid pattern in the status report', async () => { - await Bun.write('my.file', 'foobar'); + fs.writeFileSync('my.file', 'foobar'); const result = await sh($`bun ${cli} '(' x my.file --debug`); expect(result.err).toBe(''); expect(result.exit).toBe(1); diff --git a/test/cli/delimiter.test.js b/test/cli/delimiter.test.js index 4eddc724..5c00b819 100644 --- a/test/cli/delimiter.test.js +++ b/test/cli/delimiter.test.js @@ -1,27 +1,28 @@ // The delimiters after the names and after the content, as given on the command line import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; import {$} from 'bun'; import {cli, sh} from '../helper.js'; describe('--name-delimiter', () => { test('goes after each name', async () => { - await Bun.write('my.file', 'foobar\n'); - await Bun.write('your.file', 'abc123\n'); + fs.writeFileSync('my.file', 'foobar\n'); + fs.writeFileSync('your.file', 'abc123\n'); const result = await sh($`bun ${cli} b x my.file your.file -A --name-delimiter ', '`); expect(result.out).toBe('my.file, your.file, '); }); test('understands escape sequences and the € and § aliases', async () => { - await Bun.write('my.file', 'foobar\n'); - await Bun.write('your.file', 'abc123\n'); + fs.writeFileSync('my.file', 'foobar\n'); + fs.writeFileSync('your.file', 'abc123\n'); const delimiter = '\\t§t€'; const result = await sh($`bun ${cli} b x my.file your.file -A --name-delimiter ${delimiter}`); expect(result.out).toBe('my.file\t\t$your.file\t\t$'); }); test('keeps € and § with --void-euro and --void-section', async () => { - await Bun.write('my.file', 'foobar\n'); - await Bun.write('your.file', 'abc123\n'); + fs.writeFileSync('my.file', 'foobar\n'); + fs.writeFileSync('your.file', 'abc123\n'); const delimiter = '§t€'; const result = await sh( $`bun ${cli} b x my.file your.file -A --name-delimiter ${delimiter} --no-name-delimiter-tail --void-section --void-euro` @@ -30,14 +31,14 @@ describe('--name-delimiter', () => { }); test('is only put between the names with --no-name-delimiter-tail or --name-delimiter-tail=false', async () => { - await Bun.write('my.file', 'foobar\n'); - await Bun.write('your.file', 'abc123\n'); + fs.writeFileSync('my.file', 'foobar\n'); + fs.writeFileSync('your.file', 'abc123\n'); const noTail = await sh( $`bun ${cli} b x my.file your.file -A --name-delimiter ', ' --no-name-delimiter-tail` ); expect(noTail.out).toBe('my.file, your.file'); - await Bun.write('my.file', 'foobar\n'); - await Bun.write('your.file', 'abc123\n'); + fs.writeFileSync('my.file', 'foobar\n'); + fs.writeFileSync('your.file', 'abc123\n'); const tailFalse = await sh( $`bun ${cli} b x my.file your.file -A --name-delimiter ', ' --name-delimiter-tail=false` ); @@ -45,7 +46,7 @@ describe('--name-delimiter', () => { }); test('with no tail and one name gives just the name', async () => { - await Bun.write('my.file', 'foobar\n'); + fs.writeFileSync('my.file', 'foobar\n'); const result = await sh( $`bun ${cli} b x my.file --name-delimiter ', ' --no-name-delimiter-tail` ); @@ -55,11 +56,11 @@ describe('--name-delimiter', () => { describe('the content delimiter', () => { test('is a line break by default, unless the content already ends with one', async () => { - await Bun.write('my.file', 'foobar\n'); - await Bun.write('your.file', 'abc123\n'); + fs.writeFileSync('my.file', 'foobar\n'); + fs.writeFileSync('your.file', 'abc123\n'); expect((await sh($`bun ${cli} b x my.file your.file -o`)).out).toBe('fooxar\naxc123\n'); - await Bun.write('a.file', 'foo'); - await Bun.write('b.file', 'bar'); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); expect((await sh($`bun ${cli} o x a.file b.file -o`)).out).toBe('fxx\nbar\n'); expect((await sh($`bun ${cli} '[ab]' _ a.file b.file -m`)).out).toBe('b\na\n'); }); @@ -74,8 +75,8 @@ describe('the content delimiter', () => { }); test('is used as it is when given', async () => { - await Bun.write('my.file', 'foobar\n'); - await Bun.write('your.file', 'abc123\n'); + fs.writeFileSync('my.file', 'foobar\n'); + fs.writeFileSync('your.file', 'abc123\n'); expect((await sh($`bun ${cli} b x my.file your.file -o --delimiter ''`)).out).toBe( 'fooxar\naxc123\n' ); @@ -90,8 +91,8 @@ describe('the content delimiter', () => { }); test('goes after each match with --output-match, also with no tail', async () => { - await Bun.write('my.file', 'foobar\n'); - await Bun.write('your.file', 'abc123\n'); + fs.writeFileSync('my.file', 'foobar\n'); + fs.writeFileSync('your.file', 'abc123\n'); expect((await sh($`bun ${cli} '[bc]' _ my.file your.file -m --delimiter ','`)).out).toBe( 'b,b,c,' ); @@ -103,8 +104,8 @@ describe('the content delimiter', () => { describe('--delimiter, --null-out and --tail', () => { test('--delimiter sets both delimiters', async () => { - await Bun.write('my.file', 'foobar\n'); - await Bun.write('your.file', 'abc123\n'); + fs.writeFileSync('my.file', 'foobar\n'); + fs.writeFileSync('your.file', 'abc123\n'); expect(await sh($`bun ${cli} b x my.file your.file -o --delimiter '|'`)).toEqual({ out: 'fooxar\n|axc123\n|', err: 'my.file|your.file|', @@ -113,21 +114,21 @@ describe('--delimiter, --null-out and --tail', () => { }); test('--null-out is a null byte after each name and each content', async () => { - await Bun.write('my.file', 'foobar\n'); - await Bun.write('your.file', 'abc123\n'); + fs.writeFileSync('my.file', 'foobar\n'); + fs.writeFileSync('your.file', 'abc123\n'); expect((await sh($`bun ${cli} b x my.file your.file -A --null-out`)).out).toBe( 'my.file\0your.file\0' ); - await Bun.write('my.file', 'foobar\n'); - await Bun.write('your.file', 'abc123\n'); + fs.writeFileSync('my.file', 'foobar\n'); + fs.writeFileSync('your.file', 'abc123\n'); expect((await sh($`bun ${cli} b x my.file your.file -o --null-out`)).out).toBe( 'fooxar\n\0axc123\n\0' ); }); test('the most specific flag wins', async () => { - await Bun.write('my.file', 'foobar\n'); - await Bun.write('your.file', 'abc123\n'); + fs.writeFileSync('my.file', 'foobar\n'); + fs.writeFileSync('your.file', 'abc123\n'); expect((await sh($`bun ${cli} b x my.file your.file -o --null-out --delimiter ', '`)).err).toBe( 'my.file, your.file, ' ); @@ -141,8 +142,8 @@ describe('--delimiter, --null-out and --tail', () => { }); test('--tail sets both tails, and the specific tail flag wins', async () => { - await Bun.write('my.file', 'foobar\n'); - await Bun.write('your.file', 'abc123\n'); + fs.writeFileSync('my.file', 'foobar\n'); + fs.writeFileSync('your.file', 'abc123\n'); expect((await sh($`bun ${cli} '[bc]' _ my.file your.file -m --delimiter ',' --tail`)).out).toBe( 'b,b,c,' ); @@ -166,11 +167,11 @@ describe('an invalid escape sequence in a delimiter', () => { test.each([['--name-delimiter'], ['--content-delimiter'], ['--delimiter']])( 'in %s stops with an error and changes nothing', async flag => { - await Bun.write('my.file', 'foobar\n'); + fs.writeFileSync('my.file', 'foobar\n'); const result = await sh($`bun ${cli} b x my.file ${flag} '\x4'`); expect(result.err).toContain(`Invalid escape sequence in ${flag}`); expect(result.exit).toBe(1); - expect(await Bun.file('my.file').text()).toBe('foobar\n'); + expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar\n'); } ); }); diff --git a/test/cli/encoding.test.js b/test/cli/encoding.test.js index 6455915b..8c8b5465 100644 --- a/test/cli/encoding.test.js +++ b/test/cli/encoding.test.js @@ -1,5 +1,6 @@ // The CLI checks the encoding before stdin or any file is read (#690) import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; import {$} from 'bun'; import {cli, sh} from '../helper.js'; @@ -7,11 +8,11 @@ const lastLine = text => text.trim().split('\n').at(-1); describe('--encoding', () => { test('an unknown encoding stops the run with an error, and the file is not changed', async () => { - await Bun.write('my.file', 'foobar'); + fs.writeFileSync('my.file', 'foobar'); const result = await sh($`bun ${cli} o x my.file --encoding nope`); expect(result.exit).toBe(1); expect(lastLine(result.err)).toBe('Unknown encoding: nope'); - expect(await Bun.file('my.file').text()).toBe('foobar'); + expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar'); }); test('an unknown encoding also stops the run for piped data', async () => { diff --git a/test/cli/fix/691.test.js b/test/cli/fix/691.test.js index d554e5db..bf2bbf4e 100644 --- a/test/cli/fix/691.test.js +++ b/test/cli/fix/691.test.js @@ -2,6 +2,7 @@ // A replacement containing a real line break, a `\'` or a lone backslash at the end crashed the CLI with a SyntaxError and a stack trace. // The replacement is unescaped by the CLI (not the engine), so these tests run the CLI itself. import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; import {$} from 'bun'; import {cli, sh} from '../../helper.js'; @@ -10,8 +11,8 @@ const expectedOutput = 'my.file\n'; // Replace each `o` in `foobar` with the replacement and return what the CLI gives back. // Without the tail only the result of the replacement is given. -async function replaceO(replacement, ...flags) { - await Bun.write('my.file', 'foobar'); +function replaceO(replacement, ...flags) { + fs.writeFileSync('my.file', 'foobar'); return sh($`bun ${cli} o ${replacement} my.file --output --no-content-delimiter-tail ${flags}`); } diff --git a/test/cli/fix/733.test.js b/test/cli/fix/733.test.js index 0115666e..a46a0b25 100644 --- a/test/cli/fix/733.test.js +++ b/test/cli/fix/733.test.js @@ -1,13 +1,14 @@ // https://github.com/mathiasrw/rexreplace/issues/733 // yargs turned a file glob like `2024` into a number, and fast-glob only takes strings, so RexReplace crashed. import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; import {$} from 'bun'; import {cli, sh} from '../../helper.js'; describe('#733 a file glob that reads as a number', () => { test.each(['2024', '1e3', '0x10'])('is kept as a string, like %s', async name => { - await Bun.write(name, 'foobar'); + fs.writeFileSync(name, 'foobar'); expect(await sh($`bun ${cli} o x ${name}`)).toEqual({out: name + '\n', err: '', exit: 0}); - expect(await Bun.file(name).text()).toBe('fxxbar'); + expect(fs.readFileSync(name, 'utf8')).toBe('fxxbar'); }); }); diff --git a/test/cli/fix/736.test.js b/test/cli/fix/736.test.js index 6c0c1bd7..64d105d0 100644 --- a/test/cli/fix/736.test.js +++ b/test/cli/fix/736.test.js @@ -2,13 +2,14 @@ // stdin was read every time it was not a terminal, so a pipe that was open but never written to made the run wait forever. // The flags that ask for the piped data are tested in test/cli/pipe.test.js. import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; import {cli} from '../../helper.js'; // Bun.spawn and not sh() from the helper: the test needs a pipe that stays open, and a pipe in a shell command ends describe('#736 stdin is only read when the data is asked for', () => { test('files are changed without waiting for a pipe that is never closed', async () => { - await Bun.write('my.file', 'foobar'); + fs.writeFileSync('my.file', 'foobar'); const proc = Bun.spawn([process.execPath, cli, 'o', 'x', 'my.file'], { cwd: process.cwd(), stdin: 'pipe', @@ -19,6 +20,6 @@ describe('#736 stdin is only read when the data is asked for', () => { proc.kill(); expect(exit).toBe(0); - expect(await Bun.file('my.file').text()).toBe('fxxbar'); + expect(fs.readFileSync('my.file', 'utf8')).toBe('fxxbar'); }); }); diff --git a/test/cli/fix/742.test.js b/test/cli/fix/742.test.js index 3e3732ae..ff2d6285 100644 --- a/test/cli/fix/742.test.js +++ b/test/cli/fix/742.test.js @@ -1,12 +1,13 @@ // https://github.com/mathiasrw/rexreplace/issues/742 // The CLI is the only part that acts on the process. It prints the status report with --status and --status-compact. import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; import {$} from 'bun'; import {cli, sh} from '../../helper.js'; describe('#742 the CLI', () => { test('--status prints a line break and the status report with an indent of two spaces after the result', async () => { - await Bun.write('a.file', 'foo'); + fs.writeFileSync('a.file', 'foo'); const result = await sh($`bun ${cli} o x a.file --status`); const status = { files: {searched: ['a.file'], updated: ['a.file']}, @@ -24,7 +25,7 @@ describe('#742 the CLI', () => { test.each(['--status-compact', '-c'])( '%s prints the status report on one line, with no line break in front', async flag => { - await Bun.write('a.file', 'foo'); + fs.writeFileSync('a.file', 'foo'); const result = await sh($`bun ${cli} o x a.file ${flag} --quiet-total`); expect(result.out).toBe( '{"files":{"searched":["a.file"],"updated":["a.file"]},"pipe":null,"warnings":[],"errors":[]}\n' @@ -39,7 +40,7 @@ describe('#742 the CLI', () => { }); test('ends the process on a panic, and still prints the status report', async () => { - await Bun.write('a.file', 'foo'); + fs.writeFileSync('a.file', 'foo'); const result = await sh($`bun ${cli} '(' x a.file --status-compact --quiet-total`); expect(JSON.parse(result.out).errors).toHaveLength(1); expect(result.exit).toBe(1); diff --git a/test/cli/glob-pipe.test.js b/test/cli/glob-pipe.test.js index c5ea1724..9b7315d3 100644 --- a/test/cli/glob-pipe.test.js +++ b/test/cli/glob-pipe.test.js @@ -1,12 +1,13 @@ // --glob-pipe (-g): each line of the piped data is a path or a glob of the files to work on (#711) import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; import {$} from 'bun'; import {cli, sh} from '../helper.js'; describe('--glob-pipe', () => { test('a piped line can be a glob', async () => { - await Bun.write('my.file', 'foobar\n'); - await Bun.write('your.file', 'abc123\n'); + fs.writeFileSync('my.file', 'foobar\n'); + fs.writeFileSync('your.file', 'abc123\n'); expect(await sh($`echo -n '*.file' | bun ${cli} b x -g -A`)).toEqual({ out: 'my.file\nyour.file\n', err: '', @@ -17,18 +18,18 @@ describe('--glob-pipe', () => { test.each([['--replacement-pipe'], ['--pipe'], ['--js-pipe']])( 'can not be used with %s, as the piped data can only be one thing', async flag => { - await Bun.write('my.file', 'foobar\n'); + fs.writeFileSync('my.file', 'foobar\n'); const result = await sh($`echo -n my.file | bun ${cli} b x --glob-pipe ${flag}`); expect(result.err).toContain('are mutually exclusive'); expect(result.exit).toBe(1); - expect(await Bun.file('my.file').text()).toBe('foobar\n'); + expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar\n'); } ); test('--null-in can only be used with --glob-pipe', async () => { - await Bun.write('my.file', 'foobar\n'); + fs.writeFileSync('my.file', 'foobar\n'); const result = await sh($`echo -n my.file | bun ${cli} b x --null-in`); expect(result.exit).toBe(1); - expect(await Bun.file('my.file').text()).toBe('foobar\n'); + expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar\n'); }); }); diff --git a/test/cli/names.test.js b/test/cli/names.test.js index 3cd07d56..8713d605 100644 --- a/test/cli/names.test.js +++ b/test/cli/names.test.js @@ -1,12 +1,13 @@ // The names of the changed files are the result on stdout. With --output and --output-match stdout has the content, so the names go to stderr. import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; import {$} from 'bun'; import {cli, sh} from '../helper.js'; describe('the names of the changed files', () => { test('go to stdout, and nothing goes to stderr', async () => { - await Bun.write('my.file', 'foobar\n'); - await Bun.write('your.file', 'abc123\n'); + fs.writeFileSync('my.file', 'foobar\n'); + fs.writeFileSync('your.file', 'abc123\n'); expect(await sh($`bun ${cli} b x my.file your.file -A`)).toEqual({ out: 'my.file\nyour.file\n', err: '', @@ -15,31 +16,31 @@ describe('the names of the changed files', () => { }); test('stay alone on stdout with --verbose', async () => { - await Bun.write('my.file', 'foobar\n'); + fs.writeFileSync('my.file', 'foobar\n'); const result = await sh($`bun ${cli} b x my.file --verbose`); expect(result.out).toBe('my.file\n'); expect(result.err.split('\n')).not.toContain('my.file'); }); test('are not printed with --quiet, but the file is changed', async () => { - await Bun.write('my.file', 'foobar\n'); + fs.writeFileSync('my.file', 'foobar\n'); expect(await sh($`bun ${cli} b x my.file -q`)).toEqual({out: '', err: '', exit: 0}); - expect(await Bun.file('my.file').text()).toBe('fooxar\n'); + expect(fs.readFileSync('my.file', 'utf8')).toBe('fooxar\n'); }); describe('go to stderr when stdout has the content', () => { test('with --output', async () => { - await Bun.write('my.file', 'foobar\n'); + fs.writeFileSync('my.file', 'foobar\n'); expect(await sh($`bun ${cli} b x my.file -o`)).toEqual({ out: 'fooxar\n', err: 'my.file\n', exit: 0, }); - expect(await Bun.file('my.file').text()).toBe('foobar\n'); + expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar\n'); }); test('with --output-match', async () => { - await Bun.write('my.file', 'foobar\n'); + fs.writeFileSync('my.file', 'foobar\n'); expect(await sh($`bun ${cli} b _ my.file -m`)).toEqual({ out: 'b\n', err: 'my.file\n', @@ -48,26 +49,26 @@ describe('the names of the changed files', () => { }); test('and are not printed with --quiet', async () => { - await Bun.write('my.file', 'foobar\n'); + fs.writeFileSync('my.file', 'foobar\n'); expect((await sh($`bun ${cli} b x my.file -o -q`)).err).toBe(''); expect((await sh($`bun ${cli} b _ my.file -m -q`)).err).toBe(''); }); test('only for a file where something would change', async () => { - await Bun.write('my.file', 'foobar\n'); + fs.writeFileSync('my.file', 'foobar\n'); expect((await sh($`bun ${cli} z x my.file -o`)).err).toBe(''); }); test('with --name-delimiter', async () => { - await Bun.write('my.file', 'foobar\n'); - await Bun.write('your.file', 'abc123\n'); + fs.writeFileSync('my.file', 'foobar\n'); + fs.writeFileSync('your.file', 'abc123\n'); const result = await sh($`bun ${cli} b x my.file your.file -o --name-delimiter ', '`); expect(result.err).toBe('my.file, your.file, '); }); test('with --name-all also for the files where nothing would change', async () => { - await Bun.write('my.file', 'foobar\n'); - await Bun.write('your.file', 'abc123\n'); + fs.writeFileSync('my.file', 'foobar\n'); + fs.writeFileSync('your.file', 'abc123\n'); expect((await sh($`bun ${cli} b x my.file your.file -o --name-all`)).err).toBe( 'my.file\nyour.file\n' ); diff --git a/test/cli/nl.test.js b/test/cli/nl.test.js index 0435b86f..602a026c 100644 --- a/test/cli/nl.test.js +++ b/test/cli/nl.test.js @@ -1,12 +1,13 @@ // --nl (-l) ends stdout and stderr with a line break, unless they already end with one import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; import {$} from 'bun'; import {cli, sh} from '../helper.js'; describe('--nl', () => { test('adds a line break after the last match, also as -l', async () => { - await Bun.write('my.file', 'foobar\n'); - await Bun.write('your.file', 'abc123\n'); + fs.writeFileSync('my.file', 'foobar\n'); + fs.writeFileSync('your.file', 'abc123\n'); expect( (await sh($`bun ${cli} '[bc]' _ my.file your.file -m --delimiter ',' --no-tail --nl`)).out ).toBe('b,b,c\n'); @@ -16,15 +17,15 @@ describe('--nl', () => { }); test('comes after the tail', async () => { - await Bun.write('my.file', 'foobar\n'); - await Bun.write('your.file', 'abc123\n'); + fs.writeFileSync('my.file', 'foobar\n'); + fs.writeFileSync('your.file', 'abc123\n'); expect( (await sh($`bun ${cli} '[bc]' _ my.file your.file -m --delimiter ',' --tail --nl`)).out ).toBe('b,b,c,\n'); }); test('adds nothing when the output already ends with a line break', async () => { - await Bun.write('my.file', 'foobar\n'); + fs.writeFileSync('my.file', 'foobar\n'); expect((await sh($`bun ${cli} b x my.file -o --nl`)).out).toBe('fooxar\n'); }); @@ -37,14 +38,14 @@ describe('--nl', () => { }); test('ends the names with a line break, on stdout and on stderr', async () => { - await Bun.write('my.file', 'foobar\n'); - await Bun.write('your.file', 'abc123\n'); + fs.writeFileSync('my.file', 'foobar\n'); + fs.writeFileSync('your.file', 'abc123\n'); const stdout = await sh( $`bun ${cli} b x my.file your.file -A --name-delimiter ',' --no-name-delimiter-tail --nl` ); expect(stdout.out).toBe('my.file,your.file\n'); - await Bun.write('my.file', 'foobar\n'); - await Bun.write('your.file', 'abc123\n'); + fs.writeFileSync('my.file', 'foobar\n'); + fs.writeFileSync('your.file', 'abc123\n'); const stderr = await sh( $`bun ${cli} b x my.file your.file -o --name-delimiter ',' --no-name-delimiter-tail --nl` ); diff --git a/test/cli/null-bytes.test.js b/test/cli/null-bytes.test.js index 4baf6d9c..f0cc89e4 100644 --- a/test/cli/null-bytes.test.js +++ b/test/cli/null-bytes.test.js @@ -1,5 +1,6 @@ // Null bytes come out where they went in, piped in and in a file import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; import {$} from 'bun'; import {cli, sh} from '../helper.js'; @@ -21,18 +22,18 @@ describe('null bytes are kept', () => { describe('in a file', () => { test('when it is changed in place', async () => { - await Bun.write('null.file', data); + fs.writeFileSync('null.file', data); expect(await sh($`bun ${cli} o x null.file`)).toEqual({out: 'null.file\n', err: '', exit: 0}); - expect(await Bun.file('null.file').text()).toBe('fxx\0bxx\0'); + expect(fs.readFileSync('null.file', 'utf8')).toBe('fxx\0bxx\0'); }); test('with --output, followed by the line break after the content', async () => { - await Bun.write('null.file', data); + fs.writeFileSync('null.file', data); expect((await sh($`bun ${cli} o x null.file --output`)).out).toBe('fxx\0bxx\0\n'); }); test('with --output-match and --null-out', async () => { - await Bun.write('null.file', data); + fs.writeFileSync('null.file', data); expect((await sh($`bun ${cli} o _ null.file --output-match --null-out`)).out).toBe( 'o\0o\0o\0o\0' ); diff --git a/test/cli/pipe.test.js b/test/cli/pipe.test.js index a6760c52..c7221078 100644 --- a/test/cli/pipe.test.js +++ b/test/cli/pipe.test.js @@ -1,4 +1,5 @@ import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; import {$} from 'bun'; import {cli, sh} from '../helper.js'; @@ -6,7 +7,7 @@ const warning = ' 🟡 Data is piped in, but no flag tells what to do with it. I describe('--pipe and --js-pipe', () => { test('without --pipe the code does not get the piped data', async () => { - await Bun.write('my.file', 'foobar\n'); + fs.writeFileSync('my.file', 'foobar\n'); expect(await sh($`echo -n x | bun ${cli} foo pipe my.file -o --replacement-js`)).toEqual({ out: 'nullbar\n', err: 'my.file\n', @@ -15,20 +16,20 @@ describe('--pipe and --js-pipe', () => { }); test('the code gets the piped data with --replacement-js --pipe', async () => { - await Bun.write('my.file', 'foobar\n'); + fs.writeFileSync('my.file', 'foobar\n'); expect( (await sh($`echo -n x | bun ${cli} foo pipe my.file -o --replacement-js --pipe`)).out ).toBe('xbar\n'); }); test('the code gets the piped data with -jp and --js-pipe', async () => { - await Bun.write('my.file', 'foobar\n'); + fs.writeFileSync('my.file', 'foobar\n'); expect((await sh($`echo -n x | bun ${cli} foo pipe my.file -o -jp`)).out).toBe('xbar\n'); expect((await sh($`echo -n x | bun ${cli} foo pipe my.file -o --js-pipe`)).out).toBe('xbar\n'); }); test('--trim-pipe trims the piped data first', async () => { - await Bun.write('my.file', 'foobar\n'); + fs.writeFileSync('my.file', 'foobar\n'); expect((await sh($`echo -n ' x ' | bun ${cli} foo pipe my.file -o --js-pipe -T`)).out).toBe( 'xbar\n' ); @@ -37,33 +38,33 @@ describe('--pipe and --js-pipe', () => { describe('the warning that piped data is not used', () => { test('is given when data is read with --pipe, but not used', async () => { - await Bun.write('my.file', 'foobar\n'); + fs.writeFileSync('my.file', 'foobar\n'); expect(await sh($`echo -n x | bun ${cli} o x my.file --pipe`)).toEqual({ out: 'my.file\n', err: warning, exit: 0, }); - expect(await Bun.file('my.file').text()).toBe('fxxbar\n'); + expect(fs.readFileSync('my.file', 'utf8')).toBe('fxxbar\n'); }); test('is not given without --pipe, as the data is not read', async () => { - await Bun.write('my.file', 'foobar\n'); + fs.writeFileSync('my.file', 'foobar\n'); expect((await sh($`echo -n x | bun ${cli} o x my.file`)).err).toBe(''); }); test('is not given with --quiet', async () => { - await Bun.write('my.file', 'foobar\n'); + fs.writeFileSync('my.file', 'foobar\n'); expect((await sh($`echo -n x | bun ${cli} o x my.file --pipe -q`)).err).toBe(''); }); test('is not given when the data is used', async () => { - await Bun.write('my.file', 'foobar\n'); + fs.writeFileSync('my.file', 'foobar\n'); expect((await sh($`echo -n x | bun ${cli} b _ my.file -R`)).err).toBe(''); expect((await sh($`echo -n x | bun ${cli} b pipe my.file --js-pipe`)).err).toBe(''); }); test('is not given when nothing, or only a line break, is piped in', async () => { - await Bun.write('my.file', 'foobar\n'); + fs.writeFileSync('my.file', 'foobar\n'); expect((await sh($`echo -n '' | bun ${cli} o x my.file --pipe`)).err).toBe(''); expect((await sh($`echo '' | bun ${cli} o x my.file --pipe`)).err).toBe(''); expect((await sh($`bun ${cli} o x my.file --pipe < ${new Response('\r\n')}`)).err).toBe(''); diff --git a/test/engine/fix/703.test.js b/test/engine/fix/703.test.js index 7fe6cd2d..56e665ec 100644 --- a/test/engine/fix/703.test.js +++ b/test/engine/fix/703.test.js @@ -8,17 +8,17 @@ import {rr} from '../../helper.js'; // Note that rr() from the helper sets voidAsync per default, so the names come in the order of the file list // Replace in a.file, b.file and c.file where b.file has nothing to replace -async function run(flags = {}) { - await Bun.write('a.file', 'foo'); - await Bun.write('b.file', 'bar'); - await Bun.write('c.file', 'foo'); +function run(flags = {}) { + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); return rr('o', 'x', {includeGlob: ['*.file'], ...flags}); } describe('#703 the names of affected files', () => { test('go to stdout with one name on each line', async () => { expect(await run()).toEqual({out: 'a.file\nc.file\n', err: '', exit: null}); - expect(await Bun.file('a.file').text()).toBe('fxx'); + expect(fs.readFileSync('a.file', 'utf8')).toBe('fxx'); }); // A file is named when the result differs from the content, so `rexreplace o o` names nothing @@ -41,7 +41,7 @@ describe('#703 the names of affected files', () => { test.each(['quiet', 'quietTotal'])('are not printed with --%s', async flag => { expect(await run({[flag]: true})).toEqual({out: '', err: '', exit: null}); - expect(await Bun.file('c.file').text()).toBe('fxx'); + expect(fs.readFileSync('c.file', 'utf8')).toBe('fxx'); }); test('are not printed for piped data', async () => { @@ -54,7 +54,8 @@ describe('#703 the names of affected files', () => { }); test('are printed the way the path was given', async () => { - await Bun.write('sub/my.file', 'foo'); + fs.mkdirSync('sub', {recursive: true}); + fs.writeFileSync('sub/my.file', 'foo'); expect((await rr('o', 'x', {includeGlob: ['sub/*.file']})).out).toBe('sub/my.file\n'); const file = path.resolve('sub/my.file'); @@ -67,7 +68,7 @@ describe('#703 the names of affected files', () => { }); test('keeps a name with a line break in one piece', async () => { - await Bun.write('my\n.file', 'foo'); + fs.writeFileSync('my\n.file', 'foo'); expect((await rr('o', 'x', {includeGlob: ['*.file'], nullOut: true})).out).toBe( 'my\n.file\0' ); @@ -103,7 +104,7 @@ describe('#703 the names of affected files', () => { }); test('prints no delimiter when only one file is changed', async () => { - await Bun.write('my.file', 'foo'); + fs.writeFileSync('my.file', 'foo'); const config = { includeGlob: ['my.file'], nameDelimiter: ', ', @@ -121,8 +122,9 @@ describe('#703 the names of affected files', () => { // The names must not be held back until all files are done test('prints the files changed before the run is stopped by --halt', async () => { - await Bun.write('a.file', 'foo'); - await Bun.write('locked/b.file', 'foo'); + fs.writeFileSync('a.file', 'foo'); + fs.mkdirSync('locked', {recursive: true}); + fs.writeFileSync('locked/b.file', 'foo'); fs.chmodSync('locked', 0o555); try { const result = await rr('o', 'x', { @@ -140,8 +142,9 @@ describe('#703 the names of affected files', () => { }); test('with --nl still ends with a line break when the run is stopped by --halt', async () => { - await Bun.write('a.file', 'foo'); - await Bun.write('locked/b.file', 'foo'); + fs.writeFileSync('a.file', 'foo'); + fs.mkdirSync('locked', {recursive: true}); + fs.writeFileSync('locked/b.file', 'foo'); fs.chmodSync('locked', 0o555); try { const result = await rr('o', 'x', { @@ -165,7 +168,7 @@ describe('#703 the names of affected files', () => { err: '', exit: null, }); - expect(await Bun.file('b.file').text()).toBe('bar'); + expect(fs.readFileSync('b.file', 'utf8')).toBe('bar'); }); test('names every file printed with --output', async () => { @@ -192,9 +195,9 @@ describe('#703 the names of affected files', () => { }); test('adds no line break by default to content that already ends with one', async () => { - await Bun.write('a.file', 'foo\n'); - await Bun.write('b.file', ''); - await Bun.write('c.file', 'bar'); + fs.writeFileSync('a.file', 'foo\n'); + fs.writeFileSync('b.file', ''); + fs.writeFileSync('c.file', 'bar'); const config = {includeGlob: ['*.file'], output: true}; expect((await rr('o', 'x', config)).out).toBe('fxx\n\nbar\n'); }); @@ -219,7 +222,7 @@ describe('#703 the names of affected files', () => { }); test('is put after the last match, from a file or a pipe', async () => { - await Bun.write('a.file', 'foo'); + fs.writeFileSync('a.file', 'foo'); const fromFile = await rr('o', '_', {includeGlob: ['a.file'], outputMatch: true}); const fromPipe = await rr('o', '_', {pipedData: 'foo', outputMatch: true}); expect(fromFile.out).toBe('o\no\n'); @@ -279,15 +282,15 @@ describe('#703 the names of affected files', () => { test('adds nothing when the output already ends with a line break', async () => { expect((await rr('o', 'x', {pipedData: 'foo\n\n', nl: true})).out).toBe('fxx\n\n'); - await Bun.write('a.file', 'foo\n'); - await Bun.write('b.file', ''); + fs.writeFileSync('a.file', 'foo\n'); + fs.writeFileSync('b.file', ''); const config = {includeGlob: ['*.file'], output: true, nl: true}; expect((await rr('o', 'x', config)).out).toBe('fxx\n\n'); }); test('adds nothing when nothing is printed', async () => { expect((await rr('nope', 'x', {pipedData: 'foo', outputMatch: true, nl: true})).out).toBe(''); - await Bun.write('a.file', 'foo'); + fs.writeFileSync('a.file', 'foo'); expect((await rr('nope', 'x', {includeGlob: ['a.file'], nl: true})).out).toBe(''); }); @@ -347,7 +350,7 @@ describe('#703 the names of affected files', () => { ])('with --%s', (flag, out) => { test('go to stderr', async () => { expect(await run({[flag]: true})).toEqual({out, err: 'a.file\nc.file\n', exit: null}); - expect(await Bun.file('a.file').text()).toBe('foo'); + expect(fs.readFileSync('a.file', 'utf8')).toBe('foo'); }); test('are the same names as without the flag', async () => { @@ -378,11 +381,12 @@ describe('#703 the names of affected files', () => { test('leaves a path that is already full as it is', async () => { fs.mkdirSync('work'); + fs.mkdirSync('other'); const file = path.resolve('other', 'my.file'); - await Bun.write(file, 'foo'); + fs.writeFileSync(file, 'foo'); process.chdir('work'); expect((await rr('o', 'x', {includeGlob: [file], fullPath: true})).out).toBe(file + '\n'); - expect(await Bun.file(file).text()).toBe('fxx'); + expect(fs.readFileSync(file, 'utf8')).toBe('fxx'); }); test.each([ diff --git a/test/engine/fix/708.test.js b/test/engine/fix/708.test.js index 723a1db0..9eee43dc 100644 --- a/test/engine/fix/708.test.js +++ b/test/engine/fix/708.test.js @@ -10,18 +10,18 @@ const workers = Math.max(16, os.availableParallelism() * 2); describe('#708 the number of files in work at the same time is limited', () => { test('all files are changed when there are more files than workers', async () => { const names = Array.from({length: workers * 3}, (_, i) => `file-${i}.txt`); - await Promise.all(names.map(name => Bun.write(name, 'foo'))); + names.forEach(name => fs.writeFileSync(name, 'foo')); const result = await rr('o', 'x', {includeGlob: ['*.txt'], voidAsync: false}); expect(result.err).toBe(''); expect(result.out.split('\n').sort()).toEqual(['', ...names].sort()); for (const name of names) { - expect(await Bun.file(name).text()).toBe('fxx'); + expect(fs.readFileSync(name, 'utf8')).toBe('fxx'); } }); test(`at most ${workers} files are read at the same time`, async () => { const names = Array.from({length: workers * 3}, (_, i) => `file-${i}.txt`); - await Promise.all(names.map(name => Bun.write(name, 'foo'))); + names.forEach(name => fs.writeFileSync(name, 'foo')); const readFile = fs.promises.readFile; let reading = 0; let mostReading = 0; @@ -46,7 +46,7 @@ describe('#708 the number of files in work at the same time is limited', () => { test('the number of CPU cores is not asked for with 16 files or fewer', async () => { const names = Array.from({length: 16}, (_, i) => `file-${i}.txt`); - await Promise.all(names.map(name => Bun.write(name, 'foo'))); + names.forEach(name => fs.writeFileSync(name, 'foo')); const spy = spyOn(os, 'availableParallelism'); try { await rr('o', 'x', {includeGlob: ['*.txt'], voidAsync: false}); @@ -57,19 +57,19 @@ describe('#708 the number of files in work at the same time is limited', () => { }); test('one file is handled in the synchronous flow', async () => { - await Bun.write('my.file', 'foo'); + fs.writeFileSync('my.file', 'foo'); const result = await rr('o', 'x', { includeGlob: ['my.file'], voidAsync: false, verbose: true, }); expect(result.err).toContain('Open sync: my.file'); - expect(await Bun.file('my.file').text()).toBe('fxx'); + expect(fs.readFileSync('my.file', 'utf8')).toBe('fxx'); }); test('a file that can not be read gives an error in the synchronous flow, and the run goes on', async () => { - await Bun.write('locked.file', 'foo'); - await Bun.write('my.file', 'foo'); + fs.writeFileSync('locked.file', 'foo'); + fs.writeFileSync('my.file', 'foo'); fs.chmodSync('locked.file', 0); const result = await rr('o', 'x', { @@ -80,6 +80,6 @@ describe('#708 the number of files in work at the same time is limited', () => { expect(result.out).toBe('my.file\n'); expect(result.err).toContain('EACCES'); expect(result.exit).toBe(1); - expect(await Bun.file('my.file').text()).toBe('fxx'); + expect(fs.readFileSync('my.file', 'utf8')).toBe('fxx'); }); }); diff --git a/test/engine/fix/719.test.js b/test/engine/fix/719.test.js index 58220a93..17c1571f 100644 --- a/test/engine/fix/719.test.js +++ b/test/engine/fix/719.test.js @@ -1,21 +1,22 @@ // https://github.com/mathiasrw/rexreplace/issues/719 // Data piped in together with files was not used, and nothing told about it. import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; import {rr} from '../../helper.js'; const warning = ' 🟡 Data is piped in, but no flag tells what to do with it. It is not used.\n\n'; const expectedOutput = 'my.file\n'; // Run on my.file (with the content `foobar`) with `x` piped in -async function piped(flags = {}, replacement = '_') { - await Bun.write('my.file', 'foobar'); +function piped(flags = {}, replacement = '_') { + fs.writeFileSync('my.file', 'foobar'); return rr('b', replacement, {includeGlob: ['my.file'], pipedData: 'x', ...flags}); } describe('#719 a warning when piped data is not used', () => { test('is given when data is piped in and files are also given', async () => { expect(await piped()).toEqual({out: expectedOutput, err: warning, exit: null}); - expect(await Bun.file('my.file').text()).toBe('foo_ar'); + expect(fs.readFileSync('my.file', 'utf8')).toBe('foo_ar'); }); test.each(['quiet', 'quietTotal'])('is not shown with --%s', async flag => { @@ -28,7 +29,7 @@ describe('#719 a warning when piped data is not used', () => { err: warning, exit: null, }); - expect(await Bun.file('my.file').text()).toBe('foo_ar'); + expect(fs.readFileSync('my.file', 'utf8')).toBe('foo_ar'); }); test('is not given when the piped data is used', async () => { @@ -42,7 +43,7 @@ describe('#719 a warning when piped data is not used', () => { err: '', exit: null, }); - expect(await Bun.file('my.file').text()).toBe('fooxar'); + expect(fs.readFileSync('my.file', 'utf8')).toBe('fooxar'); }); test.each([null, '', '\n', '\r\n'])('is not given when %j is piped in', async pipedData => { diff --git a/test/engine/fix/726.test.js b/test/engine/fix/726.test.js index 4c1db156..573ed668 100644 --- a/test/engine/fix/726.test.js +++ b/test/engine/fix/726.test.js @@ -12,60 +12,60 @@ const flows = [ ]; // Make `name` with the content `foobar` in a directory that is not the working directory and give the absolute path of it -async function outside(name) { +function outside(name) { fs.mkdirSync('work'); fs.mkdirSync('other'); const file = path.resolve('other', name); - await Bun.write(file, 'foobar'); + fs.writeFileSync(file, 'foobar'); process.chdir('work'); return file; } describe('#726 a file given with an absolute path is changed', () => { test.each(flows)('as a path with %s', async (name, flags) => { - const file = await outside('my.file'); + const file = outside('my.file'); const result = await rr('o', 'x', {includeGlob: [file], ...flags}); expect(result).toEqual({out: file + '\n', err: '', exit: null}); - expect(await Bun.file(file).text()).toBe('fxxbar'); + expect(fs.readFileSync(file, 'utf8')).toBe('fxxbar'); expect(fs.readdirSync(path.dirname(file))).toEqual(['my.file']); }); test.each(flows)('as a glob with %s', async (name, flags) => { - const file = await outside('my.file'); + const file = outside('my.file'); const glob = path.join(path.dirname(file), '*.file'); const result = await rr('o', 'x', {includeGlob: [glob], ...flags}); expect(result).toEqual({out: file + '\n', err: '', exit: null}); - expect(await Bun.file(file).text()).toBe('fxxbar'); + expect(fs.readFileSync(file, 'utf8')).toBe('fxxbar'); expect(fs.readdirSync(path.dirname(file))).toEqual(['my.file']); }); test('in the working directory', async () => { - await Bun.write('my.file', 'foobar'); + fs.writeFileSync('my.file', 'foobar'); const file = path.resolve('my.file'); const result = await rr('o', 'x', {includeGlob: [file]}); expect(result).toEqual({out: file + '\n', err: '', exit: null}); - expect(await Bun.file(file).text()).toBe('fxxbar'); + expect(fs.readFileSync(file, 'utf8')).toBe('fxxbar'); expect(fs.readdirSync('.')).toEqual(['my.file']); }); test.each(flows)( '--keep-backup puts the backup next to the file with %s', async (name, flags) => { - const file = await outside('my.file'); + const file = outside('my.file'); await rr('o', 'x', {includeGlob: [file], keepBackup: true, ...flags}); const [backup, ...rest] = fs .readdirSync(path.dirname(file)) .filter(name => name !== 'my.file'); expect(rest).toEqual([]); expect(backup).toMatch(/^my\.file\..+\.backup$/); - expect(await Bun.file(path.join(path.dirname(file), backup)).text()).toBe('foobar'); - expect(await Bun.file(file).text()).toBe('fxxbar'); + expect(fs.readFileSync(path.join(path.dirname(file), backup), 'utf8')).toBe('foobar'); + expect(fs.readFileSync(file, 'utf8')).toBe('fxxbar'); expect(fs.readdirSync('.')).toEqual([]); } ); test('--replacement-js gets the path of the file as it is', async () => { - const file = await outside('my.file'); + const file = outside('my.file'); const result = await rr( 'foobar', '[file, dirpath, file_rel, dirpath_rel, typeof mtime].join("|")', @@ -88,13 +88,14 @@ describe('#726 a file given with an absolute path is changed', () => { test('a relative path still works from the working directory', async () => { fs.mkdirSync('sub'); - await Bun.write('sub/my.file', 'foobar'); + fs.mkdirSync('sub', {recursive: true}); + fs.writeFileSync('sub/my.file', 'foobar'); const result = await rr('o', 'x', { includeGlob: ['./sub/../sub/my.file'], keepBackup: true, }); expect(result.exit).toBe(null); - expect(await Bun.file('sub/my.file').text()).toBe('fxxbar'); + expect(fs.readFileSync('sub/my.file', 'utf8')).toBe('fxxbar'); expect(fs.readdirSync('sub')).toHaveLength(2); }); }); diff --git a/test/engine/utf8.test.js b/test/engine/utf8.test.js index ec9aed00..7070595e 100644 --- a/test/engine/utf8.test.js +++ b/test/engine/utf8.test.js @@ -1,6 +1,7 @@ // https://github.com/mathiasrw/rexreplace/issues/735 // Emoji and other UTF-8 in every way data comes in and goes out. import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; import path from 'path'; import {rr} from '../helper.js'; @@ -48,21 +49,19 @@ describe('#735 UTF-8 in the replacement', () => { describe('#735 UTF-8 in the content of a file', () => { test('is written as UTF-8 when changed in place', async () => { - await Bun.write('my.file', text); + fs.writeFileSync('my.file', text); await rr('🦖', '🦕', {includeGlob: ['my.file']}); - expect(Buffer.from(await Bun.file('my.file').arrayBuffer())).toEqual( - Buffer.from('æ 中 🦕 👩‍💻\r\n') - ); + expect(fs.readFileSync('my.file')).toEqual(Buffer.from('æ 中 🦕 👩‍💻\r\n')); }); test('is printed with --output', async () => { - await Bun.write('my.file', text); + fs.writeFileSync('my.file', text); const out = (await rr('🦖', '🦕', {includeGlob: ['my.file'], output: true})).out; expect(asUtf8(out)).toBe('æ 中 🦕 👩‍💻\r\n'); }); test('is printed with --output-match', async () => { - await Bun.write('my.file', text); + fs.writeFileSync('my.file', text); const config = {includeGlob: ['my.file'], outputMatch: true}; expect(asUtf8((await rr('🦖|👩‍💻', '_', config)).out)).toBe('🦖\n👩‍💻\n'); }); @@ -74,9 +73,9 @@ describe('#735 UTF-8 in piped data', () => { }); test('can be the replacement with --replacement-pipe', async () => { - await Bun.write('my.file', 'aXb'); + fs.writeFileSync('my.file', 'aXb'); await rr('X', '_', {includeGlob: ['my.file'], replacementPipe: true, pipedData: '🦖'}); - expect(await Bun.file('my.file').text()).toBe('a🦖b'); + expect(fs.readFileSync('my.file', 'utf8')).toBe('a🦖b'); }); }); @@ -84,19 +83,19 @@ describe('#735 UTF-8 in file names', () => { const name = '🦖 中.file'; test('a name is printed as it was given', async () => { - await Bun.write(name, 'foo'); + fs.writeFileSync(name, 'foo'); expect(asUtf8((await rr('o', 'x', {includeGlob: [name]})).out)).toBe(name + '\n'); - expect(await Bun.file(name).text()).toBe('fxx'); + expect(fs.readFileSync(name, 'utf8')).toBe('fxx'); }); test('a glob finds a name with emoji', async () => { - await Bun.write(name, 'foo'); - await Bun.write('other.file', 'foo'); + fs.writeFileSync(name, 'foo'); + fs.writeFileSync('other.file', 'foo'); expect(asUtf8((await rr('o', 'x', {includeGlob: ['*🦖*']})).out)).toBe(name + '\n'); }); test('a name is printed with its full path with --full-path', async () => { - await Bun.write(name, 'foo'); + fs.writeFileSync(name, 'foo'); const out = (await rr('o', 'x', {includeGlob: [name], fullPath: true})).out; expect(asUtf8(out)).toBe(path.resolve(name) + '\n'); }); @@ -104,26 +103,26 @@ describe('#735 UTF-8 in file names', () => { describe('#735 UTF-8 in the delimiters', () => { test('--name-delimiter, also with no tail', async () => { - await Bun.write('a.file', 'foo'); - await Bun.write('b.file', 'foo'); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); const config = {includeGlob: ['*.file'], nameDelimiter: '🦖'}; expect(asUtf8((await rr('o', 'x', config)).out)).toBe('a.file🦖b.file🦖'); - await Bun.write('a.file', 'foo'); - await Bun.write('b.file', 'foo'); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); const noTail = {...config, nameDelimiterTail: false}; expect(asUtf8((await rr('o', 'x', noTail)).out)).toBe('a.file🦖b.file'); }); test('--content-delimiter with a combined emoji', async () => { - await Bun.write('a.file', 'foo'); - await Bun.write('b.file', 'foo'); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); const config = {includeGlob: ['*.file'], output: true, contentDelimiter: '👩‍💻'}; expect(asUtf8((await rr('o', 'x', config)).out)).toBe('fxx👩‍💻fxx👩‍💻'); }); test('--delimiter sets both', async () => { - await Bun.write('a.file', 'foo'); - await Bun.write('b.file', 'foo'); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); const config = {includeGlob: ['*.file'], output: true, delimiter: '🦕'}; const result = await rr('o', 'x', config); expect(asUtf8(result.out)).toBe('fxx🦕fxx🦕'); diff --git a/test/fuzzy/output.test.js b/test/fuzzy/output.test.js index d0a76666..d16a6999 100644 --- a/test/fuzzy/output.test.js +++ b/test/fuzzy/output.test.js @@ -1,7 +1,8 @@ import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; import {rr, random, ended} from '../helper.js'; -const read = names => Promise.all(names.map(name => Bun.file(name).text())); +const read = names => names.map(name => fs.readFileSync(name, 'utf8')); describe('--output', () => { test('sends exactly what would have been written to the files with the content delimiter after each', async () => { @@ -15,19 +16,21 @@ describe('--output', () => { // null is the default: a line break, unless the content already ends with one const contentDelimiter = rnd.pick([null, '', '\n', '\0', ', ']); for (const [n, name] of names.entries()) { - await Bun.write(name, texts[n]); + fs.writeFileSync(name, texts[n]); } const config = {includeGlob: names, output: true, contentDelimiter}; // As UTF-8 like stdout and the files, so half an emoji (from `.` without --unicode) becomes `�` in both const out = Buffer.from((await rr(pattern, '_', config)).out).toString(); - expect(await read(names)).toEqual(texts); + expect(read(names)).toEqual(texts); await rr(pattern, '_', {includeGlob: names}); expect(out, JSON.stringify({pattern, contentDelimiter, texts})).toBe( null === contentDelimiter - ? ended(...(await read(names))) - : (await read(names)).map(text => text + contentDelimiter).join('') + ? ended(...read(names)) + : read(names) + .map(text => text + contentDelimiter) + .join('') ); } }); From b86edd945c9a3241314e3a55ca4655647c1e9451 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 14:11:54 +1100 Subject: [PATCH 02/27] Write the files in the test itself, also where a helper ran the command --- test/cli/fix/691.test.js | 71 ++++++--- test/engine/fix/703.test.js | 299 +++++++++++++++++++++++++++++------- test/engine/fix/719.test.js | 40 +++-- 3 files changed, 322 insertions(+), 88 deletions(-) diff --git a/test/cli/fix/691.test.js b/test/cli/fix/691.test.js index bf2bbf4e..93db4cce 100644 --- a/test/cli/fix/691.test.js +++ b/test/cli/fix/691.test.js @@ -9,37 +9,62 @@ import {cli, sh} from '../../helper.js'; // With --output the name of the file goes to stderr const expectedOutput = 'my.file\n'; -// Replace each `o` in `foobar` with the replacement and return what the CLI gives back. -// Without the tail only the result of the replacement is given. -function replaceO(replacement, ...flags) { - fs.writeFileSync('my.file', 'foobar'); - return sh($`bun ${cli} o ${replacement} my.file --output --no-content-delimiter-tail ${flags}`); -} - +// Each test replaces each `o` in `foobar` with the replacement. Without the tail only the result of the replacement is given. describe('#691 replacement that used to crash the CLI', () => { test('a real line break is inserted as a line break', async () => { - expect(await replaceO('a\nb')).toEqual({out: 'fa\nba\nbbar', err: expectedOutput, exit: 0}); - expect((await replaceO('a\r\nb')).out).toBe('fa\r\nba\r\nbbar'); - expect((await replaceO('\u2028\u2029')).out).toBe('f\u2028\u2029\u2028\u2029bar'); + fs.writeFileSync('my.file', 'foobar'); + expect( + await sh($`bun ${cli} o ${'a\nb'} my.file --output --no-content-delimiter-tail`) + ).toEqual({out: 'fa\nba\nbbar', err: expectedOutput, exit: 0}); + expect( + (await sh($`bun ${cli} o ${'a\r\nb'} my.file --output --no-content-delimiter-tail`)).out + ).toBe('fa\r\nba\r\nbbar'); + expect( + (await sh($`bun ${cli} o ${'\u2028\u2029'} my.file --output --no-content-delimiter-tail`)).out + ).toBe('f\u2028\u2029\u2028\u2029bar'); }); test("`\\'` gives a single quote", async () => { - expect(await replaceO("\\'")).toEqual({out: "f''bar", err: expectedOutput, exit: 0}); + fs.writeFileSync('my.file', 'foobar'); + expect(await sh($`bun ${cli} o ${"\\'"} my.file --output --no-content-delimiter-tail`)).toEqual( + {out: "f''bar", err: expectedOutput, exit: 0} + ); }); test('a lone backslash at the end gives a backslash', async () => { - expect(await replaceO('\\')).toEqual({out: 'f\\\\bar', err: expectedOutput, exit: 0}); - expect((await replaceO('x\\')).out).toBe('fx\\x\\bar'); + fs.writeFileSync('my.file', 'foobar'); + expect(await sh($`bun ${cli} o ${'\\'} my.file --output --no-content-delimiter-tail`)).toEqual({ + out: 'f\\\\bar', + err: expectedOutput, + exit: 0, + }); + expect( + (await sh($`bun ${cli} o ${'x\\'} my.file --output --no-content-delimiter-tail`)).out + ).toBe('fx\\x\\bar'); }); test('a backslash in front of a real line break still joins the lines (as before)', async () => { - expect(await replaceO('a\\\nb')).toEqual({out: 'fababbar', err: expectedOutput, exit: 0}); - expect((await replaceO('a\\\r\nb')).out).toBe('fababbar'); - expect((await replaceO('a \\\n b \\\n c')).out).toBe('fa b ca b cbar'); + fs.writeFileSync('my.file', 'foobar'); + expect( + await sh($`bun ${cli} o ${'a\\\nb'} my.file --output --no-content-delimiter-tail`) + ).toEqual({out: 'fababbar', err: expectedOutput, exit: 0}); + expect( + (await sh($`bun ${cli} o ${'a\\\r\nb'} my.file --output --no-content-delimiter-tail`)).out + ).toBe('fababbar'); + expect( + ( + await sh( + $`bun ${cli} o ${'a \\\n b \\\n c'} my.file --output --no-content-delimiter-tail` + ) + ).out + ).toBe('fa b ca b cbar'); }); test('the replacement can not run as code', async () => { - const result = await replaceO("\\'+process.exit(3)//"); + fs.writeFileSync('my.file', 'foobar'); + const result = await sh( + $`bun ${cli} o ${"\\'+process.exit(3)//"} my.file --output --no-content-delimiter-tail` + ); expect(result).toEqual({ out: "f'+process.exit(3)//'+process.exit(3)//bar", err: expectedOutput, @@ -48,11 +73,14 @@ describe('#691 replacement that used to crash the CLI', () => { }); test('an invalid escape sequence gives an error message and no stack trace', async () => { - const result = await replaceO('\\x4'); + fs.writeFileSync('my.file', 'foobar'); + 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\n'); expect(result.exit).toBe(1); - expect(await replaceO('\\x4', '--quiet-total')).toEqual({out: '', err: '', exit: 1}); + expect( + await sh($`bun ${cli} o ${'\\x4'} my.file --output --no-content-delimiter-tail --quiet-total`) + ).toEqual({out: '', err: '', exit: 1}); }); test.each([ @@ -67,7 +95,10 @@ describe('#691 replacement that used to crash the CLI', () => { ['"', '"'], ['`${1}`', '`${1}`'], ])('%j still gives %j', async (replacement, expected) => { - expect(await replaceO(replacement)).toEqual({ + fs.writeFileSync('my.file', 'foobar'); + expect( + await sh($`bun ${cli} o ${replacement} my.file --output --no-content-delimiter-tail`) + ).toEqual({ out: `f${expected}${expected}bar`, err: expectedOutput, exit: 0, diff --git a/test/engine/fix/703.test.js b/test/engine/fix/703.test.js index 56e665ec..c078ce27 100644 --- a/test/engine/fix/703.test.js +++ b/test/engine/fix/703.test.js @@ -7,17 +7,17 @@ import {rr} from '../../helper.js'; // Note that rr() from the helper sets voidAsync per default, so the names come in the order of the file list -// Replace in a.file, b.file and c.file where b.file has nothing to replace -function run(flags = {}) { - fs.writeFileSync('a.file', 'foo'); - fs.writeFileSync('b.file', 'bar'); - fs.writeFileSync('c.file', 'foo'); - return rr('o', 'x', {includeGlob: ['*.file'], ...flags}); -} - +// Most tests replace in a.file, b.file and c.file, where b.file has nothing to replace describe('#703 the names of affected files', () => { test('go to stdout with one name on each line', async () => { - expect(await run()).toEqual({out: 'a.file\nc.file\n', err: '', exit: null}); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect(await rr('o', 'x', {includeGlob: ['*.file']})).toEqual({ + out: 'a.file\nc.file\n', + err: '', + exit: null, + }); expect(fs.readFileSync('a.file', 'utf8')).toBe('fxx'); }); @@ -27,20 +27,33 @@ describe('#703 the names of affected files', () => { ); test('all come with the async flow', async () => { - const result = await run({voidAsync: false}); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + const result = await rr('o', 'x', {includeGlob: ['*.file'], voidAsync: false}); expect(result.out.split('\n').sort()).toEqual(['', 'a.file', 'c.file']); expect(result.err).toBe(''); }); test('are alone on stdout with --verbose', async () => { - const result = await run({verbose: true}); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + const result = await rr('o', 'x', {includeGlob: ['*.file'], verbose: true}); expect(result.out).toBe('a.file\nc.file\n'); expect(result.err).toContain('Open sync: a.file'); expect(result.err).toContain('Nothing changed in: b.file'); }); test.each(['quiet', 'quietTotal'])('are not printed with --%s', async flag => { - expect(await run({[flag]: true})).toEqual({out: '', err: '', exit: null}); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect(await rr('o', 'x', {includeGlob: ['*.file'], [flag]: true})).toEqual({ + out: '', + err: '', + exit: null, + }); expect(fs.readFileSync('c.file', 'utf8')).toBe('fxx'); }); @@ -64,7 +77,14 @@ describe('#703 the names of affected files', () => { describe('--null-out', () => { test('ends each name with a null byte', async () => { - expect(await run({nullOut: true})).toEqual({out: 'a.file\0c.file\0', err: '', exit: null}); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect(await rr('o', 'x', {includeGlob: ['*.file'], nullOut: true})).toEqual({ + out: 'a.file\0c.file\0', + err: '', + exit: null, + }); }); test('keeps a name with a line break in one piece', async () => { @@ -77,7 +97,10 @@ describe('#703 the names of affected files', () => { describe('--name-delimiter', () => { test('is put after each name', async () => { - expect(await run({nameDelimiter: ', '})).toEqual({ + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect(await rr('o', 'x', {includeGlob: ['*.file'], nameDelimiter: ', '})).toEqual({ out: 'a.file, c.file, ', err: '', exit: null, @@ -85,22 +108,56 @@ describe('#703 the names of affected files', () => { }); test('can be an empty string', async () => { - expect((await run({nameDelimiter: ''})).out).toBe('a.filec.file'); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect((await rr('o', 'x', {includeGlob: ['*.file'], nameDelimiter: ''})).out).toBe( + 'a.filec.file' + ); }); test('has the € and § aliases like the replacement', async () => { - expect((await run({nameDelimiter: '€§'})).out).toBe('a.file$\\c.file$\\'); - expect((await run({nameDelimiter: '€§', voidEuro: true})).out).toBe('a.file€\\c.file€\\'); - expect((await run({nameDelimiter: '€§', voidSection: true})).out).toBe('a.file$§c.file$§'); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect((await rr('o', 'x', {includeGlob: ['*.file'], nameDelimiter: '€§'})).out).toBe( + 'a.file$\\c.file$\\' + ); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect( + (await rr('o', 'x', {includeGlob: ['*.file'], nameDelimiter: '€§', voidEuro: true})).out + ).toBe('a.file€\\c.file€\\'); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect( + (await rr('o', 'x', {includeGlob: ['*.file'], nameDelimiter: '€§', voidSection: true})).out + ).toBe('a.file$§c.file$§'); }); }); describe('--no-name-delimiter-tail', () => { test('puts the delimiter only between the names', async () => { - expect((await run({nameDelimiter: ', ', nameDelimiterTail: false})).out).toBe( - 'a.file, c.file' + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect( + ( + await rr('o', 'x', { + includeGlob: ['*.file'], + nameDelimiter: ', ', + nameDelimiterTail: false, + }) + ).out + ).toBe('a.file, c.file'); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect((await rr('o', 'x', {includeGlob: ['*.file'], nameDelimiterTail: false})).out).toBe( + 'a.file\nc.file' ); - expect((await run({nameDelimiterTail: false})).out).toBe('a.file\nc.file'); }); test('prints no delimiter when only one file is changed', async () => { @@ -114,10 +171,22 @@ describe('#703 the names of affected files', () => { }); test('does not start the next run with a delimiter', async () => { - await run({nameDelimiter: ', ', nameDelimiterTail: false}); - expect((await run({nameDelimiter: ', ', nameDelimiterTail: false})).out).toBe( - 'a.file, c.file' - ); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + await rr('o', 'x', {includeGlob: ['*.file'], nameDelimiter: ', ', nameDelimiterTail: false}); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect( + ( + await rr('o', 'x', { + includeGlob: ['*.file'], + nameDelimiter: ', ', + nameDelimiterTail: false, + }) + ).out + ).toBe('a.file, c.file'); }); // The names must not be held back until all files are done @@ -163,7 +232,10 @@ describe('#703 the names of affected files', () => { describe('--name-all', () => { test('also names the files where nothing is changed', async () => { - expect(await run({nameAll: true})).toEqual({ + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect(await rr('o', 'x', {includeGlob: ['*.file'], nameAll: true})).toEqual({ out: 'a.file\nb.file\nc.file\n', err: '', exit: null, @@ -172,7 +244,10 @@ describe('#703 the names of affected files', () => { }); test('names every file printed with --output', async () => { - expect(await run({nameAll: true, output: true})).toEqual({ + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect(await rr('o', 'x', {includeGlob: ['*.file'], nameAll: true, output: true})).toEqual({ out: 'fxx\nbar\nfxx\n', err: 'a.file\nb.file\nc.file\n', exit: null, @@ -190,8 +265,15 @@ describe('#703 the names of affected files', () => { describe('--content-delimiter', () => { test('is a line break after each file and each match by default', async () => { - expect((await run({output: true})).out).toBe('fxx\nbar\nfxx\n'); - expect((await run({outputMatch: true})).out).toBe('o\no\no\no\n'); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect((await rr('o', 'x', {includeGlob: ['*.file'], output: true})).out).toBe( + 'fxx\nbar\nfxx\n' + ); + expect((await rr('o', 'x', {includeGlob: ['*.file'], outputMatch: true})).out).toBe( + 'o\no\no\no\n' + ); }); test('adds no line break by default to content that already ends with one', async () => { @@ -203,17 +285,32 @@ describe('#703 the names of affected files', () => { }); test('is put after the content of each file', async () => { - expect((await run({output: true, contentDelimiter: '\t'})).out).toBe('fxx\tbar\tfxx\t'); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect( + (await rr('o', 'x', {includeGlob: ['*.file'], output: true, contentDelimiter: '\t'})).out + ).toBe('fxx\tbar\tfxx\t'); }); test('is put after each match with --output-match, and not after the names', async () => { - const result = await run({outputMatch: true, contentDelimiter: ', '}); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + const result = await rr('o', 'x', { + includeGlob: ['*.file'], + outputMatch: true, + contentDelimiter: ', ', + }); expect(result).toEqual({out: 'o, o, o, o, ', err: 'a.file\nc.file\n', exit: null}); }); test('is only put between the content with --no-content-delimiter-tail', async () => { const config = {output: true, contentDelimiter: ', ', contentDelimiterTail: false}; - expect((await run(config)).out).toBe('fxx, bar, fxx'); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect((await rr('o', 'x', {includeGlob: ['*.file'], ...config})).out).toBe('fxx, bar, fxx'); }); test('leaves piped data as it is by default', async () => { @@ -238,16 +335,28 @@ describe('#703 the names of affected files', () => { describe('--tail', () => { test('puts the delimiter after the last name and the last content', async () => { - expect(await run({output: true, delimiter: ', ', tail: true})).toEqual({ + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect( + await rr('o', 'x', {includeGlob: ['*.file'], output: true, delimiter: ', ', tail: true}) + ).toEqual({ out: 'fxx, bar, fxx, ', err: 'a.file, c.file, ', exit: null, }); - expect((await run({outputMatch: true, tail: true})).out).toBe('o\no\no\no\n'); + expect( + (await rr('o', 'x', {includeGlob: ['*.file'], outputMatch: true, tail: true})).out + ).toBe('o\no\no\no\n'); }); test('--no-tail puts the delimiter only between the names and the content', async () => { - expect(await run({output: true, delimiter: ', ', tail: false})).toEqual({ + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect( + await rr('o', 'x', {includeGlob: ['*.file'], output: true, delimiter: ', ', tail: false}) + ).toEqual({ out: 'fxx, bar, fxx', err: 'a.file, c.file', exit: null, @@ -256,7 +365,10 @@ describe('#703 the names of affected files', () => { test('the specific tail flags win', async () => { const config = {output: true, delimiter: ', ', tail: false, nameDelimiterTail: true}; - expect(await run(config)).toEqual({ + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect(await rr('o', 'x', {includeGlob: ['*.file'], ...config})).toEqual({ out: 'fxx, bar, fxx', err: 'a.file, c.file, ', exit: null, @@ -267,12 +379,20 @@ describe('#703 the names of affected files', () => { describe('--nl', () => { test('ends the names with a line break', async () => { const config = {nameDelimiter: ', ', nameDelimiterTail: false, nl: true}; - expect((await run(config)).out).toBe('a.file, c.file\n'); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect((await rr('o', 'x', {includeGlob: ['*.file'], ...config})).out).toBe( + 'a.file, c.file\n' + ); }); test('ends the content and the names on stderr with a line break', async () => { const config = {outputMatch: true, delimiter: ', ', tail: false, nl: true}; - expect(await run(config)).toEqual({ + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect(await rr('o', 'x', {includeGlob: ['*.file'], ...config})).toEqual({ out: 'o, o, o, o\n', err: 'a.file, c.file\n', exit: null, @@ -301,14 +421,22 @@ describe('#703 the names of affected files', () => { contentDelimiterTail: true, nl: true, }; - expect((await run(config)).out).toBe('o, o, o, o, \n'); - expect((await run({outputMatch: true, tail: true, nl: true})).out).toBe('o\no\no\no\n'); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect((await rr('o', 'x', {includeGlob: ['*.file'], ...config})).out).toBe('o, o, o, o, \n'); + expect( + (await rr('o', 'x', {includeGlob: ['*.file'], outputMatch: true, tail: true, nl: true})).out + ).toBe('o\no\no\no\n'); }); }); describe('--delimiter and --null-out', () => { test('--delimiter sets both delimiters', async () => { - expect(await run({output: true, delimiter: ', '})).toEqual({ + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect(await rr('o', 'x', {includeGlob: ['*.file'], output: true, delimiter: ', '})).toEqual({ out: 'fxx, bar, fxx, ', err: 'a.file, c.file, ', exit: null, @@ -316,7 +444,10 @@ describe('#703 the names of affected files', () => { }); test('--null-out is the same as --delimiter with a null byte', async () => { - expect(await run({output: true, nullOut: true})).toEqual({ + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect(await rr('o', 'x', {includeGlob: ['*.file'], output: true, nullOut: true})).toEqual({ out: 'fxx\0bar\0fxx\0', err: 'a.file\0c.file\0', exit: null, @@ -324,18 +455,36 @@ describe('#703 the names of affected files', () => { }); test('the most specific delimiter wins', async () => { - expect((await run({output: true, delimiter: ', ', nullOut: true})).out).toBe( - 'fxx, bar, fxx, ' - ); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect( + ( + await rr('o', 'x', { + includeGlob: ['*.file'], + output: true, + delimiter: ', ', + nullOut: true, + }) + ).out + ).toBe('fxx, bar, fxx, '); expect( - await run({ + await rr('o', 'x', { + includeGlob: ['*.file'], output: true, delimiter: ', ', contentDelimiter: '|', nameDelimiter: ';', }) ).toEqual({out: 'fxx|bar|fxx|', err: 'a.file;c.file;', exit: null}); - expect(await run({output: true, nullOut: true, nameDelimiter: ', '})).toEqual({ + expect( + await rr('o', 'x', { + includeGlob: ['*.file'], + output: true, + nullOut: true, + nameDelimiter: ', ', + }) + ).toEqual({ out: 'fxx\0bar\0fxx\0', err: 'a.file, c.file, ', exit: null, @@ -349,30 +498,65 @@ describe('#703 the names of affected files', () => { ['outputMatch', 'o\no\no\no\n'], ])('with --%s', (flag, out) => { test('go to stderr', async () => { - expect(await run({[flag]: true})).toEqual({out, err: 'a.file\nc.file\n', exit: null}); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect(await rr('o', 'x', {includeGlob: ['*.file'], [flag]: true})).toEqual({ + out, + err: 'a.file\nc.file\n', + exit: null, + }); expect(fs.readFileSync('a.file', 'utf8')).toBe('foo'); }); test('are the same names as without the flag', async () => { - expect((await run({[flag]: true})).err).toBe((await run()).out); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect((await rr('o', 'x', {includeGlob: ['*.file'], [flag]: true})).err).toBe( + (await rr('o', 'x', {includeGlob: ['*.file']})).out + ); }); test.each(['quiet', 'quietTotal'])('are not printed with --%s', async quiet => { - expect(await run({[flag]: true, [quiet]: true})).toEqual({out, err: '', exit: null}); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect(await rr('o', 'x', {includeGlob: ['*.file'], [flag]: true, [quiet]: true})).toEqual({ + out, + err: '', + exit: null, + }); }); test('follow --null-out, --name-delimiter and --full-path', async () => { - expect((await run({[flag]: true, nullOut: true})).err).toBe('a.file\0c.file\0'); - expect((await run({[flag]: true, nameDelimiter: ', '})).err).toBe('a.file, c.file, '); - expect((await run({[flag]: true, fullPath: true})).err).toBe( - path.resolve('a.file') + '\n' + path.resolve('c.file') + '\n' + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect((await rr('o', 'x', {includeGlob: ['*.file'], [flag]: true, nullOut: true})).err).toBe( + 'a.file\0c.file\0' ); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect( + (await rr('o', 'x', {includeGlob: ['*.file'], [flag]: true, nameDelimiter: ', '})).err + ).toBe('a.file, c.file, '); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect( + (await rr('o', 'x', {includeGlob: ['*.file'], [flag]: true, fullPath: true})).err + ).toBe(path.resolve('a.file') + '\n' + path.resolve('c.file') + '\n'); }); }); describe('--full-path', () => { test('prints the full path of each name', async () => { - expect(await run({fullPath: true})).toEqual({ + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect(await rr('o', 'x', {includeGlob: ['*.file'], fullPath: true})).toEqual({ out: path.resolve('a.file') + '\n' + path.resolve('c.file') + '\n', err: '', exit: null, @@ -393,7 +577,10 @@ describe('#703 the names of affected files', () => { ['null', {nullOut: true}, '\0', '\0'], ['name-delimiter', {nameDelimiter: ', ', nameDelimiterTail: false}, ', ', ''], ])('works with --%s', async (name, flags, between, last) => { - expect((await run({fullPath: true, ...flags})).out).toBe( + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect((await rr('o', 'x', {includeGlob: ['*.file'], fullPath: true, ...flags})).out).toBe( path.resolve('a.file') + between + path.resolve('c.file') + last ); }); diff --git a/test/engine/fix/719.test.js b/test/engine/fix/719.test.js index 17c1571f..26d4774b 100644 --- a/test/engine/fix/719.test.js +++ b/test/engine/fix/719.test.js @@ -7,24 +7,29 @@ import {rr} from '../../helper.js'; const warning = ' 🟡 Data is piped in, but no flag tells what to do with it. It is not used.\n\n'; const expectedOutput = 'my.file\n'; -// Run on my.file (with the content `foobar`) with `x` piped in -function piped(flags = {}, replacement = '_') { - fs.writeFileSync('my.file', 'foobar'); - return rr('b', replacement, {includeGlob: ['my.file'], pipedData: 'x', ...flags}); -} - describe('#719 a warning when piped data is not used', () => { test('is given when data is piped in and files are also given', async () => { - expect(await piped()).toEqual({out: expectedOutput, err: warning, exit: null}); + fs.writeFileSync('my.file', 'foobar'); + expect(await rr('b', '_', {includeGlob: ['my.file'], pipedData: 'x'})).toEqual({ + out: expectedOutput, + err: warning, + exit: null, + }); expect(fs.readFileSync('my.file', 'utf8')).toBe('foo_ar'); }); test.each(['quiet', 'quietTotal'])('is not shown with --%s', async flag => { - expect(await piped({[flag]: true})).toEqual({out: '', err: '', exit: null}); + fs.writeFileSync('my.file', 'foobar'); + expect(await rr('b', '_', {includeGlob: ['my.file'], pipedData: 'x', [flag]: true})).toEqual({ + out: '', + err: '', + exit: null, + }); }); test('does not stop the run with --halt', async () => { - expect(await piped({halt: true})).toEqual({ + fs.writeFileSync('my.file', 'foobar'); + expect(await rr('b', '_', {includeGlob: ['my.file'], pipedData: 'x', halt: true})).toEqual({ out: expectedOutput, err: warning, exit: null, @@ -33,12 +38,18 @@ describe('#719 a warning when piped data is not used', () => { }); test('is not given when the piped data is used', async () => { - expect(await piped({replacementPipe: true})).toEqual({ + fs.writeFileSync('my.file', 'foobar'); + expect( + await rr('b', '_', {includeGlob: ['my.file'], pipedData: 'x', replacementPipe: true}) + ).toEqual({ out: expectedOutput, err: '', exit: null, }); - expect(await piped({replacementJs: true}, 'pipe')).toEqual({ + fs.writeFileSync('my.file', 'foobar'); + expect( + await rr('b', 'pipe', {includeGlob: ['my.file'], pipedData: 'x', replacementJs: true}) + ).toEqual({ out: expectedOutput, err: '', exit: null, @@ -47,7 +58,12 @@ describe('#719 a warning when piped data is not used', () => { }); test.each([null, '', '\n', '\r\n'])('is not given when %j is piped in', async pipedData => { - expect(await piped({pipedData})).toEqual({out: expectedOutput, err: '', exit: null}); + fs.writeFileSync('my.file', 'foobar'); + expect(await rr('b', '_', {includeGlob: ['my.file'], pipedData})).toEqual({ + out: expectedOutput, + err: '', + exit: null, + }); }); test('is not given when only data is piped in', async () => { From fb83f6e1730b7b56c026cc8a20f8ea6eebe69db3 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 14:12:32 +1100 Subject: [PATCH 03/27] Write the files in the test itself in the tests for #726 and #728 --- test/engine/fix/726.test.js | 35 +++++++++++++++++++++-------------- test/engine/fix/728.test.js | 18 ++++++++---------- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/test/engine/fix/726.test.js b/test/engine/fix/726.test.js index 573ed668..4d667901 100644 --- a/test/engine/fix/726.test.js +++ b/test/engine/fix/726.test.js @@ -11,19 +11,14 @@ const flows = [ ['--void-async', {voidAsync: true}], ]; -// Make `name` with the content `foobar` in a directory that is not the working directory and give the absolute path of it -function outside(name) { - fs.mkdirSync('work'); - fs.mkdirSync('other'); - const file = path.resolve('other', name); - fs.writeFileSync(file, 'foobar'); - process.chdir('work'); - return file; -} - +// The file is in a folder that is not the working folder, so it can only be given with its absolute path describe('#726 a file given with an absolute path is changed', () => { test.each(flows)('as a path with %s', async (name, flags) => { - const file = outside('my.file'); + fs.mkdirSync('work'); + fs.mkdirSync('other'); + const file = path.resolve('other', 'my.file'); + fs.writeFileSync(file, 'foobar'); + process.chdir('work'); const result = await rr('o', 'x', {includeGlob: [file], ...flags}); expect(result).toEqual({out: file + '\n', err: '', exit: null}); expect(fs.readFileSync(file, 'utf8')).toBe('fxxbar'); @@ -31,7 +26,11 @@ describe('#726 a file given with an absolute path is changed', () => { }); test.each(flows)('as a glob with %s', async (name, flags) => { - const file = outside('my.file'); + fs.mkdirSync('work'); + fs.mkdirSync('other'); + const file = path.resolve('other', 'my.file'); + fs.writeFileSync(file, 'foobar'); + process.chdir('work'); const glob = path.join(path.dirname(file), '*.file'); const result = await rr('o', 'x', {includeGlob: [glob], ...flags}); expect(result).toEqual({out: file + '\n', err: '', exit: null}); @@ -51,7 +50,11 @@ describe('#726 a file given with an absolute path is changed', () => { test.each(flows)( '--keep-backup puts the backup next to the file with %s', async (name, flags) => { - const file = outside('my.file'); + fs.mkdirSync('work'); + fs.mkdirSync('other'); + const file = path.resolve('other', 'my.file'); + fs.writeFileSync(file, 'foobar'); + process.chdir('work'); await rr('o', 'x', {includeGlob: [file], keepBackup: true, ...flags}); const [backup, ...rest] = fs .readdirSync(path.dirname(file)) @@ -65,7 +68,11 @@ describe('#726 a file given with an absolute path is changed', () => { ); test('--replacement-js gets the path of the file as it is', async () => { - const file = outside('my.file'); + fs.mkdirSync('work'); + fs.mkdirSync('other'); + const file = path.resolve('other', 'my.file'); + fs.writeFileSync(file, 'foobar'); + process.chdir('work'); const result = await rr( 'foobar', '[file, dirpath, file_rel, dirpath_rel, typeof mtime].join("|")', diff --git a/test/engine/fix/728.test.js b/test/engine/fix/728.test.js index 8c1422bc..30c08e57 100644 --- a/test/engine/fix/728.test.js +++ b/test/engine/fix/728.test.js @@ -10,17 +10,12 @@ const flows = [ ['--void-async', {voidAsync: true}], ]; -// Make a.file and b.file with the content `foo`, where b.file can not be read -function oneUnreadable() { - fs.writeFileSync('a.file', 'foo'); - fs.writeFileSync('b.file', 'foo'); - fs.chmodSync('b.file', 0); - return {includeGlob: ['a.file', 'b.file']}; -} - describe('#728 the exit code is 1 when a file gives an error', () => { test.each(flows)('and the other files are still changed with %s', async (name, flags) => { - const result = await rr('o', 'x', {...oneUnreadable(), ...flags}); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + fs.chmodSync('b.file', 0); + const result = await rr('o', 'x', {includeGlob: ['a.file', 'b.file'], ...flags}); expect(result.out).toBe('a.file\n'); expect(result.err).toContain('EACCES'); expect(result.exit).toBe(1); @@ -45,6 +40,9 @@ describe('#728 the exit code is 1 when a file gives an error', () => { }); test.each(['quiet', 'quietTotal'])('also with --%s', async flag => { - expect((await rr('o', 'x', {...oneUnreadable(), [flag]: true})).exit).toBe(1); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + fs.chmodSync('b.file', 0); + expect((await rr('o', 'x', {includeGlob: ['a.file', 'b.file'], [flag]: true})).exit).toBe(1); }); }); From db32953f1273cef1584a6dccf9abd7a33853d814 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 14:17:13 +1100 Subject: [PATCH 04/27] =?UTF-8?q?Start=20every=20error=20with=20=E2=9D=8C,?= =?UTF-8?q?=20and=20make=200=20files=20found=20a=20warning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 ++ src/engine.ts | 3 +- src/output.ts | 3 +- test/cli/debug.test.js | 6 ++-- test/cli/fix/742.test.js | 6 ++-- test/cli/run.ai.sh | 10 +++--- test/cli/run.sh | 11 ++++-- test/engine/basics.test.js | 6 ++-- test/engine/exclude-glob.test.js | 2 +- test/engine/exclude-re.test.js | 2 +- test/engine/fix/681.test.js | 13 ++++--- test/engine/fix/682.test.js | 8 ++--- test/engine/fix/683.test.js | 2 +- test/engine/fix/711.test.js | 4 +-- test/engine/fix/718.test.js | 6 ++-- test/engine/fix/720.test.js | 54 ++++++++++++++++++++++++++++ test/engine/halt.test.js | 16 ++++++--- test/engine/quiet-total.test.js | 4 ++- test/engine/quiet.test.js | 6 ++-- test/engine/replacement-pipe.test.js | 2 +- 20 files changed, 123 insertions(+), 43 deletions(-) create mode 100644 test/engine/fix/720.test.js diff --git a/README.md b/README.md index 65a440af..b86ef4e4 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,8 @@ Files identified for potential processing are identified by: - Piped data that is printed is left as it came in, so no delimiter is added after the end of it. Matches from `--output-match` are different: they are new output, so they get the delimiter whether they come from a file or from piped data. +- Finding no files is a warning (🟡) and not an error, so the exit code is 0. Nothing went wrong, there was just nothing to do. Errors start with ❌ and give the exit code 1. + ### Limitations diff --git a/src/engine.ts b/src/engine.ts index 0bd0fb82..37f7c830 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -136,8 +136,9 @@ async function run(config) { config.files = getFilePaths(config); + // Nothing to do is not an error, so a glob or a pipe that gives no files does not stop a script if (!config.files.length) { - return error(config.files.length + ' files found'); + return warn('0 files found'); } chat(config.files.length + ' files found'); diff --git a/src/output.ts b/src/output.ts index 985cd895..ed746629 100644 --- a/src/output.ts +++ b/src/output.ts @@ -110,7 +110,8 @@ export const error = function (msg, data = '') { const text = msg + (data ? ' ' + data : ''); config.statusInfo?.errors.push(text); if (!config.quietTotal) { - write(font.red(text) + '\n', process.stderr); + // The line break gives an empty line under the error + write(font.red(' ❌ ' + text) + '\n\n', process.stderr); } if (config.halt) { throw new Panic(text); diff --git a/test/cli/debug.test.js b/test/cli/debug.test.js index b2e48d55..3cba492f 100644 --- a/test/cli/debug.test.js +++ b/test/cli/debug.test.js @@ -39,10 +39,12 @@ describe('--debug', () => { }); test('gives the errors in the status report, and the exit code 1', async () => { - const result = await sh($`bun ${cli} o x nope.file --debug`); + fs.writeFileSync('a.file', 'foo'); + fs.chmodSync('a.file', 0); + const result = await sh($`bun ${cli} o x a.file --debug`); expect(result.err).toBe(''); expect(result.exit).toBe(1); - expect(JSON.parse(result.out).errors).toEqual(['0 files found']); + expect(JSON.parse(result.out).errors[0]).toContain('EACCES'); }); test('gives the error of an invalid pattern in the status report', async () => { diff --git a/test/cli/fix/742.test.js b/test/cli/fix/742.test.js index ff2d6285..0ec96d7d 100644 --- a/test/cli/fix/742.test.js +++ b/test/cli/fix/742.test.js @@ -34,8 +34,10 @@ describe('#742 the CLI', () => { ); test('sets the exit code to 1 when a file gives an error, and prints the status report', async () => { - const result = await sh($`bun ${cli} o x nope.file --status-compact --quiet-total`); - expect(JSON.parse(result.out).errors).toEqual(['0 files found']); + fs.writeFileSync('a.file', 'foo'); + fs.chmodSync('a.file', 0); + const result = await sh($`bun ${cli} o x a.file --status-compact --quiet-total`); + expect(JSON.parse(result.out).errors[0]).toContain('EACCES'); expect(result.exit).toBe(1); }); diff --git a/test/cli/run.ai.sh b/test/cli/run.ai.sh index 01b69976..55ea7d21 100644 --- a/test/cli/run.ai.sh +++ b/test/cli/run.ai.sh @@ -93,17 +93,15 @@ fi reset # Successful run should have NO stdout echo assert "rexreplace foo xxx my.file -q" "" -# Error run (file not found) should still print error to stderr (tested indirectly via assert_raises failure message) -# We can't easily assert stderr content with aserta, but we can check it doesn't suppress errors needed for failure -assert_raises "rexreplace foo xxx non_existent_file.txt -q" 1 # Exit code should still be non-zero +# No files found is a warning, so the exit code is 0 +assert_raises "rexreplace foo xxx non_existent_file.txt -q" 0 # -Q, --quiet-total reset # Successful run should have NO stdout echo assert "rexreplace foo xxx my.file -Q" "" -# Error run should have NO output at all (stdout or stderr) -# Check exit code is still non-zero, but no output should be printed by aserta on failure if rexreplace is truly quiet -assert_raises "rexreplace foo xxx non_existent_file.txt -Q" 1 +# No files found is a warning, so the exit code is 0 +assert_raises "rexreplace foo xxx non_existent_file.txt -Q" 0 # -H, --halt reset diff --git a/test/cli/run.sh b/test/cli/run.sh index 341a7172..06596d6e 100644 --- a/test/cli/run.sh +++ b/test/cli/run.sh @@ -54,10 +54,15 @@ assert_failure "rexreplace my-vile" assert_failure "rexreplace o-ho" -# --halt (or --bail) stops at the first error +# --halt (or --bail) stops at the first error, here that my.file can not be read reset -assert_raises "rexreplace o x nope.file --halt" 1 -assert_raises "rexreplace o x nope.file --bail" 1 +chmod 000 my.file +assert_raises "rexreplace b x my.file your.file --halt" 1 +assert_raises "rexreplace b x my.file your.file --bail" 1 +assert "cat your.file" "abc123" + +# Finding no files is a warning and not an error +assert_raises "rexreplace o x nope.file" 0 # --output prints the result and leaves the file as it is diff --git a/test/engine/basics.test.js b/test/engine/basics.test.js index 06956eda..6dd1a679 100644 --- a/test/engine/basics.test.js +++ b/test/engine/basics.test.js @@ -176,13 +176,13 @@ describe('files', () => { fs.mkdirSync('dir.file'); fs.writeFileSync('.hidden.file', 'foobar'); const result = await rr('o', 'x', {includeGlob: ['*']}); - expect(result.err).toBe('0 files found\n'); + expect(result.err).toBe(' 🟡 0 files found\n\n'); expect(fs.readFileSync('.hidden.file', 'utf8')).toBe('foobar'); }); test('are reported if none are found', async () => { const result = await rr('o', 'x', {includeGlob: ['nope.file']}); - expect(result.err).toBe('0 files found\n'); + expect(result.err).toBe(' 🟡 0 files found\n\n'); expect(result.out).toBe(''); }); }); @@ -217,6 +217,6 @@ describe('piped data', () => { }); test('leaves nothing to do if neither data nor files are given', async () => { - expect(await rr('o', 'x')).toEqual({out: '', err: '0 files found\n', exit: 1}); + expect(await rr('o', 'x')).toEqual({out: '', err: ' 🟡 0 files found\n\n', exit: null}); }); }); diff --git a/test/engine/exclude-glob.test.js b/test/engine/exclude-glob.test.js index 7a63d4e3..19181d00 100644 --- a/test/engine/exclude-glob.test.js +++ b/test/engine/exclude-glob.test.js @@ -50,7 +50,7 @@ describe('--exclude-glob', () => { test('reports when all files are left out', async () => { fs.writeFileSync('my.file', 'foo'); const result = await rr('foo', 'x', {includeGlob: ['*.file'], excludeGlob: ['*.file']}); - expect(result.err).toBe('0 files found\n'); + expect(result.err).toBe(' 🟡 0 files found\n\n'); expect(fs.readFileSync('my.file', 'utf8')).toBe('foo'); }); diff --git a/test/engine/exclude-re.test.js b/test/engine/exclude-re.test.js index d62477c1..6e072c36 100644 --- a/test/engine/exclude-re.test.js +++ b/test/engine/exclude-re.test.js @@ -48,7 +48,7 @@ describe('--exclude-re', () => { test('reports when all files are left out', async () => { fs.writeFileSync('my.file', 'foo'); const result = await rr('foo', 'x', {includeGlob: ['my.file'], excludeRe: ['.']}); - expect(result.err).toBe('0 files found\n'); + expect(result.err).toBe(' 🟡 0 files found\n\n'); expect(fs.readFileSync('my.file', 'utf8')).toBe('foo'); }); diff --git a/test/engine/fix/681.test.js b/test/engine/fix/681.test.js index 357c981a..beef3631 100644 --- a/test/engine/fix/681.test.js +++ b/test/engine/fix/681.test.js @@ -1,12 +1,15 @@ // https://github.com/mathiasrw/rexreplace/issues/681 // `--halt` used the error message as exit code: Node 18 exited with 0, newer Node versions crashed with ERR_INVALID_ARG_TYPE. import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; import {rr} from '../../helper.js'; describe('#681 --halt exits with a numeric non-zero code', () => { - test('no files found', async () => { - const result = await rr('o', 'x', {includeGlob: ['nope.file'], halt: true}); - expect(result.err).toContain('0 files found'); + test('a file that can not be read', async () => { + fs.writeFileSync('a.file', 'foo'); + fs.chmodSync('a.file', 0); + const result = await rr('o', 'x', {includeGlob: ['a.file'], halt: true}); + expect(result.err).toContain('EACCES'); expect(result.exit).toBe(1); }); @@ -17,8 +20,10 @@ describe('#681 --halt exits with a numeric non-zero code', () => { }); test('--quiet-total still halts with the exit code but shows no message', async () => { + fs.writeFileSync('a.file', 'foo'); + fs.chmodSync('a.file', 0); const result = await rr('o', 'x', { - includeGlob: ['nope.file'], + includeGlob: ['a.file'], halt: true, quietTotal: true, }); diff --git a/test/engine/fix/682.test.js b/test/engine/fix/682.test.js index ac6fc7db..73d23ecd 100644 --- a/test/engine/fix/682.test.js +++ b/test/engine/fix/682.test.js @@ -13,10 +13,10 @@ describe('#682 --quiet still displays errors', () => { expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar'); }); - test('no files found', async () => { - expect((await rr('o', 'x', {includeGlob: ['nope.file'], quiet: true})).err).toContain( - '0 files found' - ); + test('a file that can not be read', async () => { + fs.writeFileSync('a.file', 'foo'); + fs.chmodSync('a.file', 0); + expect((await rr('o', 'x', {includeGlob: ['a.file'], quiet: true})).err).toContain('EACCES'); }); test('piped data with invalid regular expression', async () => { diff --git a/test/engine/fix/683.test.js b/test/engine/fix/683.test.js index aa9b6d63..2840a13c 100644 --- a/test/engine/fix/683.test.js +++ b/test/engine/fix/683.test.js @@ -15,7 +15,7 @@ describe('#683 --exclude-re follows the regex flags of the main search', () => { test('the example from the issue', async () => { fs.writeFileSync('a.file', 'x\n'); const config = {includeGlob: ['a.file'], excludeRe: ['A'], output: true}; - expect(await rr('x', 'y', config)).toEqual({out: '', err: '0 files found\n', exit: 1}); + expect(await rr('x', 'y', config)).toEqual({out: '', err: ' 🟡 0 files found\n\n', exit: null}); expect((await rr('x', 'y', {...config, voidIgnoreCase: true})).out).toBe('y\n'); }); diff --git a/test/engine/fix/711.test.js b/test/engine/fix/711.test.js index 477206b2..37a6b15d 100644 --- a/test/engine/fix/711.test.js +++ b/test/engine/fix/711.test.js @@ -65,8 +65,8 @@ describe('#711 --glob-pipe', () => { test('with nothing piped in and no arguments there are no files', async () => { expect(await rr('o', 'x', {pipedData: '', globPipe: true})).toEqual({ out: '', - err: '0 files found\n', - exit: 1, + err: ' 🟡 0 files found\n\n', + exit: null, }); }); diff --git a/test/engine/fix/718.test.js b/test/engine/fix/718.test.js index 4ec33705..4ff45080 100644 --- a/test/engine/fix/718.test.js +++ b/test/engine/fix/718.test.js @@ -57,7 +57,7 @@ describe('#718 together with globs', () => { test('a path to a folder is not a file, so it is still a glob', async () => { fs.mkdirSync('docs', {recursive: true}); fs.writeFileSync('docs/a.file', 'foo'); - expect((await rr('o', 'x', {includeGlob: ['docs']})).exit).toBe(1); + expect((await rr('o', 'x', {includeGlob: ['docs']})).err).toBe(' 🟡 0 files found\n\n'); expect((await rr('o', 'x', {includeGlob: ['docs/*.file']})).out).toBe('docs/a.file\n'); }); @@ -134,7 +134,7 @@ describe('#718 with --exclude-glob', () => { test('a glob leaves out a file given by its name', async () => { fs.writeFileSync('a[1].file', 'foo'); const config = {includeGlob: ['a[1].file'], excludeGlob: ['*.file']}; - expect(await rr('o', 'x', config)).toEqual({out: '', err: '0 files found\n', exit: 1}); + expect(await rr('o', 'x', config)).toEqual({out: '', err: ' 🟡 0 files found\n\n', exit: null}); expect(content('a[1].file')).toBe('foo'); }); @@ -162,7 +162,7 @@ describe('#718 with --exclude-re', () => { expect((await rr('o', 'x', {includeGlob: ['a.file'], excludeRe: ['^/']})).out).toBe('a.file\n'); fs.writeFileSync('a.file', 'foo'); const config = {includeGlob: ['a.file'], excludeRe: ['^/'], fullPath: true}; - expect((await rr('o', 'x', config)).exit).toBe(1); + expect((await rr('o', 'x', config)).err).toBe(' 🟡 0 files found\n\n'); expect(content('a.file')).toBe('foo'); }); }); diff --git a/test/engine/fix/720.test.js b/test/engine/fix/720.test.js new file mode 100644 index 00000000..e7095db5 --- /dev/null +++ b/test/engine/fix/720.test.js @@ -0,0 +1,54 @@ +// https://github.com/mathiasrw/rexreplace/issues/720 +// Only the errors that stopped the run started with ❌, so the other errors looked like the info around them. +// Finding no files was an error with the exit code 1, also when there was just nothing to do. +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {rr, rrStatus} from '../../helper.js'; + +describe('#720 every error starts with ❌', () => { + test('an error from a file, and the other files are still changed', async () => { + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + fs.chmodSync('a.file', 0); + const result = await rr('o', 'x', {includeGlob: ['a.file', 'b.file']}); + expect(result.out).toBe('b.file\n'); + expect(result.err).toStartWith(' ❌ '); + expect(result.err).toContain('EACCES'); + expect(result.err).toEndWith('\n\n'); + expect(result.exit).toBe(1); + }); + + test('an error that stops the run', async () => { + expect((await rr('(', 'x', {pipedData: 'foo'})).err).toStartWith( + ' ❌ Invalid regular expression' + ); + }); +}); + +describe('#720 finding no files is a warning', () => { + test('with the exit code 0', async () => { + expect(await rr('o', 'x', {includeGlob: ['nope.file']})).toEqual({ + out: '', + err: ' 🟡 0 files found\n\n', + exit: null, + }); + }); + + test('in the status report', async () => { + const status = await rrStatus('o', 'x', {includeGlob: ['nope.file']}); + expect(status.warnings).toEqual(['0 files found']); + expect(status.errors).toEqual([]); + }); + + test.each(['quiet', 'quietTotal'])('not shown with --%s', async flag => { + expect(await rr('o', 'x', {includeGlob: ['nope.file'], [flag]: true})).toEqual({ + out: '', + err: '', + exit: null, + }); + }); + + test('does not stop the run with --halt', async () => { + expect((await rr('o', 'x', {includeGlob: ['nope.file'], halt: true})).exit).toBe(null); + }); +}); diff --git a/test/engine/halt.test.js b/test/engine/halt.test.js index 80d65d17..c3518130 100644 --- a/test/engine/halt.test.js +++ b/test/engine/halt.test.js @@ -33,14 +33,18 @@ describe('--halt', () => { }); test('stops with a number as exit code', async () => { - expect((await rr('o', 'x', {includeGlob: ['nope.file'], halt: true})).exit).toBe(1); + fs.writeFileSync('a.file', 'foo'); + fs.chmodSync('a.file', 0); + expect((await rr('o', 'x', {includeGlob: ['a.file'], halt: true})).exit).toBe(1); expect((await rr('(', 'x', {pipedData: 'foobar', halt: true})).exit).toBe(1); }); describe('combined', () => { test.each(['quiet', 'quietTotal'])('with --%s it still stops', async flag => { + fs.writeFileSync('a.file', 'foo'); + fs.chmodSync('a.file', 0); const result = await rr('o', 'x', { - includeGlob: ['nope.file'], + includeGlob: ['a.file'], halt: true, [flag]: true, }); @@ -62,10 +66,14 @@ describe('--halt', () => { expect(await rr('f', 'x', config)).toEqual({out: 'Foo xoo', err: '', exit: null}); }); - test('with --exclude-glob leaving no files', async () => { + test('with --exclude-glob leaving no files it does not stop, as that is a warning', async () => { fs.writeFileSync('my.file', 'foobar'); const config = {includeGlob: ['my.file'], excludeGlob: ['*.file'], halt: true}; - expect((await rr('o', 'x', config)).exit).not.toBe(null); + expect(await rr('o', 'x', config)).toEqual({ + out: '', + err: ' 🟡 0 files found\n\n', + exit: null, + }); expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar'); }); }); diff --git a/test/engine/quiet-total.test.js b/test/engine/quiet-total.test.js index 2edf9f14..80c5cf56 100644 --- a/test/engine/quiet-total.test.js +++ b/test/engine/quiet-total.test.js @@ -54,8 +54,10 @@ describe('--quiet-total', () => { }); test('with --halt it stops in silence', async () => { + fs.writeFileSync('a.file', 'foo'); + fs.chmodSync('a.file', 0); const result = await rr('o', 'x', { - includeGlob: ['nope.file'], + includeGlob: ['a.file'], quietTotal: true, halt: true, }); diff --git a/test/engine/quiet.test.js b/test/engine/quiet.test.js index becfb8ea..d0ffdd88 100644 --- a/test/engine/quiet.test.js +++ b/test/engine/quiet.test.js @@ -25,9 +25,9 @@ describe('--quiet', () => { expect((await rr('(', 'x', {pipedData: 'foobar', quiet: true})).err).toContain( 'Invalid regular expression' ); - expect((await rr('o', 'x', {includeGlob: ['nope.file'], quiet: true})).err).toContain( - '0 files found' - ); + fs.writeFileSync('a.file', 'foo'); + fs.chmodSync('a.file', 0); + expect((await rr('o', 'x', {includeGlob: ['a.file'], quiet: true})).err).toContain('EACCES'); }); describe('combined', () => { diff --git a/test/engine/replacement-pipe.test.js b/test/engine/replacement-pipe.test.js index 0aa21aac..ca544000 100644 --- a/test/engine/replacement-pipe.test.js +++ b/test/engine/replacement-pipe.test.js @@ -64,7 +64,7 @@ describe('--replacement-pipe', () => { test('does not treat the piped data as content to search in', async () => { const result = await rr('o', '_', {replacementPipe: true, pipedData: 'foo'}); - expect(result).toEqual({out: '', err: '0 files found\n', exit: 1}); + expect(result).toEqual({out: '', err: ' 🟡 0 files found\n\n', exit: null}); }); describe('combined', () => { From f96fc000ac8c2221b88b5e73d1bb52285f39d75e Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 14:42:40 +1100 Subject: [PATCH 05/27] Write the files in the test itself in the last tests with a helper --- test/engine/exclude-glob.test.js | 90 ++++++++++++--------- test/engine/exclude-re.test.js | 98 ++++++++++++----------- test/engine/fix/683.test.js | 73 ++++++++++++----- test/engine/fix/699.test.js | 121 ++++++++++++++++++----------- test/engine/halt.test.js | 13 ++-- test/engine/replacement-js.test.js | 117 +++++++++++++++++----------- 6 files changed, 313 insertions(+), 199 deletions(-) diff --git a/test/engine/exclude-glob.test.js b/test/engine/exclude-glob.test.js index 19181d00..1b3cd0f2 100644 --- a/test/engine/exclude-glob.test.js +++ b/test/engine/exclude-glob.test.js @@ -2,49 +2,61 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; import {rr} from '../helper.js'; -// Make `names` as files containing "foo" and return the ones changed by replacing "foo" -const changed = async (names, config) => { - names.forEach(name => { - fs.mkdirSync(name.replace(/[^/]*$/, '') || '.', {recursive: true}); - fs.writeFileSync(name, 'foo'); - }); - await rr('foo', 'x', config); - return names.filter(name => 'x' === fs.readFileSync(name, 'utf8')); -}; - describe('--exclude-glob', () => { test('leaves out the files found by the glob', async () => { const config = {includeGlob: ['*.file'], excludeGlob: ['y*']}; - expect(await changed(['my.file', 'your.file'], config)).toEqual(['my.file']); + fs.writeFileSync('my.file', 'foo'); + fs.writeFileSync('your.file', 'foo'); + expect((await rr('foo', 'x', config)).out).toBe('my.file\n'); }); test('can be the name of a file', async () => { const config = {includeGlob: ['*.file'], excludeGlob: ['your.file']}; - expect(await changed(['my.file', 'your.file'], config)).toEqual(['my.file']); + fs.writeFileSync('my.file', 'foo'); + fs.writeFileSync('your.file', 'foo'); + expect((await rr('foo', 'x', config)).out).toBe('my.file\n'); }); test('can be given several times', async () => { - const names = ['a.file', 'b.file', 'c.file']; - expect(await changed(names, {includeGlob: ['*.file'], excludeGlob: ['a.*', 'b.*']})).toEqual([ - 'c.file', - ]); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + fs.writeFileSync('c.file', 'foo'); + expect((await rr('foo', 'x', {includeGlob: ['*.file'], excludeGlob: ['a.*', 'b.*']})).out).toBe( + 'c.file\n' + ); }); test('understands braces and **', async () => { - const names = ['a.file', 'b.file', 'c.file', 'sub/a.file', 'sub/deep/d.file']; - expect(await changed(names, {includeGlob: ['**/*.file'], excludeGlob: ['{a,b}.file']})).toEqual( - ['c.file', 'sub/a.file', 'sub/deep/d.file'] + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + fs.writeFileSync('c.file', 'foo'); + fs.mkdirSync('sub', {recursive: true}); + fs.writeFileSync('sub/a.file', 'foo'); + fs.mkdirSync('sub/deep', {recursive: true}); + fs.writeFileSync('sub/deep/d.file', 'foo'); + expect( + (await rr('foo', 'x', {includeGlob: ['**/*.file'], excludeGlob: ['{a,b}.file']})).out + ).toBe('c.file\nsub/a.file\nsub/deep/d.file\n'); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + fs.writeFileSync('c.file', 'foo'); + fs.mkdirSync('sub', {recursive: true}); + fs.writeFileSync('sub/a.file', 'foo'); + fs.mkdirSync('sub/deep', {recursive: true}); + fs.writeFileSync('sub/deep/d.file', 'foo'); + expect((await rr('foo', 'x', {includeGlob: ['**/*.file'], excludeGlob: ['sub/**']})).out).toBe( + 'a.file\nb.file\nc.file\n' ); - expect(await changed(names, {includeGlob: ['**/*.file'], excludeGlob: ['sub/**']})).toEqual([ - 'a.file', - 'b.file', - 'c.file', - ]); - expect(await changed(names, {includeGlob: ['**/*.file'], excludeGlob: ['**/a.file']})).toEqual([ - 'b.file', - 'c.file', - 'sub/deep/d.file', - ]); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + fs.writeFileSync('c.file', 'foo'); + fs.mkdirSync('sub', {recursive: true}); + fs.writeFileSync('sub/a.file', 'foo'); + fs.mkdirSync('sub/deep', {recursive: true}); + fs.writeFileSync('sub/deep/d.file', 'foo'); + expect( + (await rr('foo', 'x', {includeGlob: ['**/*.file'], excludeGlob: ['**/a.file']})).out + ).toBe('b.file\nc.file\nsub/deep/d.file\n'); }); test('reports when all files are left out', async () => { @@ -56,20 +68,25 @@ describe('--exclude-glob', () => { test('makes no difference if it finds nothing', async () => { const config = {includeGlob: ['*.file'], excludeGlob: ['nope*', 'sub/**']}; - expect(await changed(['my.file'], config)).toEqual(['my.file']); + fs.writeFileSync('my.file', 'foo'); + expect((await rr('foo', 'x', config)).out).toBe('my.file\n'); }); describe('combined', () => { test('with --exclude-re both are used', async () => { - const names = ['a.file', 'b.file', 'c.file']; const config = {includeGlob: ['*.file'], excludeGlob: ['a.*'], excludeRe: ['^b']}; - expect(await changed(names, config)).toEqual(['c.file']); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + fs.writeFileSync('c.file', 'foo'); + expect((await rr('foo', 'x', config)).out).toBe('c.file\n'); }); test('with a negated glob', async () => { - const names = ['a.file', 'b.file', 'c.file']; const config = {includeGlob: ['*.file', '!a.file'], excludeGlob: ['b.*']}; - expect(await changed(names, config)).toEqual(['c.file']); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + fs.writeFileSync('c.file', 'foo'); + expect((await rr('foo', 'x', config)).out).toBe('c.file\n'); }); test.each(['output', 'outputMatch'])('with --%s only the files left are sent', async flag => { @@ -86,9 +103,10 @@ describe('--exclude-glob', () => { }); test('with --literal the glob is still a glob', async () => { - const names = ['a.file', 'b.file']; const config = {includeGlob: ['*.file'], excludeGlob: ['a.*'], literal: true}; - expect(await changed(names, config)).toEqual(['b.file']); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + expect((await rr('foo', 'x', config)).out).toBe('b.file\n'); }); test('with piped data it has nothing to do', async () => { diff --git a/test/engine/exclude-re.test.js b/test/engine/exclude-re.test.js index 6e072c36..bbc436b2 100644 --- a/test/engine/exclude-re.test.js +++ b/test/engine/exclude-re.test.js @@ -2,47 +2,47 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; import {rr} from '../helper.js'; -// Make `names` as files containing "foo" and return the ones changed by replacing "foo" -const changed = async (names, config) => { - names.forEach(name => { - fs.mkdirSync(name.replace(/[^/]*$/, '') || '.', {recursive: true}); - fs.writeFileSync(name, 'foo'); - }); - await rr('foo', 'x', config); - return names.filter(name => 'x' === fs.readFileSync(name, 'utf8')); -}; - describe('--exclude-re', () => { test('leaves out files with a path matching the regex', async () => { const config = {includeGlob: ['*.file'], excludeRe: ['^y']}; - expect(await changed(['my.file', 'your.file'], config)).toEqual(['my.file']); + fs.writeFileSync('my.file', 'foo'); + fs.writeFileSync('your.file', 'foo'); + expect((await rr('foo', 'x', config)).out).toBe('my.file\n'); }); test('matches anywhere in the path unless anchored', async () => { - const names = ['my.file', 'your.file']; - expect(await changed(names, {includeGlob: ['*.file'], excludeRe: ['y']})).toEqual([]); + fs.writeFileSync('my.file', 'foo'); + fs.writeFileSync('your.file', 'foo'); + expect((await rr('foo', 'x', {includeGlob: ['*.file'], excludeRe: ['y']})).out).toBe(''); }); test('is a regex', async () => { - const names = ['a1.file', 'a2.file', 'ab.file']; - expect(await changed(names, {includeGlob: ['*.file'], excludeRe: ['a\\d\\.file$']})).toEqual([ - 'ab.file', - ]); + fs.writeFileSync('a1.file', 'foo'); + fs.writeFileSync('a2.file', 'foo'); + fs.writeFileSync('ab.file', 'foo'); + expect((await rr('foo', 'x', {includeGlob: ['*.file'], excludeRe: ['a\\d\\.file$']})).out).toBe( + 'ab.file\n' + ); }); test('can be given several times', async () => { - const names = ['a.file', 'b.file', 'c.file']; - expect(await changed(names, {includeGlob: ['*.file'], excludeRe: ['^a', '^b']})).toEqual([ - 'c.file', - ]); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + fs.writeFileSync('c.file', 'foo'); + expect((await rr('foo', 'x', {includeGlob: ['*.file'], excludeRe: ['^a', '^b']})).out).toBe( + 'c.file\n' + ); }); test('sees the path of files in sub directories', async () => { - const names = ['a.file', 'sub/a.file', 'sub/deep/a.file']; - expect(await changed(names, {includeGlob: ['**/*.file'], excludeRe: ['^sub/[^/]+$']})).toEqual([ - 'a.file', - 'sub/deep/a.file', - ]); + fs.writeFileSync('a.file', 'foo'); + fs.mkdirSync('sub', {recursive: true}); + fs.writeFileSync('sub/a.file', 'foo'); + fs.mkdirSync('sub/deep', {recursive: true}); + fs.writeFileSync('sub/deep/a.file', 'foo'); + expect( + (await rr('foo', 'x', {includeGlob: ['**/*.file'], excludeRe: ['^sub/[^/]+$']})).out + ).toBe('a.file\nsub/deep/a.file\n'); }); test('reports when all files are left out', async () => { @@ -53,49 +53,59 @@ describe('--exclude-re', () => { }); test('makes no difference if it matches nothing', async () => { - expect(await changed(['my.file'], {includeGlob: ['*.file'], excludeRe: ['nope']})).toEqual([ - 'my.file', - ]); + fs.writeFileSync('my.file', 'foo'); + expect((await rr('foo', 'x', {includeGlob: ['*.file'], excludeRe: ['nope']})).out).toBe( + 'my.file\n' + ); }); test('follows the regex flags of the main search', async () => { - const names = ['MY.file', 'your.file']; - expect(await changed(names, {includeGlob: ['*.file'], excludeRe: ['^my']})).toEqual([ - 'your.file', - ]); + fs.writeFileSync('MY.file', 'foo'); + fs.writeFileSync('your.file', 'foo'); + expect((await rr('foo', 'x', {includeGlob: ['*.file'], excludeRe: ['^my']})).out).toBe( + 'your.file\n' + ); }); describe('combined', () => { test('with --exclude-glob both are used', async () => { - const names = ['a.file', 'b.file', 'c.file']; const config = {includeGlob: ['*.file'], excludeRe: ['^a'], excludeGlob: ['b.*']}; - expect(await changed(names, config)).toEqual(['c.file']); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + fs.writeFileSync('c.file', 'foo'); + expect((await rr('foo', 'x', config)).out).toBe('c.file\n'); }); test('with a negated glob', async () => { - const names = ['a.file', 'b.file', 'c.file']; const config = {includeGlob: ['*.file', '!b.file'], excludeRe: ['^a']}; - expect(await changed(names, config)).toEqual(['c.file']); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + fs.writeFileSync('c.file', 'foo'); + expect((await rr('foo', 'x', config)).out).toBe('c.file\n'); }); test('with --literal the exclusion is plain text', async () => { - const names = ['a.file', 'axfile']; const config = {includeGlob: ['a*'], excludeRe: ['a.file'], literal: true}; - expect(await changed(names, config)).toEqual(['axfile']); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('axfile', 'foo'); + expect((await rr('foo', 'x', config)).out).toBe('axfile\n'); }); test('with € and § as aliases unless voided', async () => { - const names = ['a1.file', 'ab.files']; - expect(await changed(names, {includeGlob: ['a*'], excludeRe: ['§d.file€']})).toEqual([ - 'ab.files', - ]); + fs.writeFileSync('a1.file', 'foo'); + fs.writeFileSync('ab.files', 'foo'); + expect((await rr('foo', 'x', {includeGlob: ['a*'], excludeRe: ['§d.file€']})).out).toBe( + 'ab.files\n' + ); const voided = { includeGlob: ['a*'], excludeRe: ['§d.file€'], voidEuro: true, voidSection: true, }; - expect(await changed(names, voided)).toEqual(names); + fs.writeFileSync('a1.file', 'foo'); + fs.writeFileSync('ab.files', 'foo'); + expect((await rr('foo', 'x', voided)).out).toBe('a1.file\nab.files\n'); }); test('with --output only the files left are sent', async () => { diff --git a/test/engine/fix/683.test.js b/test/engine/fix/683.test.js index 2840a13c..343c0f88 100644 --- a/test/engine/fix/683.test.js +++ b/test/engine/fix/683.test.js @@ -4,13 +4,6 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; import {rr} from '../../helper.js'; -// Make `names` as files containing "foo" and return the ones changed by replacing "foo" -const changed = async (names, config) => { - names.forEach(name => fs.writeFileSync(name, 'foo')); - await rr('foo', 'x', {includeGlob: ['*.file'], ...config}); - return names.filter(name => 'x' === fs.readFileSync(name, 'utf8')); -}; - describe('#683 --exclude-re follows the regex flags of the main search', () => { test('the example from the issue', async () => { fs.writeFileSync('a.file', 'x\n'); @@ -20,32 +13,70 @@ describe('#683 --exclude-re follows the regex flags of the main search', () => { }); test('ignores case as default', async () => { - expect(await changed(['MY.file', 'your.file'], {excludeRe: ['^my']})).toEqual(['your.file']); - expect(await changed(['my.file', 'your.file'], {excludeRe: ['^MY']})).toEqual(['your.file']); + fs.writeFileSync('MY.file', 'foo'); + fs.writeFileSync('your.file', 'foo'); + expect((await rr('foo', 'x', {includeGlob: ['*.file'], excludeRe: ['^my']})).out).toBe( + 'your.file\n' + ); + fs.writeFileSync('my.file', 'foo'); + fs.writeFileSync('your.file', 'foo'); + expect((await rr('foo', 'x', {includeGlob: ['*.file'], excludeRe: ['^MY']})).out).toBe( + 'your.file\n' + ); }); test('--void-ignore-case makes the case matter', async () => { - const names = ['MY.file', 'your.file']; - expect(await changed(names, {excludeRe: ['^my'], voidIgnoreCase: true})).toEqual(names); - expect(await changed(names, {excludeRe: ['^MY'], voidIgnoreCase: true})).toEqual(['your.file']); + fs.writeFileSync('MY.file', 'foo'); + fs.writeFileSync('your.file', 'foo'); + expect( + (await rr('foo', 'x', {includeGlob: ['*.file'], excludeRe: ['^my'], voidIgnoreCase: true})) + .out + ).toBe('MY.file\nyour.file\n'); + fs.writeFileSync('MY.file', 'foo'); + fs.writeFileSync('your.file', 'foo'); + expect( + (await rr('foo', 'x', {includeGlob: ['*.file'], excludeRe: ['^MY'], voidIgnoreCase: true})) + .out + ).toBe('your.file\n'); }); test('--dot-all lets a dot match a line break in the path', async () => { - const names = ['a\nb.file', 'ab.file']; - expect(await changed(names, {excludeRe: ['a.b']})).toEqual(names); - expect(await changed(names, {excludeRe: ['a.b'], dotAll: true})).toEqual(['ab.file']); + fs.writeFileSync('a\nb.file', 'foo'); + fs.writeFileSync('ab.file', 'foo'); + expect((await rr('foo', 'x', {includeGlob: ['*.file'], excludeRe: ['a.b']})).out).toBe( + 'a\nb.file\nab.file\n' + ); + fs.writeFileSync('a\nb.file', 'foo'); + fs.writeFileSync('ab.file', 'foo'); + expect( + (await rr('foo', 'x', {includeGlob: ['*.file'], excludeRe: ['a.b'], dotAll: true})).out + ).toBe('ab.file\n'); }); test('--void-multiline makes ^ match only at the start of the path', async () => { - const names = ['a\nb.file', 'c.file']; - expect(await changed(names, {excludeRe: ['^b']})).toEqual(['c.file']); - expect(await changed(names, {excludeRe: ['^b'], voidMultiline: true})).toEqual(names); + fs.writeFileSync('a\nb.file', 'foo'); + fs.writeFileSync('c.file', 'foo'); + expect((await rr('foo', 'x', {includeGlob: ['*.file'], excludeRe: ['^b']})).out).toBe( + 'c.file\n' + ); + fs.writeFileSync('a\nb.file', 'foo'); + fs.writeFileSync('c.file', 'foo'); + expect( + (await rr('foo', 'x', {includeGlob: ['*.file'], excludeRe: ['^b'], voidMultiline: true})).out + ).toBe('a\nb.file\nc.file\n'); }); test('--unicode lets \\u{...} match in the path', async () => { - const names = ['a.file', 'b.file']; - expect(await changed(names, {excludeRe: ['\\u{61}']})).toEqual(names); - expect(await changed(names, {excludeRe: ['\\u{61}'], unicode: true})).toEqual(['b.file']); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + expect((await rr('foo', 'x', {includeGlob: ['*.file'], excludeRe: ['\\u{61}']})).out).toBe( + 'a.file\nb.file\n' + ); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + expect( + (await rr('foo', 'x', {includeGlob: ['*.file'], excludeRe: ['\\u{61}'], unicode: true})).out + ).toBe('b.file\n'); }); test('an invalid regex stops with an error (used to throw a SyntaxError)', async () => { diff --git a/test/engine/fix/699.test.js b/test/engine/fix/699.test.js index 0295034d..0446bb9b 100644 --- a/test/engine/fix/699.test.js +++ b/test/engine/fix/699.test.js @@ -5,73 +5,97 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; import {rr} from '../../helper.js'; -// Make `names` as files containing "foo" and return the ones changed by replacing "foo" -const changed = async (names, config) => { - names.forEach(name => fs.writeFileSync(name, 'foo')); - await rr('foo', 'x', config); - return names.filter(name => 'x' === fs.readFileSync(name, 'utf8')); -}; - const voided = {voidEuro: true, voidSection: true}; describe('#699 € and § are only aliases in the pattern, the replacement and --exclude-re', () => { describe('file globs', () => { test('€ is a plain char', async () => { - const names = ['cost€.file', 'cost$.file']; - expect(await changed(names, {includeGlob: ['cost€.file']})).toEqual(['cost€.file']); - expect(await changed(names, {includeGlob: ['cost$.file']})).toEqual(['cost$.file']); - expect(await changed(names, {includeGlob: ['*€.file']})).toEqual(['cost€.file']); + fs.writeFileSync('cost€.file', 'foo'); + fs.writeFileSync('cost$.file', 'foo'); + expect((await rr('foo', 'x', {includeGlob: ['cost€.file']})).out).toBe('cost€.file\n'); + fs.writeFileSync('cost€.file', 'foo'); + fs.writeFileSync('cost$.file', 'foo'); + expect((await rr('foo', 'x', {includeGlob: ['cost$.file']})).out).toBe('cost$.file\n'); + fs.writeFileSync('cost€.file', 'foo'); + fs.writeFileSync('cost$.file', 'foo'); + expect((await rr('foo', 'x', {includeGlob: ['*€.file']})).out).toBe('cost€.file\n'); }); test('§ is a plain char', async () => { - const names = ['par§.file', 'par.file']; - expect(await changed(names, {includeGlob: ['par§.file']})).toEqual(['par§.file']); - expect(await changed(names, {includeGlob: ['*§.file']})).toEqual(['par§.file']); + fs.writeFileSync('par§.file', 'foo'); + fs.writeFileSync('par.file', 'foo'); + expect((await rr('foo', 'x', {includeGlob: ['par§.file']})).out).toBe('par§.file\n'); + fs.writeFileSync('par§.file', 'foo'); + fs.writeFileSync('par.file', 'foo'); + expect((await rr('foo', 'x', {includeGlob: ['*§.file']})).out).toBe('par§.file\n'); }); test('§ does not escape like a backslash does', async () => { - const names = ['a[1].file', 'a1.file']; - expect(await changed(names, {includeGlob: ['a\\[1\\].file']})).toEqual(['a[1].file']); - expect(await changed(names, {includeGlob: ['a§[1§].file']})).toEqual([]); + fs.writeFileSync('a[1].file', 'foo'); + fs.writeFileSync('a1.file', 'foo'); + expect((await rr('foo', 'x', {includeGlob: ['a\\[1\\].file']})).out).toBe('a[1].file\n'); + fs.writeFileSync('a[1].file', 'foo'); + fs.writeFileSync('a1.file', 'foo'); + expect((await rr('foo', 'x', {includeGlob: ['a§[1§].file']})).out).toBe(''); }); test('--void-euro and --void-section make no difference', async () => { - const names = ['cost€.file', 'cost$.file', 'par§.file']; - expect(await changed(names, {includeGlob: ['cost€.file', 'par§.file'], ...voided})).toEqual([ - 'cost€.file', - 'par§.file', - ]); + fs.writeFileSync('cost€.file', 'foo'); + fs.writeFileSync('cost$.file', 'foo'); + fs.writeFileSync('par§.file', 'foo'); + expect( + (await rr('foo', 'x', {includeGlob: ['cost€.file', 'par§.file'], ...voided})).out + ).toBe('cost€.file\npar§.file\n'); }); }); describe('--exclude-glob', () => { - const names = ['a[1].file', 'cost$.file', 'cost€.file', 'par§.file']; - test('€ and § are plain chars', async () => { - expect(await changed(names, {includeGlob: ['*.file'], excludeGlob: ['cost€.file']})).toEqual([ - 'a[1].file', - 'cost$.file', - 'par§.file', - ]); - expect(await changed(names, {includeGlob: ['*.file'], excludeGlob: ['par§.file']})).toEqual([ - 'a[1].file', - 'cost$.file', - 'cost€.file', - ]); + fs.writeFileSync('a[1].file', 'foo'); + fs.writeFileSync('cost$.file', 'foo'); + fs.writeFileSync('cost€.file', 'foo'); + fs.writeFileSync('par§.file', 'foo'); + expect( + (await rr('foo', 'x', {includeGlob: ['*.file'], excludeGlob: ['cost€.file']})).out + ).toBe('a[1].file\ncost$.file\npar§.file\n'); + fs.writeFileSync('a[1].file', 'foo'); + fs.writeFileSync('cost$.file', 'foo'); + fs.writeFileSync('cost€.file', 'foo'); + fs.writeFileSync('par§.file', 'foo'); + expect( + (await rr('foo', 'x', {includeGlob: ['*.file'], excludeGlob: ['par§.file']})).out + ).toBe('a[1].file\ncost$.file\ncost€.file\n'); }); test('§ does not escape like a backslash does', async () => { + fs.writeFileSync('a[1].file', 'foo'); + fs.writeFileSync('cost$.file', 'foo'); + fs.writeFileSync('cost€.file', 'foo'); + fs.writeFileSync('par§.file', 'foo'); expect( - await changed(names, {includeGlob: ['*.file'], excludeGlob: ['a\\[1\\].file']}) - ).toEqual(['cost$.file', 'cost€.file', 'par§.file']); - expect(await changed(names, {includeGlob: ['*.file'], excludeGlob: ['a§[1§].file']})).toEqual( - names - ); + (await rr('foo', 'x', {includeGlob: ['*.file'], excludeGlob: ['a\\[1\\].file']})).out + ).toBe('cost$.file\ncost€.file\npar§.file\n'); + fs.writeFileSync('a[1].file', 'foo'); + fs.writeFileSync('cost$.file', 'foo'); + fs.writeFileSync('cost€.file', 'foo'); + fs.writeFileSync('par§.file', 'foo'); + expect( + (await rr('foo', 'x', {includeGlob: ['*.file'], excludeGlob: ['a§[1§].file']})).out + ).toBe('a[1].file\ncost$.file\ncost€.file\npar§.file\n'); }); test('--void-euro and --void-section make no difference', async () => { const config = {includeGlob: ['*.file'], excludeGlob: ['cost€.file', 'par§.file']}; - expect(await changed(names, {...config, ...voided})).toEqual(await changed(names, config)); + fs.writeFileSync('a[1].file', 'foo'); + fs.writeFileSync('cost$.file', 'foo'); + fs.writeFileSync('cost€.file', 'foo'); + fs.writeFileSync('par§.file', 'foo'); + expect((await rr('foo', 'x', config)).out).toBe('a[1].file\ncost$.file\n'); + fs.writeFileSync('a[1].file', 'foo'); + fs.writeFileSync('cost$.file', 'foo'); + fs.writeFileSync('cost€.file', 'foo'); + fs.writeFileSync('par§.file', 'foo'); + expect((await rr('foo', 'x', {...config, ...voided})).out).toBe('a[1].file\ncost$.file\n'); }); }); @@ -111,13 +135,18 @@ describe('#699 € and § are only aliases in the pattern, the replacement and - }); test('--exclude-re', async () => { - const names = ['a1.file', 'ab.file', 'ab.files']; - expect(await changed(names, {includeGlob: ['a*'], excludeRe: ['§d', 'file€']})).toEqual([ - 'ab.files', - ]); + fs.writeFileSync('a1.file', 'foo'); + fs.writeFileSync('ab.file', 'foo'); + fs.writeFileSync('ab.files', 'foo'); + expect((await rr('foo', 'x', {includeGlob: ['a*'], excludeRe: ['§d', 'file€']})).out).toBe( + 'ab.files\n' + ); + fs.writeFileSync('a1.file', 'foo'); + fs.writeFileSync('ab.file', 'foo'); + fs.writeFileSync('ab.files', 'foo'); expect( - await changed(names, {includeGlob: ['a*'], excludeRe: ['§d', 'file€'], ...voided}) - ).toEqual(names); + (await rr('foo', 'x', {includeGlob: ['a*'], excludeRe: ['§d', 'file€'], ...voided})).out + ).toBe('a1.file\nab.file\nab.files\n'); }); }); }); diff --git a/test/engine/halt.test.js b/test/engine/halt.test.js index c3518130..6b62e10a 100644 --- a/test/engine/halt.test.js +++ b/test/engine/halt.test.js @@ -4,22 +4,21 @@ import {rr} from '../helper.js'; describe('--halt', () => { // a.file can not be read, and the files are handled in the order of the list - function lockedFirst() { + test('stops the process on the first error', async () => { fs.writeFileSync('a.file', 'foo'); fs.writeFileSync('b.file', 'foo'); fs.chmodSync('a.file', 0); - return {includeGlob: ['a.file', 'b.file']}; - } - - test('stops the process on the first error', async () => { - const result = await rr('o', 'x', {...lockedFirst(), halt: true}); + const result = await rr('o', 'x', {includeGlob: ['a.file', 'b.file'], halt: true}); expect(result.err).toContain('EACCES'); expect(result.exit).toBe(1); expect(fs.readFileSync('b.file', 'utf8')).toBe('foo'); }); test('is not the default, so the other files are still handled', async () => { - const result = await rr('o', 'x', lockedFirst()); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + fs.chmodSync('a.file', 0); + const result = await rr('o', 'x', {includeGlob: ['a.file', 'b.file']}); expect(result.err).toContain('EACCES'); expect(result.exit).toBe(1); expect(fs.readFileSync('b.file', 'utf8')).toBe('fxx'); diff --git a/test/engine/replacement-js.test.js b/test/engine/replacement-js.test.js index 56d70a22..a068d180 100644 --- a/test/engine/replacement-js.test.js +++ b/test/engine/replacement-js.test.js @@ -6,20 +6,13 @@ import {rr} from '../helper.js'; const js = async (pattern, code, pipedData, config) => (await rr(pattern, code, {pipedData, replacementJs: true, ...config})).out; -// Run `code` against my.file holding `text` and return what it gives -const inFile = async (code, text = 'foobar', file = 'my.file') => { - fs.mkdirSync(path.dirname(file), {recursive: true}); - fs.writeFileSync(file, text); - return ( - await rr('^[^]*$', code, { - includeGlob: [file], - replacementJs: true, - output: true, - // Without the tail only the value of the code is given - contentDelimiterTail: false, - voidMultiline: true, - }) - ).out; +// The code runs once on the whole content of my.file. With --output and no tail only the value of the code is printed. +const wholeFile = { + includeGlob: ['my.file'], + replacementJs: true, + output: true, + contentDelimiterTail: false, + voidMultiline: true, }; const timestamp = /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d{3}$/; @@ -105,21 +98,31 @@ describe('--replacement-js', () => { describe('variables', () => { test('fs, path and globs are available', async () => { - expect(await inFile('typeof fs.readFileSync + typeof path.join + typeof globs.sync')).toBe( - 'functionfunctionfunction' - ); - expect(await inFile('fs.readFileSync(file, "utf8").toUpperCase()')).toBe('FOOBAR'); - expect(await inFile('globs.sync("*").join()')).toBe('my.file'); + fs.writeFileSync('my.file', 'foobar'); + expect( + ( + await rr( + '^[^]*$', + 'typeof fs.readFileSync + typeof path.join + typeof globs.sync', + wholeFile + ) + ).out + ).toBe('functionfunctionfunction'); + expect( + (await rr('^[^]*$', 'fs.readFileSync(file, "utf8").toUpperCase()', wholeFile)).out + ).toBe('FOOBAR'); + expect((await rr('^[^]*$', 'globs.sync("*").join()', wholeFile)).out).toBe('my.file'); }); test('require and r find files relative to the current dir', async () => { fs.writeFileSync('data.json', '{"version": "1.2.3"}'); - expect(await inFile('require("./data.json").version')).toBe('1.2.3'); - expect(await inFile('r("./data.json").version')).toBe('1.2.3'); - expect(await inFile('r("data.json").version')).toBe('1.2.3'); - expect(await inFile('r("data").version')).toBe('1.2.3'); - expect(await inFile('r(cwd + "/data.json").version')).toBe('1.2.3'); - expect(await inFile('require("path").sep')).toBe(path.sep); + fs.writeFileSync('my.file', 'foobar'); + expect((await rr('^[^]*$', 'require("./data.json").version', wholeFile)).out).toBe('1.2.3'); + expect((await rr('^[^]*$', 'r("./data.json").version', wholeFile)).out).toBe('1.2.3'); + expect((await rr('^[^]*$', 'r("data.json").version', wholeFile)).out).toBe('1.2.3'); + expect((await rr('^[^]*$', 'r("data").version', wholeFile)).out).toBe('1.2.3'); + expect((await rr('^[^]*$', 'r(cwd + "/data.json").version', wholeFile)).out).toBe('1.2.3'); + expect((await rr('^[^]*$', 'require("path").sep', wholeFile)).out).toBe(path.sep); }); test('text is the full content and find is the pattern', async () => { @@ -148,7 +151,8 @@ describe('--replacement-js', () => { '[file, file_rel, dirpath, dirpath_rel, dirname, filename, name, ext].join("\\n")'; const cwd = process.cwd(); - expect((await inFile(code)).split('\n')).toEqual([ + fs.writeFileSync('my.file', 'foobar'); + expect((await rr('^[^]*$', code, wholeFile)).out.split('\n')).toEqual([ path.join(cwd, 'my.file'), 'my.file', cwd, @@ -159,7 +163,13 @@ describe('--replacement-js', () => { '.file', ]); - expect((await inFile(code, 'foobar', 'sub/dir/my.file.txt')).split('\n')).toEqual([ + fs.mkdirSync('sub/dir', {recursive: true}); + fs.writeFileSync('sub/dir/my.file.txt', 'foobar'); + expect( + (await rr('^[^]*$', code, {...wholeFile, includeGlob: ['sub/dir/my.file.txt']})).out.split( + '\n' + ) + ).toEqual([ path.join(cwd, 'sub/dir/my.file.txt'), path.join('sub/dir/my.file.txt'), path.join(cwd, 'sub/dir'), @@ -178,31 +188,40 @@ describe('--replacement-js', () => { }); test('cwd is the current working dir', async () => { - expect(await inFile('cwd')).toBe(process.cwd()); + fs.writeFileSync('my.file', 'foobar'); + expect((await rr('^[^]*$', 'cwd', wholeFile)).out).toBe(process.cwd()); expect(await js('foo', 'cwd', 'foo')).toBe(process.cwd()); }); test('bytes and size tell the size of the content', async () => { - expect(await inFile('bytes + "|" + size')).toBe('6|6 Bytes'); - expect(await inFile('bytes + "|" + size', 'x')).toBe('1|1 Byte'); - expect(await inFile('bytes + "|" + size', 'æ🦖')).toBe('6|6 Bytes'); - expect(await inFile('bytes + "|" + size', 'x'.repeat(1024))).toBe('1024|1.0 KB'); - expect(await inFile('bytes + "|" + size', 'x'.repeat(1536))).toBe('1536|1.5 KB'); - expect(await inFile('bytes + "|" + size', 'x'.repeat(3 * 1024 * 1024))).toBe( - '3145728|3.0 MB' - ); + fs.writeFileSync('my.file', 'foobar'); + expect((await rr('^[^]*$', 'bytes + "|" + size', wholeFile)).out).toBe('6|6 Bytes'); + fs.writeFileSync('my.file', 'x'); + expect((await rr('^[^]*$', 'bytes + "|" + size', wholeFile)).out).toBe('1|1 Byte'); + fs.writeFileSync('my.file', 'æ🦖'); + expect((await rr('^[^]*$', 'bytes + "|" + size', wholeFile)).out).toBe('6|6 Bytes'); + fs.writeFileSync('my.file', 'x'.repeat(1024)); + expect((await rr('^[^]*$', 'bytes + "|" + size', wholeFile)).out).toBe('1024|1.0 KB'); + fs.writeFileSync('my.file', 'x'.repeat(1536)); + expect((await rr('^[^]*$', 'bytes + "|" + size', wholeFile)).out).toBe('1536|1.5 KB'); + fs.writeFileSync('my.file', 'x'.repeat(3 * 1024 * 1024)); + expect((await rr('^[^]*$', 'bytes + "|" + size', wholeFile)).out).toBe('3145728|3.0 MB'); expect(await js('foobar', 'bytes + "|" + size', 'foobar')).toBe('6|6 Bytes'); }); test('size of empty content is "0 Bytes" (#687)', async () => { - expect(await inFile('bytes + "|" + size', '')).toBe('0|0 Bytes'); + fs.writeFileSync('my.file', ''); + expect((await rr('^[^]*$', 'bytes + "|" + size', wholeFile)).out).toBe('0|0 Bytes'); }); test('time and now tell when rexreplace was started', async () => { - expect(await inFile('time')).toMatch(timestamp); - expect(await inFile('now === time')).toBe('true'); - expect(await inFile('time_obj instanceof Date')).toBe('true'); - expect(Math.abs(Date.now() - Number(await inFile('time_obj.getTime()')))).toBeLessThan(60000); + fs.writeFileSync('my.file', 'foobar'); + expect((await rr('^[^]*$', 'time', wholeFile)).out).toMatch(timestamp); + expect((await rr('^[^]*$', 'now === time', wholeFile)).out).toBe('true'); + expect((await rr('^[^]*$', 'time_obj instanceof Date', wholeFile)).out).toBe('true'); + expect( + Math.abs(Date.now() - Number((await rr('^[^]*$', 'time_obj.getTime()', wholeFile)).out)) + ).toBeLessThan(60000); }); test('mtime and ctime tell about the last change of the file', async () => { @@ -249,14 +268,22 @@ describe('--replacement-js', () => { 'size', ]; const code = `[${names.map(name => `${name}_ === ${name} + " "`).join()}].join()`; - expect(await inFile(code)).toBe(names.map(() => 'true').join()); + fs.writeFileSync('my.file', 'foobar'); + expect((await rr('^[^]*$', code, wholeFile)).out).toBe(names.map(() => 'true').join()); expect(await js('foobar', code, 'foobar')).toBe(names.map(() => 'true').join()); }); test('are the same when the code runs once per match', async () => { - expect(await inFile('$0; [filename, name, ext, bytes, text, find, _, nl].join("|")')).toBe( - 'my.file|my|.file|6|foobar|^[^]*$| |\n' - ); + fs.writeFileSync('my.file', 'foobar'); + expect( + ( + await rr( + '^[^]*$', + '$0; [filename, name, ext, bytes, text, find, _, nl].join("|")', + wholeFile + ) + ).out + ).toBe('my.file|my|.file|6|foobar|^[^]*$| |\n'); }); test('follow the file when several files are handled', async () => { From 71c2744ccad1d5deaa63151cf60754156c03a253 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 14:59:01 +1100 Subject: [PATCH 06/27] Rename die() to panic(), and end a panic with No further processing --- src/cli.ts | 8 ++++---- src/engine.ts | 20 ++++++++++---------- src/output.ts | 25 ++++++++++++++++--------- test/cli/fix/691.test.js | 4 +++- test/cli/fix/700.test.js | 2 +- test/engine/encoding.test.js | 6 +++++- test/engine/fix/720.test.js | 19 +++++++++++++++++++ 7 files changed, 58 insertions(+), 26 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 0238f445..0287c387 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, die} from './output.ts'; +import {outputConfig, panic} 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. @@ -541,9 +541,9 @@ const unescape = (key, name) => { } catch (e) { // The engine is not running yet, so the panic is caught here try { - die('Invalid escape sequence in ' + name + ':', typed[key]); - } catch (panic) { - exitOnPanic(config, panic); + panic('Invalid escape sequence in ' + name + ':', typed[key]); + } catch (e) { + exitOnPanic(config, e); } } }; diff --git a/src/engine.ts b/src/engine.ts index 37f7c830..a41b7263 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -8,7 +8,7 @@ const globs = require('fast-glob'); const now = new Date(); -import {outputConfig, chat, info, print, printDone, warn, error, die} from './output.ts'; +import {outputConfig, chat, info, print, printDone, warn, error, panic} from './output.ts'; const re = { mctime: /[mc]time/, @@ -30,7 +30,7 @@ const runtimeEngine = process.versions.bun ? 'JSC' : 'V8'; function validateEncoding(encoding = 'utf8') { if (!Buffer.isEncoding(encoding)) { - die('Unknown encoding:', encoding); + panic('Unknown encoding:', encoding); } } @@ -80,7 +80,7 @@ async function run(config) { // With --glob-pipe each line of the piped data is a path or a glob, used together with the ones given as arguments if (config.globPipe) { if (config.replacementPipe || config.pipe) { - die( + panic( '--glob-pipe can not be used with --replacement-pipe or --pipe, as the piped data can only be one thing' ); } @@ -337,14 +337,14 @@ function getFinalReplacement(replacement, conf: any) { if (conf.replacementPipe) { conf.pipedDataUsed = true; if (null === conf.pipedData) { - return die('No data piped into replacement'); + return panic('No data piped into replacement'); } replacement = conf.pipedData; } if (conf.outputMatch) { if (parseInt(process.versions.node) < 6) { - return die('outputMatch is only supported in node 6+'); + return panic('outputMatch is only supported in node 6+'); } return function (match, ...rest) { @@ -367,7 +367,7 @@ function getFinalReplacement(replacement, conf: any) { re.capturedGroupRef.test(conf.replacement) && parseInt(process.versions.node) < 6 ) { - return die('Captured groups for javascript replacement is only supported in node 6+'); + return panic('Captured groups for javascript replacement is only supported in node 6+'); } return replacement; @@ -395,12 +395,12 @@ function getFinalRegex(pattern, config) { try { regex = new RegExp(pattern, flags); } catch (e) { - die(e.message); + panic(e.message); } break; case 'V8': case 'JSC': - die( + panic( `When running via the ${runtime} runtime you can only use default or ${runtimeEngine} as engine` ); break; @@ -410,11 +410,11 @@ function getFinalRegex(pattern, config) { //regex = new RE2(pattern, flags); regex = new RegExp(pattern, flags); } catch (e) { - die(e.message); + panic(e.message); } break; default: - die(`Engine ${config.engine} not supported`); + panic(`Engine ${config.engine} not supported`); } return regex; diff --git a/src/output.ts b/src/output.ts index ed746629..7f50bdaf 100644 --- a/src/output.ts +++ b/src/output.ts @@ -93,26 +93,33 @@ export const chat = function (msg) { } }; -export const die = function (msg = '', data = '', displayHelp = false) { +// An error is told about, and goes into `errors` in the status report. A panic also says the run stops here. +const printError = function (text, stop) { + if (!config.quietTotal) { + // The line break gives an empty line under the error + write( + font.red(' ❌ ' + text + (stop ? '\n No further processing.' : '')) + '\n\n', + process.stderr + ); + } +}; + +// 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 : ''); config.statusInfo?.errors.push(text); - if (!config.quietTotal) { - // The line break gives an empty line under the error - write(font.red(' ❌ ' + text) + '\n\n', process.stderr); - } + printError(text, true); throw new Panic(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 : ''); config.statusInfo?.errors.push(text); - if (!config.quietTotal) { - // The line break gives an empty line under the error - write(font.red(' ❌ ' + text) + '\n\n', process.stderr); - } + printError(text, config.halt); if (config.halt) { throw new Panic(text); } diff --git a/test/cli/fix/691.test.js b/test/cli/fix/691.test.js index 93db4cce..ebf07034 100644 --- a/test/cli/fix/691.test.js +++ b/test/cli/fix/691.test.js @@ -76,7 +76,9 @@ describe('#691 replacement that used to crash the CLI', () => { fs.writeFileSync('my.file', 'foobar'); 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\n'); + expect(result.err).toBe( + ' ❌ Invalid escape sequence in replacement: \\x4\n No further processing.\n\n' + ); expect(result.exit).toBe(1); expect( await sh($`bun ${cli} o ${'\\x4'} my.file --output --no-content-delimiter-tail --quiet-total`) diff --git a/test/cli/fix/700.test.js b/test/cli/fix/700.test.js index ac06c152..2bb3fd3d 100644 --- a/test/cli/fix/700.test.js +++ b/test/cli/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\n', + err: ' ❌ Invalid escape sequence in replacement: §x4\n No further processing.\n\n', exit: 1, }); }); diff --git a/test/engine/encoding.test.js b/test/engine/encoding.test.js index 9ef47893..69ec6b00 100644 --- a/test/engine/encoding.test.js +++ b/test/engine/encoding.test.js @@ -48,7 +48,11 @@ 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\n', exit: 1}; + const unknownEncoding = { + out: '', + err: ' ❌ Unknown encoding: nope\n No further processing.\n\n', + exit: 1, + }; test('stops the run with an error if it is unknown (#690)', async () => { fs.writeFileSync('my.file', 'foobar'); diff --git a/test/engine/fix/720.test.js b/test/engine/fix/720.test.js index e7095db5..a2513fe4 100644 --- a/test/engine/fix/720.test.js +++ b/test/engine/fix/720.test.js @@ -25,6 +25,25 @@ describe('#720 every error starts with ❌', () => { }); }); +describe('#720 an error that stops the run says so', () => { + test('a panic', async () => { + expect((await rr('(', 'x', {pipedData: 'foo'})).err).toEndWith( + '\n No further processing.\n\n' + ); + }); + + test('an error from a file with --halt, but not without', async () => { + fs.writeFileSync('a.file', 'foo'); + fs.chmodSync('a.file', 0); + expect((await rr('o', 'x', {includeGlob: ['a.file'], halt: true})).err).toContain( + 'No further processing.' + ); + expect((await rr('o', 'x', {includeGlob: ['a.file']})).err).not.toContain( + 'No further processing.' + ); + }); +}); + describe('#720 finding no files is a warning', () => { test('with the exit code 0', async () => { expect(await rr('o', 'x', {includeGlob: ['nope.file']})).toEqual({ From 5bb35fc1cae8ee3c521e633e1a68ba6238b0b09e Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 15:10:50 +1100 Subject: [PATCH 07/27] Give an error in a file exit code 2 and a panic exit code 3 --- README.md | 12 +++++++- src/cli.ts | 16 ++++++++--- test/cli/debug.test.js | 6 ++-- test/cli/delimiter.test.js | 2 +- test/cli/encoding.test.js | 4 +-- test/cli/fix/691.test.js | 4 +-- test/cli/fix/700.test.js | 2 +- test/cli/fix/742.test.js | 6 ++-- test/cli/fix/783.test.js | 42 ++++++++++++++++++++++++++++ test/cli/glob-pipe.test.js | 4 +-- test/cli/run.sh | 7 +++-- test/engine/basics.test.js | 2 +- test/engine/encoding.test.js | 2 +- test/engine/engine.test.js | 8 +++--- test/engine/fix/681.test.js | 6 ++-- test/engine/fix/682.test.js | 4 +-- test/engine/fix/683.test.js | 2 +- test/engine/fix/703.test.js | 4 +-- test/engine/fix/705.test.js | 4 +-- test/engine/fix/708.test.js | 2 +- test/engine/fix/711.test.js | 2 +- test/engine/fix/720.test.js | 2 +- test/engine/fix/728.test.js | 8 +++--- test/engine/fix/742.test.js | 4 +-- test/engine/halt.test.js | 10 +++---- test/engine/quiet-total.test.js | 4 +-- test/engine/quiet.test.js | 2 +- test/engine/replacement-pipe.test.js | 2 +- test/engine/unicode.test.js | 2 +- test/engine/void-section.test.js | 2 +- test/helper.js | 8 +++--- 31 files changed, 124 insertions(+), 61 deletions(-) create mode 100644 test/cli/fix/783.test.js diff --git a/README.md b/README.md index b86ef4e4..cd9928dd 100644 --- a/README.md +++ b/README.md @@ -311,10 +311,20 @@ Files identified for potential processing are identified by: - Piped data that is printed is left as it came in, so no delimiter is added after the end of it. Matches from `--output-match` are different: they are new output, so they get the delimiter whether they come from a file or from piped data. -- Finding no files is a warning (🟡) and not an error, so the exit code is 0. Nothing went wrong, there was just nothing to do. Errors start with ❌ and give the exit code 1. +- Finding no files is a warning (🟡) and not an error, so the exit code is 0. Nothing went wrong, there was just nothing to do. +### Exit codes + +| Code | Meaning | +| --- | --- | +| 0 | Done. Warnings (🟡) are ok. | +| 2 | One or more files gave an error (❌), and the run went on. | +| 3 | The run stopped (❌ … `No further processing.`): a bad pattern, an unknown encoding, wrong flags, or any error with `--halt`. | + +Code 1 is kept for a check that says no, like `grep` does when nothing is found. + ### Limitations - RexReplace reads each file fully into memory, so working on your 8Gb log files will probably not be ideal. diff --git a/src/cli.ts b/src/cli.ts index 0287c387..adc11505 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -29,6 +29,12 @@ const yargs = require('yargs') // A file can be named with only digits, like `2024`, so each glob is kept as the string that was given .parserConfiguration({'parse-positional-numbers': false}) .strict() + // Wrong flags are a panic, so the exit code is 3. The help and the message go to stderr, as yargs does it. + .fail((msg, err, yargs) => { + yargs.showHelp(); + console.error(msg || err.message); + process.exit(3); + }) .usage( 'RexReplace ' + @@ -466,7 +472,7 @@ function exitOnPanic(config, e) { if (e.status) { printStatus(config, e.status); } - process.exit(1); + process.exit(3); } function backOut(exitcode = 1) { @@ -492,8 +498,9 @@ function unescapeString(str = '') { return new Function(`return '${source}'`)(); } +// Help that was asked for is not an error. Too few arguments are wrong flags, so a panic. if (0 < needHelp) { - backOut(needHelp - 1); + backOut(1 === needHelp ? 0 : 3); } // All options into one big config object for the rexreplace core @@ -562,13 +569,14 @@ if (config.contentDelimiter) { unescape('contentDelimiter', '--content-delimiter'); } -// The exit code is only set, so the process ends by itself when all output is written +// The exit code is only set, so the process ends by itself when all output is written. +// 0: done, 2: an error in a file and the run went on, 3: a panic (the run stopped) const run = () => rexreplace.engine(config).then( status => { printStatus(config, status); if (status.errors.length) { - process.exitCode = 1; + process.exitCode = 2; } }, e => exitOnPanic(config, e) diff --git a/test/cli/debug.test.js b/test/cli/debug.test.js index 3cba492f..f5754ab6 100644 --- a/test/cli/debug.test.js +++ b/test/cli/debug.test.js @@ -38,12 +38,12 @@ describe('--debug', () => { expect(JSON.parse(result.out.slice('fxxbar\n'.length)).files.updated).toEqual(['my.file']); }); - test('gives the errors in the status report, and the exit code 1', async () => { + test('gives the errors in the status report, and the exit code 2', async () => { fs.writeFileSync('a.file', 'foo'); fs.chmodSync('a.file', 0); const result = await sh($`bun ${cli} o x a.file --debug`); expect(result.err).toBe(''); - expect(result.exit).toBe(1); + expect(result.exit).toBe(2); expect(JSON.parse(result.out).errors[0]).toContain('EACCES'); }); @@ -51,7 +51,7 @@ describe('--debug', () => { fs.writeFileSync('my.file', 'foobar'); const result = await sh($`bun ${cli} '(' x my.file --debug`); expect(result.err).toBe(''); - expect(result.exit).toBe(1); + expect(result.exit).toBe(3); expect(JSON.parse(result.out).errors[0]).toContain('Invalid regular expression'); }); }); diff --git a/test/cli/delimiter.test.js b/test/cli/delimiter.test.js index 5c00b819..215df651 100644 --- a/test/cli/delimiter.test.js +++ b/test/cli/delimiter.test.js @@ -170,7 +170,7 @@ describe('an invalid escape sequence in a delimiter', () => { fs.writeFileSync('my.file', 'foobar\n'); const result = await sh($`bun ${cli} b x my.file ${flag} '\x4'`); expect(result.err).toContain(`Invalid escape sequence in ${flag}`); - expect(result.exit).toBe(1); + expect(result.exit).toBe(3); expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar\n'); } ); diff --git a/test/cli/encoding.test.js b/test/cli/encoding.test.js index 8c8b5465..bcf31009 100644 --- a/test/cli/encoding.test.js +++ b/test/cli/encoding.test.js @@ -10,14 +10,14 @@ describe('--encoding', () => { test('an unknown encoding stops the run with an error, and the file is not changed', async () => { fs.writeFileSync('my.file', 'foobar'); const result = await sh($`bun ${cli} o x my.file --encoding nope`); - expect(result.exit).toBe(1); + expect(result.exit).toBe(3); expect(lastLine(result.err)).toBe('Unknown encoding: nope'); expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar'); }); test('an unknown encoding also stops the run for piped data', async () => { const result = await sh($`echo -n foo | bun ${cli} o x --encoding nope`); - expect(result.exit).toBe(1); + expect(result.exit).toBe(3); expect(lastLine(result.err)).toBe('Unknown encoding: nope'); }); diff --git a/test/cli/fix/691.test.js b/test/cli/fix/691.test.js index ebf07034..ccb9b563 100644 --- a/test/cli/fix/691.test.js +++ b/test/cli/fix/691.test.js @@ -79,10 +79,10 @@ describe('#691 replacement that used to crash the CLI', () => { expect(result.err).toBe( ' ❌ Invalid escape sequence in replacement: \\x4\n No further processing.\n\n' ); - expect(result.exit).toBe(1); + expect(result.exit).toBe(3); expect( await sh($`bun ${cli} o ${'\\x4'} my.file --output --no-content-delimiter-tail --quiet-total`) - ).toEqual({out: '', err: '', exit: 1}); + ).toEqual({out: '', err: '', exit: 3}); }); test.each([ diff --git a/test/cli/fix/700.test.js b/test/cli/fix/700.test.js index 2bb3fd3d..2f657f3a 100644 --- a/test/cli/fix/700.test.js +++ b/test/cli/fix/700.test.js @@ -40,7 +40,7 @@ describe('#700 § is a full substitute for \\ in the replacement', () => { expect(await replaceB('§x4')).toEqual({ out: '', err: ' ❌ Invalid escape sequence in replacement: §x4\n No further processing.\n\n', - exit: 1, + exit: 3, }); }); diff --git a/test/cli/fix/742.test.js b/test/cli/fix/742.test.js index 0ec96d7d..1e6eaf29 100644 --- a/test/cli/fix/742.test.js +++ b/test/cli/fix/742.test.js @@ -33,18 +33,18 @@ describe('#742 the CLI', () => { } ); - test('sets the exit code to 1 when a file gives an error, and prints the status report', async () => { + test('sets the exit code to 2 when a file gives an error, and prints the status report', async () => { fs.writeFileSync('a.file', 'foo'); fs.chmodSync('a.file', 0); const result = await sh($`bun ${cli} o x a.file --status-compact --quiet-total`); expect(JSON.parse(result.out).errors[0]).toContain('EACCES'); - expect(result.exit).toBe(1); + expect(result.exit).toBe(2); }); test('ends the process on a panic, and still prints the status report', async () => { fs.writeFileSync('a.file', 'foo'); const result = await sh($`bun ${cli} '(' x a.file --status-compact --quiet-total`); expect(JSON.parse(result.out).errors).toHaveLength(1); - expect(result.exit).toBe(1); + expect(result.exit).toBe(3); }); }); diff --git a/test/cli/fix/783.test.js b/test/cli/fix/783.test.js new file mode 100644 index 00000000..b35c3993 --- /dev/null +++ b/test/cli/fix/783.test.js @@ -0,0 +1,42 @@ +// https://github.com/mathiasrw/rexreplace/issues/783 +// The exit code was 1 for an error in a file, for a run that stopped and for wrong flags, so a script could not tell them apart. +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {$} from 'bun'; +import {cli, sh} from '../../helper.js'; + +describe('#783 the exit code tells what happened', () => { + test('0 when it is done, also with a warning', async () => { + fs.writeFileSync('my.file', 'foobar'); + expect((await sh($`bun ${cli} o x my.file`)).exit).toBe(0); + expect((await sh($`bun ${cli} o x nope.file`)).exit).toBe(0); + }); + + test('0 for the help that was asked for', async () => { + expect((await sh($`bun ${cli} -h`)).exit).toBe(0); + }); + + test('2 when a file gives an error and the run goes on', async () => { + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + fs.chmodSync('a.file', 0); + expect((await sh($`bun ${cli} o x a.file b.file`)).exit).toBe(2); + expect(fs.readFileSync('b.file', 'utf8')).toBe('fxx'); + }); + + test('3 when the run stops', async () => { + fs.writeFileSync('my.file', 'foobar'); + expect((await sh($`bun ${cli} '(' x my.file`)).exit).toBe(3); + expect((await sh($`bun ${cli} o x my.file --encoding nope`)).exit).toBe(3); + }); + + test('3 for wrong flags and too few arguments, with no stray line on stdout', async () => { + fs.writeFileSync('my.file', 'foobar'); + const result = await sh($`bun ${cli} o x my.file --nope`); + expect(result.exit).toBe(3); + expect(result.out).toBe(''); + expect(result.err).toEndWith('Unknown argument: nope\n'); + expect((await sh($`bun ${cli} o`)).exit).toBe(3); + expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar'); + }); +}); diff --git a/test/cli/glob-pipe.test.js b/test/cli/glob-pipe.test.js index 9b7315d3..959e175a 100644 --- a/test/cli/glob-pipe.test.js +++ b/test/cli/glob-pipe.test.js @@ -21,7 +21,7 @@ describe('--glob-pipe', () => { fs.writeFileSync('my.file', 'foobar\n'); const result = await sh($`echo -n my.file | bun ${cli} b x --glob-pipe ${flag}`); expect(result.err).toContain('are mutually exclusive'); - expect(result.exit).toBe(1); + expect(result.exit).toBe(3); expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar\n'); } ); @@ -29,7 +29,7 @@ describe('--glob-pipe', () => { test('--null-in can only be used with --glob-pipe', async () => { fs.writeFileSync('my.file', 'foobar\n'); const result = await sh($`echo -n my.file | bun ${cli} b x --null-in`); - expect(result.exit).toBe(1); + expect(result.exit).toBe(3); expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar\n'); }); }); diff --git a/test/cli/run.sh b/test/cli/run.sh index 06596d6e..b4ac8284 100644 --- a/test/cli/run.sh +++ b/test/cli/run.sh @@ -54,12 +54,15 @@ assert_failure "rexreplace my-vile" assert_failure "rexreplace o-ho" +# The exit code: 0 when done, 2 when a file gave an error and the run went on, 3 when the run stopped # --halt (or --bail) stops at the first error, here that my.file can not be read reset chmod 000 my.file -assert_raises "rexreplace b x my.file your.file --halt" 1 -assert_raises "rexreplace b x my.file your.file --bail" 1 +assert_raises "rexreplace b x my.file your.file --halt" 3 +assert_raises "rexreplace b x my.file your.file --bail" 3 assert "cat your.file" "abc123" +assert_raises "rexreplace b x my.file your.file" 2 +assert "cat your.file" "axc123" # Finding no files is a warning and not an error assert_raises "rexreplace o x nope.file" 0 diff --git a/test/engine/basics.test.js b/test/engine/basics.test.js index 6dd1a679..d4c68c6d 100644 --- a/test/engine/basics.test.js +++ b/test/engine/basics.test.js @@ -42,7 +42,7 @@ describe('pattern', () => { test('stops with an error and leaves the files alone if it is not a valid regex', async () => { fs.writeFileSync('my.file', 'foobar'); const result = await rr('(', 'x', {includeGlob: ['my.file']}); - expect(result.exit).toBe(1); + expect(result.exit).toBe(3); expect(result.err).toContain('Invalid regular expression'); expect(result.out).toBe(''); expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar'); diff --git a/test/engine/encoding.test.js b/test/engine/encoding.test.js index 69ec6b00..e18fdd2e 100644 --- a/test/engine/encoding.test.js +++ b/test/engine/encoding.test.js @@ -51,7 +51,7 @@ describe('--encoding', () => { const unknownEncoding = { out: '', err: ' ❌ Unknown encoding: nope\n No further processing.\n\n', - exit: 1, + exit: 3, }; test('stops the run with an error if it is unknown (#690)', async () => { diff --git a/test/engine/engine.test.js b/test/engine/engine.test.js index 84d2fd5e..7e8d71d8 100644 --- a/test/engine/engine.test.js +++ b/test/engine/engine.test.js @@ -15,7 +15,7 @@ describe('--engine', () => { test('stops with an error if the engine is the one of another runtime', async () => { fs.writeFileSync('my.file', 'foobar'); const result = await rr('o', 'x', {includeGlob: ['my.file'], engine: 'V8'}); - expect(result.exit).toBe(1); + expect(result.exit).toBe(3); expect(result.err).toContain( 'When running via the Bun runtime you can only use default or JSC as engine' ); @@ -25,13 +25,13 @@ describe('--engine', () => { test('stops with an error if the engine is unknown', async () => { fs.writeFileSync('my.file', 'foobar'); const result = await rr('o', 'x', {includeGlob: ['my.file'], engine: 'nope'}); - expect(result.exit).toBe(1); + expect(result.exit).toBe(3); expect(result.err).toContain('Engine nope not supported'); expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar'); }); test('is case sensitive', async () => { - expect((await rr('o', 'x', {pipedData: 'foobar', engine: 'jsc'})).exit).toBe(1); + expect((await rr('o', 'x', {pipedData: 'foobar', engine: 'jsc'})).exit).toBe(3); }); describe('combined', () => { @@ -54,7 +54,7 @@ describe('--engine', () => { engine: 'nope', quietTotal: true, }); - expect(result).toEqual({out: '', err: '', exit: 1}); + expect(result).toEqual({out: '', err: '', exit: 3}); }); }); }); diff --git a/test/engine/fix/681.test.js b/test/engine/fix/681.test.js index beef3631..3c3ecb22 100644 --- a/test/engine/fix/681.test.js +++ b/test/engine/fix/681.test.js @@ -10,13 +10,13 @@ describe('#681 --halt exits with a numeric non-zero code', () => { fs.chmodSync('a.file', 0); const result = await rr('o', 'x', {includeGlob: ['a.file'], halt: true}); expect(result.err).toContain('EACCES'); - expect(result.exit).toBe(1); + expect(result.exit).toBe(3); }); test('invalid regular expression', async () => { const result = await rr('(', 'x', {pipedData: 'foobar', halt: true}); expect(result.err).toContain('Invalid regular expression'); - expect(result.exit).toBe(1); + expect(result.exit).toBe(3); }); test('--quiet-total still halts with the exit code but shows no message', async () => { @@ -27,6 +27,6 @@ describe('#681 --halt exits with a numeric non-zero code', () => { halt: true, quietTotal: true, }); - expect(result).toEqual({out: '', err: '', exit: 1}); + expect(result).toEqual({out: '', err: '', exit: 3}); }); }); diff --git a/test/engine/fix/682.test.js b/test/engine/fix/682.test.js index 73d23ecd..368e3842 100644 --- a/test/engine/fix/682.test.js +++ b/test/engine/fix/682.test.js @@ -9,7 +9,7 @@ describe('#682 --quiet still displays errors', () => { fs.writeFileSync('my.file', 'foobar'); const result = await rr('(', 'x', {includeGlob: ['my.file'], quiet: true}); expect(result.err).toContain('Invalid regular expression'); - expect(result.exit).toBe(1); + expect(result.exit).toBe(3); expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar'); }); @@ -35,7 +35,7 @@ describe('#682 --quiet still displays errors', () => { expect(await rr('(', 'x', {includeGlob: ['my.file'], quietTotal: true})).toEqual({ out: '', err: '', - exit: 1, + exit: 3, }); expect((await rr('o', 'x', {includeGlob: ['nope.file'], quietTotal: true})).err).toBe(''); }); diff --git a/test/engine/fix/683.test.js b/test/engine/fix/683.test.js index 343c0f88..7d9dd4a8 100644 --- a/test/engine/fix/683.test.js +++ b/test/engine/fix/683.test.js @@ -83,7 +83,7 @@ describe('#683 --exclude-re follows the regex flags of the main search', () => { fs.writeFileSync('my.file', 'foo'); const result = await rr('foo', 'x', {includeGlob: ['my.file'], excludeRe: ['(']}); expect(result.err).toContain('Invalid regular expression'); - expect(result.exit).toBe(1); + expect(result.exit).toBe(3); expect(fs.readFileSync('my.file', 'utf8')).toBe('foo'); }); }); diff --git a/test/engine/fix/703.test.js b/test/engine/fix/703.test.js index c078ce27..42543964 100644 --- a/test/engine/fix/703.test.js +++ b/test/engine/fix/703.test.js @@ -204,7 +204,7 @@ describe('#703 the names of affected files', () => { }); expect(result.out).toBe('a.file'); expect(result.err).toContain('EACCES'); - expect(result.exit).toBe(1); + expect(result.exit).toBe(3); } finally { fs.chmodSync('locked', 0o755); } @@ -223,7 +223,7 @@ describe('#703 the names of affected files', () => { nl: true, }); expect(result.out).toBe('a.file\n'); - expect(result.exit).toBe(1); + expect(result.exit).toBe(3); } finally { fs.chmodSync('locked', 0o755); } diff --git a/test/engine/fix/705.test.js b/test/engine/fix/705.test.js index b40376c5..b5cc4da0 100644 --- a/test/engine/fix/705.test.js +++ b/test/engine/fix/705.test.js @@ -81,7 +81,7 @@ describe('#705 the engine returns a promise that resolves when all files are don const result = await rr('o', 'x', {includeGlob: ['*.file'], ...flags}); expect(result.err).toContain('ENAMETOOLONG'); - expect(result.exit).toBe(1); + expect(result.exit).toBe(2); expect(content(['a.file', tooLong, 'c.file'])).toEqual(['fxx', 'foo', 'fxx']); expect(fs.readdirSync('.')).toHaveLength(3); }); @@ -90,7 +90,7 @@ describe('#705 the engine returns a promise that resolves when all files are don fs.writeFileSync(tooLong, 'foo'); const result = await rr('o', 'x', {includeGlob: ['*.file'], halt: true, ...flags}); expect(result.err).toContain('ENAMETOOLONG'); - expect(result.exit).toBe(1); + expect(result.exit).toBe(3); }); }); diff --git a/test/engine/fix/708.test.js b/test/engine/fix/708.test.js index 9eee43dc..c2a77616 100644 --- a/test/engine/fix/708.test.js +++ b/test/engine/fix/708.test.js @@ -79,7 +79,7 @@ describe('#708 the number of files in work at the same time is limited', () => { expect(result.out).toBe('my.file\n'); expect(result.err).toContain('EACCES'); - expect(result.exit).toBe(1); + expect(result.exit).toBe(2); expect(fs.readFileSync('my.file', 'utf8')).toBe('fxx'); }); }); diff --git a/test/engine/fix/711.test.js b/test/engine/fix/711.test.js index 37a6b15d..6d3b255f 100644 --- a/test/engine/fix/711.test.js +++ b/test/engine/fix/711.test.js @@ -83,7 +83,7 @@ describe('#711 --glob-pipe', () => { ])('can not be used with %s, as the piped data can only be one thing', async (name, flags) => { fs.writeFileSync('a.file', 'foo'); const result = await rr('o', 'x', {pipedData: 'a.file', globPipe: true, ...flags}); - expect(result.exit).toBe(1); + expect(result.exit).toBe(3); expect(result.err).toContain('--glob-pipe can not be used with'); expect(content('a.file')).toBe('foo'); }); diff --git a/test/engine/fix/720.test.js b/test/engine/fix/720.test.js index a2513fe4..49857ac2 100644 --- a/test/engine/fix/720.test.js +++ b/test/engine/fix/720.test.js @@ -15,7 +15,7 @@ describe('#720 every error starts with ❌', () => { expect(result.err).toStartWith(' ❌ '); expect(result.err).toContain('EACCES'); expect(result.err).toEndWith('\n\n'); - expect(result.exit).toBe(1); + expect(result.exit).toBe(2); }); test('an error that stops the run', async () => { diff --git a/test/engine/fix/728.test.js b/test/engine/fix/728.test.js index 30c08e57..0cc7e3f4 100644 --- a/test/engine/fix/728.test.js +++ b/test/engine/fix/728.test.js @@ -10,7 +10,7 @@ const flows = [ ['--void-async', {voidAsync: true}], ]; -describe('#728 the exit code is 1 when a file gives an error', () => { +describe('#728 the exit code is not 0 when a file gives an error', () => { test.each(flows)('and the other files are still changed with %s', async (name, flags) => { fs.writeFileSync('a.file', 'foo'); fs.writeFileSync('b.file', 'foo'); @@ -18,7 +18,7 @@ describe('#728 the exit code is 1 when a file gives an error', () => { const result = await rr('o', 'x', {includeGlob: ['a.file', 'b.file'], ...flags}); expect(result.out).toBe('a.file\n'); expect(result.err).toContain('EACCES'); - expect(result.exit).toBe(1); + expect(result.exit).toBe(2); expect(fs.readFileSync('a.file', 'utf8')).toBe('fxx'); }); @@ -31,7 +31,7 @@ describe('#728 the exit code is 1 when a file gives an error', () => { const result = await rr('o', 'x', {includeGlob: ['locked/my.file']}); expect(result.out).toBe(''); expect(result.err).toContain('EACCES'); - expect(result.exit).toBe(1); + expect(result.exit).toBe(2); expect(fs.readFileSync('locked/my.file', 'utf8')).toBe('foo'); } finally { // The folder must be writable again so the test folder can be removed @@ -43,6 +43,6 @@ describe('#728 the exit code is 1 when a file gives an error', () => { fs.writeFileSync('a.file', 'foo'); fs.writeFileSync('b.file', 'foo'); fs.chmodSync('b.file', 0); - expect((await rr('o', 'x', {includeGlob: ['a.file', 'b.file'], [flag]: true})).exit).toBe(1); + expect((await rr('o', 'x', {includeGlob: ['a.file', 'b.file'], [flag]: true})).exit).toBe(2); }); }); diff --git a/test/engine/fix/742.test.js b/test/engine/fix/742.test.js index 454db424..064221de 100644 --- a/test/engine/fix/742.test.js +++ b/test/engine/fix/742.test.js @@ -95,7 +95,7 @@ describe('#742 a panic', () => { fs.writeFileSync('c.file', 'foo'); fs.chmodSync('b.file', 0); const config = {includeGlob: ['*.file'], halt: true}; - expect((await rr('o', 'x', config)).exit).toBe(1); + expect((await rr('o', 'x', config)).exit).toBe(3); fs.writeFileSync('a.file', 'foo'); const status = await rrStatus('o', 'x', config); expect(status.files).toEqual({searched: ['a.file', 'b.file'], updated: ['a.file']}); @@ -103,7 +103,7 @@ describe('#742 a panic', () => { }); test('is thrown when the run can not go on, with the error in the status', async () => { - expect(await rr('(', 'x', {pipedData: 'foo'})).toMatchObject({out: '', exit: 1}); + expect(await rr('(', 'x', {pipedData: 'foo'})).toMatchObject({out: '', exit: 3}); expect((await rrStatus('(', 'x', {pipedData: 'foo'})).errors).toHaveLength(1); }); }); diff --git a/test/engine/halt.test.js b/test/engine/halt.test.js index 6b62e10a..8df1823a 100644 --- a/test/engine/halt.test.js +++ b/test/engine/halt.test.js @@ -10,7 +10,7 @@ describe('--halt', () => { fs.chmodSync('a.file', 0); const result = await rr('o', 'x', {includeGlob: ['a.file', 'b.file'], halt: true}); expect(result.err).toContain('EACCES'); - expect(result.exit).toBe(1); + expect(result.exit).toBe(3); expect(fs.readFileSync('b.file', 'utf8')).toBe('foo'); }); @@ -20,7 +20,7 @@ describe('--halt', () => { fs.chmodSync('a.file', 0); const result = await rr('o', 'x', {includeGlob: ['a.file', 'b.file']}); expect(result.err).toContain('EACCES'); - expect(result.exit).toBe(1); + expect(result.exit).toBe(2); expect(fs.readFileSync('b.file', 'utf8')).toBe('fxx'); }); @@ -34,8 +34,8 @@ describe('--halt', () => { test('stops with a number as exit code', async () => { fs.writeFileSync('a.file', 'foo'); fs.chmodSync('a.file', 0); - expect((await rr('o', 'x', {includeGlob: ['a.file'], halt: true})).exit).toBe(1); - expect((await rr('(', 'x', {pipedData: 'foobar', halt: true})).exit).toBe(1); + expect((await rr('o', 'x', {includeGlob: ['a.file'], halt: true})).exit).toBe(3); + expect((await rr('(', 'x', {pipedData: 'foobar', halt: true})).exit).toBe(3); }); describe('combined', () => { @@ -52,7 +52,7 @@ describe('--halt', () => { test('with an invalid pattern it stops with or without it', async () => { expect((await rr('(', 'x', {pipedData: 'foobar', halt: true})).exit).not.toBe(null); - expect((await rr('(', 'x', {pipedData: 'foobar'})).exit).toBe(1); + expect((await rr('(', 'x', {pipedData: 'foobar'})).exit).toBe(3); }); test('with piped data and several flags when all goes well', async () => { diff --git a/test/engine/quiet-total.test.js b/test/engine/quiet-total.test.js index 80c5cf56..80ac758d 100644 --- a/test/engine/quiet-total.test.js +++ b/test/engine/quiet-total.test.js @@ -25,7 +25,7 @@ describe('--quiet-total', () => { test('still stops on an error', async () => { fs.writeFileSync('my.file', 'foobar'); - expect((await rr('(', 'x', {includeGlob: ['my.file'], quietTotal: true})).exit).toBe(1); + expect((await rr('(', 'x', {includeGlob: ['my.file'], quietTotal: true})).exit).toBe(3); expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar'); }); @@ -50,7 +50,7 @@ describe('--quiet-total', () => { test('with --replacement-pipe and nothing piped it stops in silence', async () => { fs.writeFileSync('my.file', 'foobar'); const config = {includeGlob: ['my.file'], quietTotal: true, replacementPipe: true}; - expect(await rr('o', '_', config)).toEqual({out: '', err: '', exit: 1}); + expect(await rr('o', '_', config)).toEqual({out: '', err: '', exit: 3}); }); test('with --halt it stops in silence', async () => { diff --git a/test/engine/quiet.test.js b/test/engine/quiet.test.js index d0ffdd88..bbc9fd8d 100644 --- a/test/engine/quiet.test.js +++ b/test/engine/quiet.test.js @@ -17,7 +17,7 @@ describe('--quiet', () => { test('still stops on an invalid pattern', async () => { fs.writeFileSync('my.file', 'foobar'); - expect((await rr('(', 'x', {includeGlob: ['my.file'], quiet: true})).exit).toBe(1); + expect((await rr('(', 'x', {includeGlob: ['my.file'], quiet: true})).exit).toBe(3); expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar'); }); diff --git a/test/engine/replacement-pipe.test.js b/test/engine/replacement-pipe.test.js index ca544000..6ce19b0e 100644 --- a/test/engine/replacement-pipe.test.js +++ b/test/engine/replacement-pipe.test.js @@ -57,7 +57,7 @@ describe('--replacement-pipe', () => { test('stops with an error if nothing is piped in', async () => { fs.writeFileSync('my.file', 'foobar'); const result = await rr('o', '_', {includeGlob: ['my.file'], replacementPipe: true}); - expect(result.exit).toBe(1); + expect(result.exit).toBe(3); expect(result.err).toContain('No data piped into replacement'); expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar'); }); diff --git a/test/engine/unicode.test.js b/test/engine/unicode.test.js index 5510a18c..0b412258 100644 --- a/test/engine/unicode.test.js +++ b/test/engine/unicode.test.js @@ -38,7 +38,7 @@ describe('--unicode', () => { test('is strict about escapes that have no meaning', async () => { expect((await rr('\\-', 'x', {pipedData: 'a-b'})).out).toBe('axb'); const result = await rr('\\-', 'x', {pipedData: 'a-b', unicode: true}); - expect(result.exit).toBe(1); + expect(result.exit).toBe(3); expect(result.err).toContain('Invalid regular expression'); expect(result.out).toBe(''); }); diff --git a/test/engine/void-section.test.js b/test/engine/void-section.test.js index 3ba95863..5bab9a63 100644 --- a/test/engine/void-section.test.js +++ b/test/engine/void-section.test.js @@ -44,7 +44,7 @@ describe('--void-section', () => { test('a lone § at the end of the pattern is an unfinished escape', async () => { const result = await rr('a§', 'x', {pipedData: 'a§'}); - expect(result.exit).toBe(1); + expect(result.exit).toBe(3); expect(result.err).toContain('Invalid regular expression'); expect((await rr('a§', 'x', {pipedData: 'a§', voidSection: true})).out).toBe('x'); }); diff --git a/test/helper.js b/test/helper.js index ed2ccfd9..2b932fb0 100644 --- a/test/helper.js +++ b/test/helper.js @@ -69,17 +69,17 @@ async function run(pattern, replacement, config) { }; alias(options); status = await engine(options); - // Like the CLI, an error gives the exit code 1 when the run is done + // Like the CLI, an error gives the exit code 2 when the run is done if (status.errors.length) { - result.exit = 1; + result.exit = 2; } } catch (e) { - // Like the CLI, a panic stops the run with the exit code 1 + // Like the CLI, a panic stops the run with the exit code 3 if (!e?.panic) { throw e; } status = e.status; - result.exit = 1; + result.exit = 3; } finally { spies.forEach(spy => spy.mockRestore()); } From 6905629a44a4120ef9ffa8c42fdc2d061d7192eb Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 15:16:30 +1100 Subject: [PATCH 08/27] Add --rename to search and replace in the path of files --- README.md | 5 +- src/cli.ts | 17 +++- src/engine.ts | 136 +++++++++++++++++++++++++ src/output.ts | 4 +- src/shim.ts | 11 +++ test/cli/rename.test.js | 24 +++++ test/cli/run.sh | 19 ++++ test/engine/rename-force.test.js | 32 ++++++ test/engine/rename.test.js | 164 +++++++++++++++++++++++++++++++ 9 files changed, 404 insertions(+), 8 deletions(-) create mode 100644 src/shim.ts create mode 100644 test/cli/rename.test.js create mode 100644 test/engine/rename-force.test.js create mode 100644 test/engine/rename.test.js diff --git a/README.md b/README.md index cd9928dd..3a97b907 100644 --- a/README.md +++ b/README.md @@ -260,8 +260,10 @@ Use --js-pipe to access the pipe data in your javascript code. | `-o` | **`--output`** Output the final result instead of saving to file. Will also output content even if no replacement has taken place. [boolean] | | `-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. [boolean] | +| `-b` | **`--keep-backup`** Keep a backup file of the original content. With --rename the old file is kept. [boolean] | | `-S` | **`--simulate`** Do everything but write the files. The names of the files that would be changed are printed as usual, and no backup is made. [boolean] | +| `-r` | **`--rename`** Search and replace in the path of each file, and rename the file to the result. The content is not touched. A / in the replacement moves the file, and folders that are not there are made. If a rename would lose a file, no file is renamed. [boolean] | +| | **`--rename-force`** With --rename, 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. 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] | | `-T` | **`--trim-pipe`** Trim piped data before processing. If piped data only consists of chars that can be trimmed (new line, space, tabs...) it will become an empty string. [boolean] | | `-p` | **`--pipe`** Read the piped data and make it available to --replacement-js as `pipe`. [boolean] | @@ -295,6 +297,7 @@ Files identified for potential processing are identified by: - Data can be piped in, both as the text to search and as the replacement - The names of the changed files are printed to stdout, so they can be piped on (to stderr with `--output` and `--output-match`) - Set the delimiter between names and between the content of files +- Rename and move files with a search and replace on their path (`--rename`). A rename that would lose a file stops the run before any file is renamed, and `--simulate` shows each old name next to its new one - See the [release note](https://github.com/mathiasrw/rexreplace/releases) for a log of changes. Descriptions are given in latest patch version. ### Quirks diff --git a/src/cli.ts b/src/cli.ts index adc11505..884d3c7a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -237,7 +237,7 @@ const yargs = require('yargs') .alias('B', 'void-backup') .boolean('b') - .describe('b', 'Keep a backup file of the original content.') + .describe('b', 'Keep a backup file of the original content. With --rename the old file is kept.') .alias('b', 'keep-backup') .boolean('S') @@ -247,6 +247,17 @@ const yargs = require('yargs') ) .alias('S', 'simulate') + .boolean('r') + .describe( + 'r', + 'Search and replace in the path of each file, and rename the file to the result. The content is not touched. A / in the replacement moves the file, and folders that are not there are made. If a rename would lose a file, no file is renamed.' + ) + .alias('r', 'rename') + + .boolean('rename-force') + .describe('rename-force', 'With --rename, write over a file that is there already.') + .implies('rename-force', 'rename') + .boolean('m') .describe( 'm', @@ -373,10 +384,6 @@ All variables, except from module, date objects, \`nl\` and \`_\`, has a corresp /* // Ideas - .boolean('n') - .describe('n', "Do replacement on file path/names instead of file content (rename/move the files)") - .alias('n', 'name') - // https://github.com/eugeneware/replacestream .integer('M') .describe('M', "Maximum length of match. Set this value only if any single file of your ") diff --git a/src/engine.ts b/src/engine.ts index a41b7263..501050c1 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -10,6 +10,8 @@ const now = new Date(); import {outputConfig, chat, info, print, printDone, warn, error, panic} from './output.ts'; +import './shim.ts'; + const re = { mctime: /[mc]time/, colon: /:/g, @@ -55,6 +57,11 @@ export async function engine(config: any = {engine: 'default'}) { function newStatus(config) { const status: any = {files: {searched: [], updated: []}, pipe: null, warnings: [], errors: []}; + // The old and the new path of each file that is renamed, or would be + if (config.rename) { + status.renames = []; + } + // The status report with --verbose also tells what went into the engine and what it did. // The config is copied before it gets the status, and the piped data is told about in `pipe`. if (config.verbose && (config.status || config.statusCompact)) { @@ -130,6 +137,10 @@ async function run(config) { config.contentDelimiterTail = config.contentDelimiterTail ?? config.tail ?? !(piped && config.output); + if (config.rename && piped) { + panic('--rename needs files, as piped data has no name to change'); + } + if (piped) { return doReplacement('Piped data', config, config.pipedData); } @@ -151,6 +162,10 @@ async function run(config) { fs.existsSync(filepath) ? true : error('File not found:', filepath) ); + if (config.rename) { + return renameFiles(files, config); + } + // A limited number of files are in work at the same time, so the memory and the open files stay within bounds. // Each worker takes the next file when it is done with one. Up to 16 files are all handled at once. const workers = config.voidAsync @@ -273,6 +288,127 @@ function updated(file, config) { info(file); } +// --rename: the path of each file is the content that is searched and replaced, and the file is renamed to the result +function renameFiles(files, config) { + const plan = files.map(file => { + config.statusInfo.files.searched.push(file); + chat('Work on the path of: ' + file); + const replacement = + config.replacementJs && !config.outputMatch + ? dynamicReplacement(file, config, file) + : config.replacement; + return {old: file, new: file.replace(config.regex, replacement)}; + }); + const renames = plan.filter(item => item.new !== item.old); + config.statusInfo.renames.push(...renames); + + // With --output-match the matches are printed by the replacement, and the names go to stderr + if (config.outputMatch) { + return plan.forEach(item => + item.new !== item.old ? updated(item.old, config) : config.nameAll && info(item.old) + ); + } + + findClashes(renames, config); + + plan.forEach(item => { + if (item.new === item.old) { + chat('Same name: ' + item.old); + return config.nameAll && nameRename(item, config); + } + // --simulate and --output do everything but rename + if (config.simulate || config.output) { + config.statusInfo.files.updated.push(item.old); + return nameRename(item, config); + } + renameFile(item, config); + }); +} + +// 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) { + info(item.old, process.stderr); + } + info(item.new, process.stdout); +} + +// A rename must not lose a file, so any clash stops the run before a file is renamed. +// The paths are compared as absolute paths, as two relative paths can lead to the same file. +function findClashes(renames, config) { + const byTarget = new Map(); + renames.forEach(item => { + const target = path.resolve(item.new); + byTarget.set(target, (byTarget.get(target) || []).concat(item)); + }); + const sources = new Set(renames.map(item => path.resolve(item.old))); + const targets = new Set(byTarget.keys()); + const ontoRenamed = targets.intersection(sources); + const ontoOthers = targets.difference(sources); + + const clashes = []; + byTarget.forEach((items, target) => { + const [item] = items; + if (1 < items.length) { + clashes.push( + items.map(each => each.old).join(' and ') + + (2 === items.length ? ' would both' : ' would all') + + ' be renamed to ' + + item.new + ); + } else if (ontoRenamed.has(target)) { + clashes.push(item.old + ' would be renamed to ' + item.new + ', which is also renamed'); + } else if (ontoOthers.has(target) && !config.renameForce && isOtherFile(item.old, target)) { + clashes.push(item.old + ' would be renamed to ' + item.new + ', which is there already'); + } + }); + + if (clashes.length) { + clashes.forEach(clash => error(clash)); + panic('No file is renamed, as a rename would lose a file'); + } +} + +// On a file system that is case-insensitive, `A.md` is the same file as `a.md`, so a rename of only the case is not a clash +function isOtherFile(file, target) { + const stats = fs.statSync(target, {throwIfNoEntry: false}); + if (!stats) { + return false; + } + const own = fs.statSync(file); + return own.dev !== stats.dev || own.ino !== stats.ino; +} + +function renameFile(item, config) { + const target = path.resolve(item.new); + try { + const folder = path.dirname(target); + if (!fs.existsSync(folder)) { + chat('Make folder: ' + path.dirname(item.new)); + fs.mkdirSync(folder, {recursive: true}); + } + // Checked again, as the files can have changed since the clashes were found + if (isOtherFile(item.old, target)) { + if (!config.renameForce) { + return error(item.new + ' is there already, so ' + item.old + ' is not renamed'); + } + chat('Write over: ' + item.new); + } + // With --keep-backup the old file is kept, so the file is copied + if (config.keepBackup) { + chat('Copy: ' + item.old + ' to ' + item.new); + fs.copyFileSync(item.old, target); + } else { + chat('Rename: ' + item.old + ' to ' + item.new); + fs.renameSync(item.old, target); + } + } catch (e) { + return error(e); + } + config.statusInfo.files.updated.push(item.old); + info(item.new); +} + // How the piped data was used function pipeType(config, piped) { if (config.globPipe) { diff --git a/src/output.ts b/src/output.ts index 7f50bdaf..ff418041 100644 --- a/src/output.ts +++ b/src/output.ts @@ -62,12 +62,12 @@ export const printDone = function () { }; // The name of a changed file goes to stdout so the names can be piped -export const info = function (name) { +export const info = function (name, stream = null) { if (config.quiet || config.quietTotal) { return; } // stdout is taken when the result is printed, so then the names go to stderr - const stream = config.output || config.outputMatch ? process.stderr : process.stdout; + stream ??= config.output || config.outputMatch ? process.stderr : process.stdout; print(name, config.nameDelimiter, config.nameDelimiterTail, stream); }; diff --git a/src/shim.ts b/src/shim.ts new file mode 100644 index 00000000..3d576fd5 --- /dev/null +++ b/src/shim.ts @@ -0,0 +1,11 @@ +// The Set methods came in Node 22. They are added here for older runtimes, with only what RexReplace uses. + +const proto: any = Set.prototype; + +proto.intersection ??= function (other) { + return new Set([...this].filter(item => other.has(item))); +}; + +proto.difference ??= function (other) { + return new Set([...this].filter(item => !other.has(item))); +}; diff --git a/test/cli/rename.test.js b/test/cli/rename.test.js new file mode 100644 index 00000000..271aeae0 --- /dev/null +++ b/test/cli/rename.test.js @@ -0,0 +1,24 @@ +// --rename (-r) does the search and replace on the path of each file, and renames the file to the result +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {$} from 'bun'; +import {cli, sh} from '../helper.js'; + +describe('--rename', () => { + test('gives the new names to stdout, so they can be piped on', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'foo'); + const result = await sh( + $`bun ${cli} md txt '*.md' -r --null-out | bun ${cli} f x --glob-pipe --null-in -A` + ); + expect(result).toEqual({out: 'a.txt\nb.txt\n', err: '', exit: 0}); + expect(fs.readFileSync('a.txt', 'utf8')).toBe('xoo'); + }); + + test('--rename-force needs --rename', async () => { + fs.writeFileSync('a.md', 'foo'); + const result = await sh($`bun ${cli} md txt a.md --rename-force`); + expect(result.exit).toBe(3); + expect(fs.readdirSync('.')).toEqual(['a.md']); + }); +}); diff --git a/test/cli/run.sh b/test/cli/run.sh index b4ac8284..c6861845 100644 --- a/test/cli/run.sh +++ b/test/cli/run.sh @@ -233,6 +233,25 @@ assert "rexreplace 'oo' 'x' my.file your.file --simulate" 'my.file' assert "cat my.file" 'foobar' +# --rename (-r) does the search and replace on the path, and renames the file to the result +reset +assert "rexreplace '^my' 'our' my.file --rename" 'our.file' +assert "cat our.file" 'foobar' +assert "rexreplace '^' 'sub/' our.file -r" 'sub/our.file' +assert "cat sub/our.file" 'foobar' +rm -rf sub + +# --rename with --simulate prints the old names to stderr and the new ones to stdout +reset +assert "rexreplace '^my' 'our' my.file -r --simulate 2>/dev/null" 'our.file' +assert "rexreplace '^my' 'our' my.file -r --simulate 2>&1 >/dev/null" 'my.file' + +# A rename that would lose a file stops the run before any file is renamed +reset +assert_raises "rexreplace '^(my|your)' 'our' my.file your.file -r" 3 +assert "cat my.file" 'foobar' + + # --full-path prints the full path of each name reset assert "rexreplace 'b' 'x' my.file --full-path" "$(pwd -P)/my.file" diff --git a/test/engine/rename-force.test.js b/test/engine/rename-force.test.js new file mode 100644 index 00000000..2cd09317 --- /dev/null +++ b/test/engine/rename-force.test.js @@ -0,0 +1,32 @@ +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {rr} from '../helper.js'; + +describe('--rename-force', () => { + test('writes over a file that is there already', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('a.txt', 'bar'); + const config = {includeGlob: ['a.md'], rename: true, renameForce: true, verbose: true}; + const result = await rr('md', 'txt', config); + expect(result.out).toBe('a.txt\n'); + expect(result.err).toContain('Write over: a.txt\nRename: a.md to a.txt\n'); + expect(fs.readdirSync('.')).toEqual(['a.txt']); + expect(fs.readFileSync('a.txt', 'utf8')).toBe('foo'); + }); + + test('does not write over a file that is renamed in the same run', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'bar'); + const config = {includeGlob: ['*.md'], rename: true, renameForce: true, replacementJs: true}; + expect((await rr('^[ab]', '$0 == "a" ? "b" : "a"', config)).exit).toBe(3); + expect(fs.readFileSync('a.md', 'utf8')).toBe('foo'); + }); + + test('does not let two files get the same new path', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'bar'); + const config = {includeGlob: ['*.md'], rename: true, renameForce: true}; + expect((await rr('^[ab]', 'c', config)).exit).toBe(3); + expect(fs.readdirSync('.').sort()).toEqual(['a.md', 'b.md']); + }); +}); diff --git a/test/engine/rename.test.js b/test/engine/rename.test.js new file mode 100644 index 00000000..2147335a --- /dev/null +++ b/test/engine/rename.test.js @@ -0,0 +1,164 @@ +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import path from 'path'; +import {rr, rrStatus} from '../helper.js'; + +const files = () => fs.readdirSync('.', {recursive: true}).sort(); + +describe('--rename', () => { + test('renames each file to the result of the search and replace on its path', async () => { + fs.writeFileSync('a.markdown', 'foo'); + fs.writeFileSync('b.markdown', 'bar'); + const config = {includeGlob: ['*.markdown'], rename: true}; + expect(await rr('\\.markdown$', '.md', config)).toEqual({ + out: 'a.md\nb.md\n', + err: '', + exit: null, + }); + expect(files()).toEqual(['a.md', 'b.md']); + expect(fs.readFileSync('a.md', 'utf8')).toBe('foo'); + }); + + test('leaves a file where the pattern changes nothing, and names it with --name-all', async () => { + fs.writeFileSync('a.txt', 'foo'); + fs.writeFileSync('b.md', 'foo'); + const config = {includeGlob: ['*.*'], rename: true}; + expect((await rr('txt', 'text', config)).out).toBe('a.text\n'); + fs.renameSync('a.text', 'a.txt'); + expect((await rr('txt', 'text', {...config, nameAll: true})).out).toBe('a.text\nb.md\n'); + expect(files()).toEqual(['a.text', 'b.md']); + }); + + test('moves a file with a / in the replacement, and makes the folders', async () => { + fs.writeFileSync('a.md', 'foo'); + const result = await rr('^', 'docs/old/', {includeGlob: ['a.md'], rename: true, verbose: true}); + expect(result.out).toBe('docs/old/a.md\n'); + expect(result.err).toContain('Make folder: docs/old\nRename: a.md to docs/old/a.md\n'); + expect(fs.readFileSync('docs/old/a.md', 'utf8')).toBe('foo'); + }); + + test('works on the full path with --full-path', async () => { + fs.writeFileSync('a.md', 'foo'); + const config = {includeGlob: ['a.md'], rename: true, fullPath: true}; + expect((await rr('a\\.md$', 'b.md', config)).out).toBe(path.resolve('b.md') + '\n'); + expect(files()).toEqual(['b.md']); + }); + + test('runs the code of --replacement-js with the variables of the file', async () => { + fs.writeFileSync('a.md', 'foo'); + const config = {includeGlob: ['a.md'], rename: true, replacementJs: true}; + expect((await rr('^.*$', 'name.toUpperCase() + ext', config)).out).toBe('A.md\n'); + }); + + test('renames only the case of a name', async () => { + fs.writeFileSync('a.md', 'foo'); + const config = {includeGlob: ['a.md'], rename: true, voidIgnoreCase: true}; + expect(await rr('a', 'A', config)).toEqual({out: 'A.md\n', err: '', exit: null}); + expect(files()).toEqual(['A.md']); + }); + + test('tells the old and the new paths in the status report', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.txt', 'foo'); + const status = await rrStatus('md', 'txt', {includeGlob: ['a.md', 'b.txt'], rename: true}); + expect(status.renames).toEqual([{old: 'a.md', new: 'a.txt'}]); + expect(status.files).toEqual({searched: ['a.md', 'b.txt'], updated: ['a.md']}); + }); + + describe.each([['simulate'], ['output']])('with --%s', flag => { + test('the old names go to stderr and the new ones to stdout, and nothing is renamed', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'foo'); + const config = {includeGlob: ['*.md'], rename: true, [flag]: true}; + expect(await rr('md', 'txt', config)).toEqual({ + out: 'a.txt\nb.txt\n', + err: 'a.md\nb.md\n', + exit: null, + }); + expect(files()).toEqual(['a.md', 'b.md']); + expect((await rrStatus('md', 'txt', config)).renames).toEqual([ + {old: 'a.md', new: 'a.txt'}, + {old: 'b.md', new: 'b.txt'}, + ]); + }); + }); + + test('with --output-match the matches go to stdout and the names to stderr', async () => { + fs.writeFileSync('a.md', 'foo'); + const config = {includeGlob: ['a.md'], rename: true, outputMatch: true}; + expect(await rr('md', '_', config)).toEqual({out: 'md\n', err: 'a.md\n', exit: null}); + expect(files()).toEqual(['a.md']); + }); +}); + +describe('--rename with --keep-backup', () => { + test('keeps the old file', async () => { + fs.writeFileSync('a.md', 'foo'); + const config = {includeGlob: ['a.md'], rename: true, keepBackup: true, verbose: true}; + const result = await rr('md', 'txt', config); + expect(result.out).toBe('a.txt\n'); + expect(result.err).toContain('Copy: a.md to a.txt\n'); + expect(files()).toEqual(['a.md', 'a.txt']); + expect(fs.readFileSync('a.txt', 'utf8')).toBe('foo'); + }); +}); + +describe('--rename stops the run before any file is renamed, when a rename would lose a file', () => { + const stopped = + ' ❌ No file is renamed, as a rename would lose a file\n No further processing.\n\n'; + + test('when two files would get the same new path, also given in two ways', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'foo'); + const config = {includeGlob: ['a.md', './b.md'], rename: true}; + expect(await rr('^(\\./)?[ab]', 'c', config)).toEqual({ + out: '', + err: ' ❌ ./b.md and a.md would both be renamed to c.md\n\n' + stopped, + exit: 3, + }); + expect(files()).toEqual(['a.md', 'b.md']); + }); + + test('when two files would swap names', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'bar'); + const config = {includeGlob: ['*.md'], rename: true, replacementJs: true}; + expect(await rr('^[ab]', '$0 == "a" ? "b" : "a"', config)).toEqual({ + out: '', + err: + ' ❌ a.md would be renamed to b.md, which is also renamed\n\n' + + ' ❌ b.md would be renamed to a.md, which is also renamed\n\n' + + stopped, + exit: 3, + }); + expect(fs.readFileSync('a.md', 'utf8')).toBe('foo'); + }); + + test('when the new path is a file already, and other files are fine', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('a.txt', 'bar'); + fs.writeFileSync('b.md', 'foo'); + expect(await rr('md', 'txt', {includeGlob: ['*.md'], rename: true})).toEqual({ + out: '', + err: ' ❌ a.md would be renamed to a.txt, which is there already\n\n' + stopped, + exit: 3, + }); + expect(files()).toEqual(['a.md', 'a.txt', 'b.md']); + }); + + test('with --simulate too', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('a.txt', 'bar'); + expect( + (await rr('md', 'txt', {includeGlob: ['a.md'], rename: true, simulate: true})).exit + ).toBe(3); + }); +}); + +describe('--rename can not be used', () => { + test('with piped data, as it has no name', async () => { + const result = await rr('o', 'x', {pipedData: 'foo', rename: true}); + expect(result.err).toStartWith(' ❌ --rename needs files'); + expect(result.exit).toBe(3); + }); +}); From cba498dc9f95619cd714e8792aa2efcd5bd85402 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 15:17:03 +1100 Subject: [PATCH 09/27] Say planned renames clash, not that a file would be lost --- README.md | 4 ++-- src/cli.ts | 2 +- src/engine.ts | 4 ++-- test/cli/run.sh | 2 +- test/engine/rename.test.js | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3a97b907..7a2ba742 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,7 @@ Use --js-pipe to access the pipe data in your javascript code. | `-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`** Do everything but write the files. The names of the files that would be changed are printed as usual, and no backup is made. [boolean] | -| `-r` | **`--rename`** Search and replace in the path of each file, and rename the file to the result. The content is not touched. A / in the replacement moves the file, and folders that are not there are made. If a rename would lose a file, no file is renamed. [boolean] | +| `-r` | **`--rename`** Search and replace in the path of each file, and rename the file to the result. The content is not touched. A / in the replacement moves the file, and folders that are not there are made. If planned renames clash, no file is renamed. [boolean] | | | **`--rename-force`** With --rename, 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. 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] | | `-T` | **`--trim-pipe`** Trim piped data before processing. If piped data only consists of chars that can be trimmed (new line, space, tabs...) it will become an empty string. [boolean] | @@ -297,7 +297,7 @@ Files identified for potential processing are identified by: - Data can be piped in, both as the text to search and as the replacement - The names of the changed files are printed to stdout, so they can be piped on (to stderr with `--output` and `--output-match`) - Set the delimiter between names and between the content of files -- Rename and move files with a search and replace on their path (`--rename`). A rename that would lose a file stops the run before any file is renamed, and `--simulate` shows each old name next to its new one +- Rename and move files with a search and replace on their path (`--rename`). If planned renames clash, the run stops before any file is renamed, and `--simulate` shows each old name next to its new one - See the [release note](https://github.com/mathiasrw/rexreplace/releases) for a log of changes. Descriptions are given in latest patch version. ### Quirks diff --git a/src/cli.ts b/src/cli.ts index 884d3c7a..fb2d0dc7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -250,7 +250,7 @@ const yargs = require('yargs') .boolean('r') .describe( 'r', - 'Search and replace in the path of each file, and rename the file to the result. The content is not touched. A / in the replacement moves the file, and folders that are not there are made. If a rename would lose a file, no file is renamed.' + 'Search and replace in the path of each file, and rename the file to the result. The content is not touched. A / in the replacement moves the file, and folders that are not there are made. If planned renames clash, no file is renamed.' ) .alias('r', 'rename') diff --git a/src/engine.ts b/src/engine.ts index 501050c1..9836aaa6 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -333,7 +333,7 @@ function nameRename(item, config) { info(item.new, process.stdout); } -// A rename must not lose a file, so any clash stops the run before a file is renamed. +// If planned renames clash, the run stops before any file is renamed. // The paths are compared as absolute paths, as two relative paths can lead to the same file. function findClashes(renames, config) { const byTarget = new Map(); @@ -365,7 +365,7 @@ function findClashes(renames, config) { if (clashes.length) { clashes.forEach(clash => error(clash)); - panic('No file is renamed, as a rename would lose a file'); + panic('No file is renamed, as the planned renames clash'); } } diff --git a/test/cli/run.sh b/test/cli/run.sh index c6861845..a7f4d151 100644 --- a/test/cli/run.sh +++ b/test/cli/run.sh @@ -246,7 +246,7 @@ reset assert "rexreplace '^my' 'our' my.file -r --simulate 2>/dev/null" 'our.file' assert "rexreplace '^my' 'our' my.file -r --simulate 2>&1 >/dev/null" 'my.file' -# A rename that would lose a file stops the run before any file is renamed +# If two planned renames clash, the run stops before any file is renamed reset assert_raises "rexreplace '^(my|your)' 'our' my.file your.file -r" 3 assert "cat my.file" 'foobar' diff --git a/test/engine/rename.test.js b/test/engine/rename.test.js index 2147335a..d7b77b26 100644 --- a/test/engine/rename.test.js +++ b/test/engine/rename.test.js @@ -103,9 +103,9 @@ describe('--rename with --keep-backup', () => { }); }); -describe('--rename stops the run before any file is renamed, when a rename would lose a file', () => { +describe('--rename stops the run before any file is renamed, when planned renames clash', () => { const stopped = - ' ❌ No file is renamed, as a rename would lose a file\n No further processing.\n\n'; + ' ❌ No file is renamed, as the planned renames clash\n No further processing.\n\n'; test('when two files would get the same new path, also given in two ways', async () => { fs.writeFileSync('a.md', 'foo'); From 260d8bb7c5a5c206b6fb1d7a368b07c9a73a06ed Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 15:18:48 +1100 Subject: [PATCH 10/27] Test the same new path written in two ways on its own --- test/engine/rename.test.js | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/test/engine/rename.test.js b/test/engine/rename.test.js index d7b77b26..9bc5c907 100644 --- a/test/engine/rename.test.js +++ b/test/engine/rename.test.js @@ -107,18 +107,27 @@ describe('--rename stops the run before any file is renamed, when planned rename const stopped = ' ❌ No file is renamed, as the planned renames clash\n No further processing.\n\n'; - test('when two files would get the same new path, also given in two ways', async () => { + test('when two files would get the same new path', async () => { fs.writeFileSync('a.md', 'foo'); fs.writeFileSync('b.md', 'foo'); - const config = {includeGlob: ['a.md', './b.md'], rename: true}; - expect(await rr('^(\\./)?[ab]', 'c', config)).toEqual({ + expect(await rr('^[ab]', 'c', {includeGlob: ['*.md'], rename: true})).toEqual({ out: '', - err: ' ❌ ./b.md and a.md would both be renamed to c.md\n\n' + stopped, + err: ' ❌ a.md and b.md would both be renamed to c.md\n\n' + stopped, exit: 3, }); expect(files()).toEqual(['a.md', 'b.md']); }); + test('when the same new path is written in two ways', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'foo'); + const config = {includeGlob: ['a.md', './b.md'], rename: true}; + expect((await rr('[ab]\\.md$', 'c.md', config)).err).toBe( + ' ❌ ./b.md and a.md would both be renamed to ./c.md\n\n' + stopped + ); + expect(files()).toEqual(['a.md', 'b.md']); + }); + test('when two files would swap names', async () => { fs.writeFileSync('a.md', 'foo'); fs.writeFileSync('b.md', 'bar'); From d57bd96cb59041badcdc90ef12d98d17cc4dfcea Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 15:18:51 +1100 Subject: [PATCH 11/27] Add --expect-files, --expect-diff, --expect-zero-diff and --expect-diff-all --- src/cli.ts | 25 ++++++++++--- src/engine.ts | 55 ++++++++++++++++++++++++++- src/output.ts | 6 +++ test/engine/expect-diff-all.test.js | 37 ++++++++++++++++++ test/engine/expect-diff.test.js | 56 ++++++++++++++++++++++++++++ test/engine/expect-files.test.js | 42 +++++++++++++++++++++ test/engine/expect-zero-diff.test.js | 56 ++++++++++++++++++++++++++++ test/helper.js | 4 +- 8 files changed, 274 insertions(+), 7 deletions(-) create mode 100644 test/engine/expect-diff-all.test.js create mode 100644 test/engine/expect-diff.test.js create mode 100644 test/engine/expect-files.test.js create mode 100644 test/engine/expect-zero-diff.test.js diff --git a/src/cli.ts b/src/cli.ts index fb2d0dc7..95aa3de5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -247,6 +247,23 @@ const yargs = require('yargs') ) .alias('S', 'simulate') + // Checks for a CI step. When one is not met, the exit code is 1. + .boolean('F') + .describe('F', 'Check that at least one file is found. The exit code is 1 if not.') + .alias('F', 'expect-files') + + .boolean('z') + .describe('z', 'Check that at least one file is changed. The exit code is 1 if not.') + .alias('z', 'expect-diff') + + .boolean('Z') + .describe('Z', 'Check that no file is changed. The exit code is 1 if one is.') + .alias('Z', 'expect-zero-diff') + + .boolean('expect-diff-all') + .describe('expect-diff-all', 'Check that all files found are changed. The exit code is 1 if not.') + .conflicts('Z', ['z', 'expect-diff-all']) + .boolean('r') .describe( 'r', @@ -363,10 +380,6 @@ All variables, except from module, date objects, \`nl\` and \`_\`, has a corresp `Normally . `+ '' ) - - - -E (Expect there to be no match and return exit 1 if found) - -e (Expect there to be batch and return exit 1 if not found) */ /* .boolean('P') @@ -577,13 +590,15 @@ if (config.contentDelimiter) { } // The exit code is only set, so the process ends by itself when all output is written. -// 0: done, 2: an error in a file and the run went on, 3: a panic (the run stopped) +// 0: done, 1: a check that was not met, 2: an error in a file and the run went on, 3: a panic (the run stopped) const run = () => rexreplace.engine(config).then( status => { printStatus(config, status); if (status.errors.length) { process.exitCode = 2; + } else if (status.checks?.length) { + process.exitCode = 1; } }, e => exitOnPanic(config, e) diff --git a/src/engine.ts b/src/engine.ts index 9836aaa6..84a56399 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -8,7 +8,17 @@ const globs = require('fast-glob'); const now = new Date(); -import {outputConfig, chat, info, print, printDone, warn, error, panic} from './output.ts'; +import { + outputConfig, + chat, + info, + print, + printDone, + warn, + error, + panic, + checkFailed, +} from './output.ts'; import './shim.ts'; @@ -43,6 +53,7 @@ export async function engine(config: any = {engine: 'default'}) { outputConfig(config); try { await run(config); + checkExpectations(config); } catch (e) { if (e?.panic) { e.status = config.statusInfo; @@ -62,6 +73,11 @@ function newStatus(config) { status.renames = []; } + // The checks from --expect-* that are not met + if (config.expectFiles || config.expectDiff || config.expectZeroDiff || config.expectDiffAll) { + status.checks = []; + } + // The status report with --verbose also tells what went into the engine and what it did. // The config is copied before it gets the status, and the piped data is told about in `pipe`. if (config.verbose && (config.status || config.statusCompact)) { @@ -84,6 +100,10 @@ function newStatus(config) { async function run(config) { validateEncoding(config.encoding); + if (config.expectZeroDiff && (config.expectDiff || config.expectDiffAll)) { + panic('--expect-zero-diff can not be used with --expect-diff or --expect-diff-all'); + } + // With --glob-pipe each line of the piped data is a path or a glob, used together with the ones given as arguments if (config.globPipe) { if (config.replacementPipe || config.pipe) { @@ -409,6 +429,39 @@ function renameFile(item, config) { info(item.new); } +// The checks from --expect-* are done when the run is done. Piped data counts as one file. +function checkExpectations(config) { + const status = config.statusInfo; + if (!status.checks) { + return; + } + const piped = Boolean(config.dataIsPiped); + const found = piped ? 1 : status.files.searched.length; + const changed = piped ? Number(status.pipe.updated) : status.files.updated.length; + // Nothing is written with --simulate, --output and --output-match + const would = !piped && (config.simulate || config.output || config.outputMatch); + const files = count => (piped ? 'the piped data' : count + (1 === count ? ' file' : ' files')); + const change = count => + would ? 'would change' : 1 === count || piped ? 'was changed' : 'were changed'; + + if (config.expectFiles && !found) { + checkFailed('Expected files, but 0 files found'); + } + if ((config.expectDiff || config.expectDiffAll) && !changed) { + checkFailed('Expected a diff, but nothing ' + (would ? 'would change' : 'was changed')); + } else if (config.expectDiffAll && changed < found) { + const unchanged = found - changed; + checkFailed( + 'Expected all files to change, but ' + + files(unchanged) + + (would ? ' would not change' : 1 === unchanged ? ' was not changed' : ' were not changed') + ); + } + if (config.expectZeroDiff && changed) { + checkFailed('Expected no diff, but ' + files(changed) + ' ' + change(changed)); + } +} + // How the piped data was used function pipeType(config, piped) { if (config.globPipe) { diff --git a/src/output.ts b/src/output.ts index ff418041..82c903d9 100644 --- a/src/output.ts +++ b/src/output.ts @@ -115,6 +115,12 @@ export const panic = function (msg = '', data = '', displayHelp = false) { throw new Panic(text); }; +// A check from --expect-* that is not met. It is told like an error, but goes into `checks` in the status report. +export const checkFailed = function (text) { + config.statusInfo?.checks.push(text); + printError(text, false); +}; + // Something failed, and the run goes on. With --halt it is a panic. export const error = function (msg, data = '') { const text = msg + (data ? ' ' + data : ''); diff --git a/test/engine/expect-diff-all.test.js b/test/engine/expect-diff-all.test.js new file mode 100644 index 00000000..13ec3562 --- /dev/null +++ b/test/engine/expect-diff-all.test.js @@ -0,0 +1,37 @@ +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {rr} from '../helper.js'; + +describe('--expect-diff-all', () => { + test('is met when all files found are changed', async () => { + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'boo'); + expect((await rr('o', 'x', {includeGlob: ['*.file'], expectDiffAll: true})).exit).toBe(null); + }); + + test('is not met when a file is not changed', async () => { + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'baz'); + expect(await rr('o', 'x', {includeGlob: ['*.file'], expectDiffAll: true})).toEqual({ + out: 'a.file\n', + err: ' ❌ Expected all files to change, but 2 files were not changed\n\n', + exit: 1, + }); + }); + + test('is not met when no file is found', async () => { + const result = await rr('o', 'x', {includeGlob: ['nope.file'], expectDiffAll: true}); + expect(result.err).toContain(' ❌ Expected a diff, but nothing was changed'); + expect(result.exit).toBe(1); + }); + + test('says would with --simulate', async () => { + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + const config = {includeGlob: ['*.file'], expectDiffAll: true, simulate: true}; + expect((await rr('o', 'x', config)).err).toBe( + ' ❌ Expected all files to change, but 1 file would not change\n\n' + ); + }); +}); diff --git a/test/engine/expect-diff.test.js b/test/engine/expect-diff.test.js new file mode 100644 index 00000000..343d116d --- /dev/null +++ b/test/engine/expect-diff.test.js @@ -0,0 +1,56 @@ +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {rr} from '../helper.js'; + +describe('--expect-diff', () => { + test('is met when a file is changed', async () => { + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + expect(await rr('o', 'x', {includeGlob: ['*.file'], expectDiff: true})).toEqual({ + out: 'a.file\n', + err: '', + exit: null, + }); + }); + + test('is not met when nothing is changed', async () => { + fs.writeFileSync('my.file', 'foobar'); + expect(await rr('nope', 'x', {includeGlob: ['my.file'], expectDiff: true})).toEqual({ + out: '', + err: ' ❌ Expected a diff, but nothing was changed\n\n', + exit: 1, + }); + }); + + test('is not met when no file is found', async () => { + expect((await rr('o', 'x', {includeGlob: ['nope.file'], expectDiff: true})).exit).toBe(1); + }); + + test('says would with --simulate', async () => { + fs.writeFileSync('my.file', 'foobar'); + const config = {includeGlob: ['my.file'], expectDiff: true, simulate: true}; + expect((await rr('nope', 'x', config)).err).toBe( + ' ❌ Expected a diff, but nothing would change\n\n' + ); + }); + + test('a match that gives the same text is not a diff', async () => { + fs.writeFileSync('my.file', 'foobar'); + expect((await rr('o', 'o', {includeGlob: ['my.file'], expectDiff: true})).exit).toBe(1); + }); + + test('piped data counts as one file', async () => { + expect(await rr('o', 'x', {pipedData: 'foo', expectDiff: true})).toEqual({ + out: 'fxx', + err: '', + exit: null, + }); + expect((await rr('z', 'x', {pipedData: 'foo', expectDiff: true})).exit).toBe(1); + }); + + test('a rename is a diff', async () => { + fs.writeFileSync('a.md', 'foo'); + const config = {includeGlob: ['a.md'], rename: true, expectDiff: true}; + expect((await rr('md', 'txt', config)).exit).toBe(null); + }); +}); diff --git a/test/engine/expect-files.test.js b/test/engine/expect-files.test.js new file mode 100644 index 00000000..6fd00d1e --- /dev/null +++ b/test/engine/expect-files.test.js @@ -0,0 +1,42 @@ +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {rr, rrStatus} from '../helper.js'; + +describe('--expect-files', () => { + test('is met when a file is found, also when nothing is changed', async () => { + fs.writeFileSync('my.file', 'foobar'); + expect(await rr('nope', 'x', {includeGlob: ['my.file'], expectFiles: true})).toEqual({ + out: '', + err: '', + exit: null, + }); + }); + + test('is not met when no file is found, so the warning becomes a failed check', async () => { + expect(await rr('o', 'x', {includeGlob: ['nope.file'], expectFiles: true})).toEqual({ + out: '', + err: ' 🟡 0 files found\n\n ❌ Expected files, but 0 files found\n\n', + exit: 1, + }); + }); + + test('is told in `checks` in the status report, and not in `errors`', async () => { + const status = await rrStatus('o', 'x', {includeGlob: ['nope.file'], expectFiles: true}); + expect(status.checks).toEqual(['Expected files, but 0 files found']); + expect(status.errors).toEqual([]); + }); + + test('is still shown with --quiet, but not with --quiet-total', async () => { + const config = {includeGlob: ['nope.file'], expectFiles: true}; + expect((await rr('o', 'x', {...config, quiet: true})).err).toBe( + ' ❌ Expected files, but 0 files found\n\n' + ); + expect(await rr('o', 'x', {...config, quietTotal: true})).toEqual({out: '', err: '', exit: 1}); + }); + + test('an error in a file gives the exit code 2, also when a check is not met', async () => { + fs.writeFileSync('a.file', 'foo'); + fs.chmodSync('a.file', 0); + expect((await rr('o', 'x', {includeGlob: ['a.file'], expectDiff: true})).exit).toBe(2); + }); +}); diff --git a/test/engine/expect-zero-diff.test.js b/test/engine/expect-zero-diff.test.js new file mode 100644 index 00000000..0a82e2eb --- /dev/null +++ b/test/engine/expect-zero-diff.test.js @@ -0,0 +1,56 @@ +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {rr} from '../helper.js'; + +describe('--expect-zero-diff', () => { + test('is met when nothing is changed, also when no file is found', async () => { + fs.writeFileSync('my.file', 'foobar'); + expect(await rr('nope', 'x', {includeGlob: ['my.file'], expectZeroDiff: true})).toEqual({ + out: '', + err: '', + exit: null, + }); + expect((await rr('o', 'x', {includeGlob: ['nope.file'], expectZeroDiff: true})).exit).toBe( + null + ); + }); + + test('is not met when a file is changed, and the file is still changed', async () => { + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + expect(await rr('o', 'x', {includeGlob: ['*.file'], expectZeroDiff: true})).toEqual({ + out: 'a.file\nb.file\n', + err: ' ❌ Expected no diff, but 2 files were changed\n\n', + exit: 1, + }); + expect(fs.readFileSync('a.file', 'utf8')).toBe('fxx'); + }); + + test('with --simulate only checks', async () => { + fs.writeFileSync('my.file', 'foobar'); + const config = {includeGlob: ['my.file'], expectZeroDiff: true, simulate: true}; + expect(await rr('o', 'x', config)).toEqual({ + out: 'my.file\n', + err: ' ❌ Expected no diff, but 1 file would change\n\n', + exit: 1, + }); + expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar'); + }); + + test('tells about piped data', async () => { + expect((await rr('o', 'x', {pipedData: 'foo', expectZeroDiff: true})).err).toBe( + ' ❌ Expected no diff, but the piped data was changed\n\n' + ); + }); + + test.each([['expectDiff'], ['expectDiffAll']])('can not be used with --%s', async flag => { + fs.writeFileSync('my.file', 'foobar'); + const result = await rr('o', 'x', { + includeGlob: ['my.file'], + expectZeroDiff: true, + [flag]: true, + }); + expect(result.exit).toBe(3); + expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar'); + }); +}); diff --git a/test/helper.js b/test/helper.js index 2b932fb0..eecee799 100644 --- a/test/helper.js +++ b/test/helper.js @@ -69,9 +69,11 @@ async function run(pattern, replacement, config) { }; alias(options); status = await engine(options); - // Like the CLI, an error gives the exit code 2 when the run is done + // Like the CLI, an error gives the exit code 2 and a check that is not met 1, when the run is done if (status.errors.length) { result.exit = 2; + } else if (status.checks?.length) { + result.exit = 1; } } catch (e) { // Like the CLI, a panic stops the run with the exit code 3 From 821fa6842ff4da8900e8d4c95357dfb12e20a1db Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 15:20:19 +1100 Subject: [PATCH 12/27] Show the --expect-* flags in the demo and the README --- README.md | 10 ++++++++-- test/cli/expect.test.js | 27 +++++++++++++++++++++++++++ test/cli/run.sh | 9 +++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 test/cli/expect.test.js diff --git a/README.md b/README.md index 7a2ba742..d8d74948 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,10 @@ Use --js-pipe to access the pipe data in your javascript code. | `-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`** Do everything but write the files. The names of the files that would be changed are printed as usual, and no backup is made. [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] | +| `-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 path of each file, and rename the file to the result. The content is not touched. A / in the replacement moves the file, and folders that are not there are made. If planned renames clash, no file is renamed. [boolean] | | | **`--rename-force`** With --rename, 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. 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] | @@ -297,6 +301,7 @@ Files identified for potential processing are identified by: - Data can be piped in, both as the text to search and as the replacement - The names of the changed files are printed to stdout, so they can be piped on (to stderr with `--output` and `--output-match`) - Set the delimiter between names and between the content of files +- Check the result in a CI step with `--expect-files`, `--expect-diff`, `--expect-zero-diff` and `--expect-diff-all`. Add `--simulate` to only check - Rename and move files with a search and replace on their path (`--rename`). If planned renames clash, the run stops before any file is renamed, and `--simulate` shows each old name next to its new one - See the [release note](https://github.com/mathiasrw/rexreplace/releases) for a log of changes. Descriptions are given in latest patch version. @@ -323,10 +328,11 @@ Files identified for potential processing are identified by: | Code | Meaning | | --- | --- | | 0 | Done. Warnings (🟡) are ok. | +| 1 | A check from `--expect-*` was not met. | | 2 | One or more files gave an error (❌), and the run went on. | -| 3 | The run stopped (❌ … `No further processing.`): a bad pattern, an unknown encoding, wrong flags, or any error with `--halt`. | +| 3 | The run stopped (❌ … `No further processing.`): a bad pattern, an unknown encoding, wrong flags, planned renames that clash, or any error with `--halt`. | -Code 1 is kept for a check that says no, like `grep` does when nothing is found. +Like `grep` and `diff`, code 1 means that RexReplace did its job and the answer is no, and 2 or 3 means that something went wrong. ### Limitations diff --git a/test/cli/expect.test.js b/test/cli/expect.test.js new file mode 100644 index 00000000..d445bb43 --- /dev/null +++ b/test/cli/expect.test.js @@ -0,0 +1,27 @@ +// --expect-* checks the result, so a CI step can stop when it is not as expected +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {$} from 'bun'; +import {cli, sh} from '../helper.js'; + +describe('--expect-*', () => { + test('gives the exit code 1 when a check is not met', async () => { + fs.writeFileSync('my.file', 'foobar'); + expect(await sh($`bun ${cli} o x my.file -Z -S`)).toEqual({ + out: 'my.file\n', + err: ' ❌ Expected no diff, but 1 file would change\n\n', + exit: 1, + }); + expect((await sh($`bun ${cli} nope x my.file -z`)).exit).toBe(1); + expect((await sh($`bun ${cli} o x nope.file -F`)).exit).toBe(1); + }); + + test.each([['-z'], ['--expect-diff-all']])( + '--expect-zero-diff can not be used with %s', + async flag => { + fs.writeFileSync('my.file', 'foobar'); + expect((await sh($`bun ${cli} o x my.file -Z ${flag}`)).exit).toBe(3); + expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar'); + } + ); +}); diff --git a/test/cli/run.sh b/test/cli/run.sh index a7f4d151..aff97065 100644 --- a/test/cli/run.sh +++ b/test/cli/run.sh @@ -252,6 +252,15 @@ assert_raises "rexreplace '^(my|your)' 'our' my.file your.file -r" 3 assert "cat my.file" 'foobar' +# --expect-* checks the result, so a CI step can stop. When a check is not met, the exit code is 1. +reset +assert_raises "rexreplace 'oo' 'x' my.file --expect-zero-diff --simulate" 1 +assert "cat my.file" 'foobar' +assert_raises "rexreplace 'nope' 'x' my.file --expect-diff" 1 +assert_raises "rexreplace 'oo' 'x' my.file -z" 0 +assert_raises "rexreplace 'oo' 'x' nope.file --expect-files" 1 + + # --full-path prints the full path of each name reset assert "rexreplace 'b' 'x' my.file --full-path" "$(pwd -P)/my.file" From 242e91223d6b59062fc22f25fb4ac9be176a3b1a Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 15:29:53 +1100 Subject: [PATCH 13/27] Say the process is halted and no change is applied when planned renames clash --- README.md | 4 ++-- src/cli.ts | 2 +- src/engine.ts | 4 ++-- test/engine/rename.test.js | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7a2ba742..2246cf9a 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,7 @@ Use --js-pipe to access the pipe data in your javascript code. | `-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`** Do everything but write the files. The names of the files that would be changed are printed as usual, and no backup is made. [boolean] | -| `-r` | **`--rename`** Search and replace in the path of each file, and rename the file to the result. The content is not touched. A / in the replacement moves the file, and folders that are not there are made. If planned renames clash, no file is renamed. [boolean] | +| `-r` | **`--rename`** Search and replace in the path of each file, and rename the file to the result. The content is not touched. A / in the replacement moves the file, and folders that are not there are made. If planned renames clash the process is halted and no change is applied to any file. [boolean] | | | **`--rename-force`** With --rename, 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. 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] | | `-T` | **`--trim-pipe`** Trim piped data before processing. If piped data only consists of chars that can be trimmed (new line, space, tabs...) it will become an empty string. [boolean] | @@ -297,7 +297,7 @@ Files identified for potential processing are identified by: - Data can be piped in, both as the text to search and as the replacement - The names of the changed files are printed to stdout, so they can be piped on (to stderr with `--output` and `--output-match`) - Set the delimiter between names and between the content of files -- Rename and move files with a search and replace on their path (`--rename`). If planned renames clash, the run stops before any file is renamed, and `--simulate` shows each old name next to its new one +- Rename and move files with a search and replace on their path (`--rename`). If planned renames clash the process is halted and no change is applied to any file, and `--simulate` shows each old name next to its new one - See the [release note](https://github.com/mathiasrw/rexreplace/releases) for a log of changes. Descriptions are given in latest patch version. ### Quirks diff --git a/src/cli.ts b/src/cli.ts index fb2d0dc7..97393036 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -250,7 +250,7 @@ const yargs = require('yargs') .boolean('r') .describe( 'r', - 'Search and replace in the path of each file, and rename the file to the result. The content is not touched. A / in the replacement moves the file, and folders that are not there are made. If planned renames clash, no file is renamed.' + 'Search and replace in the path of each file, and rename the file to the result. The content is not touched. A / in the replacement moves the file, and folders that are not there are made. If planned renames clash the process is halted and no change is applied to any file.' ) .alias('r', 'rename') diff --git a/src/engine.ts b/src/engine.ts index 9836aaa6..eccaea43 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -333,7 +333,7 @@ function nameRename(item, config) { info(item.new, process.stdout); } -// If planned renames clash, the run stops before any file is renamed. +// If planned renames clash the process is halted and no change is applied to any file. // The paths are compared as absolute paths, as two relative paths can lead to the same file. function findClashes(renames, config) { const byTarget = new Map(); @@ -365,7 +365,7 @@ function findClashes(renames, config) { if (clashes.length) { clashes.forEach(clash => error(clash)); - panic('No file is renamed, as the planned renames clash'); + panic('Planned renames clash, so no change is applied to any file'); } } diff --git a/test/engine/rename.test.js b/test/engine/rename.test.js index 9bc5c907..28449e77 100644 --- a/test/engine/rename.test.js +++ b/test/engine/rename.test.js @@ -105,7 +105,7 @@ describe('--rename with --keep-backup', () => { describe('--rename stops the run before any file is renamed, when planned renames clash', () => { const stopped = - ' ❌ No file is renamed, as the planned renames clash\n No further processing.\n\n'; + ' ❌ Planned renames clash, so no change is applied to any file\n No further processing.\n\n'; test('when two files would get the same new path', async () => { fs.writeFileSync('a.md', 'foo'); From 5889989cff55dbb4ef581906ceb0ae652fa57999 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 15:37:39 +1100 Subject: [PATCH 14/27] Fix --rename clashes with files in the run that keep their name, and with names that differ only in case --- src/engine.ts | 25 +++++++++++++++---------- test/engine/rename-force.test.js | 16 ++++++++++++++++ test/engine/rename.test.js | 27 +++++++++++++++++++++++++-- 3 files changed, 56 insertions(+), 12 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index eccaea43..4c1ac180 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -309,7 +309,7 @@ function renameFiles(files, config) { ); } - findClashes(renames, config); + findClashes(plan, renames, config); plan.forEach(item => { if (item.new === item.old) { @@ -334,17 +334,21 @@ function nameRename(item, config) { } // If planned renames clash the process is halted and no change is applied to any file. -// The paths are compared as absolute paths, as two relative paths can lead to the same file. -function findClashes(renames, config) { +// The paths are compared as absolute paths, as two relative paths can lead to the same file, +// and the case is ignored, as `A.md` and `a.md` are the same file on a file system that is case-insensitive. +const pathKey = file => path.resolve(file).toLowerCase(); + +function findClashes(plan, renames, config) { const byTarget = new Map(); renames.forEach(item => { - const target = path.resolve(item.new); + const target = pathKey(item.new); byTarget.set(target, (byTarget.get(target) || []).concat(item)); }); - const sources = new Set(renames.map(item => path.resolve(item.old))); + // All files in the run, also the ones that keep their name + const inRun = new Set(plan.map(item => pathKey(item.old))); const targets = new Set(byTarget.keys()); - const ontoRenamed = targets.intersection(sources); - const ontoOthers = targets.difference(sources); + const ontoRun = targets.intersection(inRun); + const ontoOthers = targets.difference(inRun); const clashes = []; byTarget.forEach((items, target) => { @@ -356,9 +360,10 @@ function findClashes(renames, config) { ' be renamed to ' + item.new ); - } else if (ontoRenamed.has(target)) { - clashes.push(item.old + ' would be renamed to ' + item.new + ', which is also renamed'); - } else if (ontoOthers.has(target) && !config.renameForce && isOtherFile(item.old, target)) { + } else if (ontoRun.has(target) && pathKey(item.old) !== target) { + // Not even --rename-force writes over a file in the run. A file can still change the case of its own name. + clashes.push(item.old + ' would be renamed to ' + item.new + ', which is a file in this run'); + } else if (ontoOthers.has(target) && !config.renameForce && isOtherFile(item.old, item.new)) { clashes.push(item.old + ' would be renamed to ' + item.new + ', which is there already'); } }); diff --git a/test/engine/rename-force.test.js b/test/engine/rename-force.test.js index 2cd09317..2fdb4f80 100644 --- a/test/engine/rename-force.test.js +++ b/test/engine/rename-force.test.js @@ -22,6 +22,22 @@ describe('--rename-force', () => { expect(fs.readFileSync('a.md', 'utf8')).toBe('foo'); }); + test('does not write over a file in the run that keeps its name', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.txt', 'bar'); + const config = {includeGlob: ['a.md', 'b.txt'], rename: true, renameForce: true}; + expect((await rr('^a\\.md$', 'b.txt', config)).exit).toBe(3); + expect(fs.readFileSync('b.txt', 'utf8')).toBe('bar'); + }); + + test('does not let two new paths differ only in case', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'bar'); + const config = {includeGlob: ['*.md'], rename: true, renameForce: true, replacementJs: true}; + expect((await rr('^[ab]', '$0 == "a" ? "X" : "x"', config)).exit).toBe(3); + expect(fs.readdirSync('.').sort()).toEqual(['a.md', 'b.md']); + }); + test('does not let two files get the same new path', async () => { fs.writeFileSync('a.md', 'foo'); fs.writeFileSync('b.md', 'bar'); diff --git a/test/engine/rename.test.js b/test/engine/rename.test.js index 28449e77..4a48d824 100644 --- a/test/engine/rename.test.js +++ b/test/engine/rename.test.js @@ -135,14 +135,37 @@ describe('--rename stops the run before any file is renamed, when planned rename expect(await rr('^[ab]', '$0 == "a" ? "b" : "a"', config)).toEqual({ out: '', err: - ' ❌ a.md would be renamed to b.md, which is also renamed\n\n' + - ' ❌ b.md would be renamed to a.md, which is also renamed\n\n' + + ' ❌ a.md would be renamed to b.md, which is a file in this run\n\n' + + ' ❌ b.md would be renamed to a.md, which is a file in this run\n\n' + stopped, exit: 3, }); expect(fs.readFileSync('a.md', 'utf8')).toBe('foo'); }); + test('when the new path is a file in the run that keeps its name', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.txt', 'bar'); + expect(await rr('^a\\.md$', 'b.txt', {includeGlob: ['a.md', 'b.txt'], rename: true})).toEqual({ + out: '', + err: ' ❌ a.md would be renamed to b.txt, which is a file in this run\n\n' + stopped, + exit: 3, + }); + expect(fs.readFileSync('b.txt', 'utf8')).toBe('bar'); + }); + + test('when two new paths differ only in case, as they are the same file on some file systems', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'bar'); + const config = {includeGlob: ['*.md'], rename: true, replacementJs: true}; + expect(await rr('^[ab]', '$0 == "a" ? "X" : "x"', config)).toEqual({ + out: '', + err: ' ❌ a.md and b.md would both be renamed to X.md\n\n' + stopped, + exit: 3, + }); + expect(files()).toEqual(['a.md', 'b.md']); + }); + test('when the new path is a file already, and other files are fine', async () => { fs.writeFileSync('a.md', 'foo'); fs.writeFileSync('a.txt', 'bar'); From 8442cf250108edf36628e43f995271aca239dac8 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 15:39:38 +1100 Subject: [PATCH 15/27] Move the tests for issues to test/fix, one file for each issue --- README.md | 9 +++--- devguide.md | 2 +- package.json | 2 +- test/cli/fix/742.test.js | 50 ------------------------------- test/{engine => }/fix/681.test.js | 2 +- test/{engine => }/fix/682.test.js | 2 +- test/{engine => }/fix/683.test.js | 2 +- test/{cli => }/fix/691.test.js | 2 +- test/{engine => }/fix/699.test.js | 2 +- test/{cli => }/fix/700.test.js | 2 +- test/{engine => }/fix/703.test.js | 2 +- test/{engine => }/fix/705.test.js | 4 +-- test/{engine => }/fix/708.test.js | 2 +- test/{engine => }/fix/710.test.js | 2 +- test/{engine => }/fix/711.test.js | 2 +- test/{engine => }/fix/716.test.js | 2 +- test/{engine => }/fix/718.test.js | 2 +- test/{engine => }/fix/719.test.js | 2 +- test/{engine => }/fix/720.test.js | 2 +- test/{engine => }/fix/726.test.js | 2 +- test/{engine => }/fix/728.test.js | 2 +- test/{cli => }/fix/733.test.js | 2 +- test/{cli => }/fix/736.test.js | 2 +- test/{engine => }/fix/742.test.js | 49 ++++++++++++++++++++++++++++-- 24 files changed, 74 insertions(+), 78 deletions(-) delete mode 100644 test/cli/fix/742.test.js rename test/{engine => }/fix/681.test.js (96%) rename test/{engine => }/fix/682.test.js (97%) rename test/{engine => }/fix/683.test.js (98%) rename test/{cli => }/fix/691.test.js (98%) rename test/{engine => }/fix/699.test.js (99%) rename test/{cli => }/fix/700.test.js (98%) rename test/{engine => }/fix/703.test.js (99%) rename test/{engine => }/fix/705.test.js (97%) rename test/{engine => }/fix/708.test.js (98%) rename test/{engine => }/fix/710.test.js (98%) rename test/{engine => }/fix/711.test.js (98%) rename test/{engine => }/fix/716.test.js (97%) rename test/{engine => }/fix/718.test.js (99%) rename test/{engine => }/fix/719.test.js (98%) rename test/{engine => }/fix/720.test.js (98%) rename test/{engine => }/fix/726.test.js (99%) rename test/{engine => }/fix/728.test.js (98%) rename test/{cli => }/fix/733.test.js (93%) rename test/{cli => }/fix/736.test.js (96%) rename test/{engine => }/fix/742.test.js (79%) diff --git a/README.md b/README.md index b86ef4e4..56ad926a 100644 --- a/README.md +++ b/README.md @@ -345,18 +345,19 @@ rr x y myfile -o > /dev/null 0,21s user 0,04s system 86% cpu 0,294 total ### Regression -The tests come in four groups: +The tests come in five groups: - **The demo** in [test/cli/run.sh](https://github.com/mathiasrw/rexreplace/blob/main/test/cli/run.sh) shows how RexReplace is used, with a few examples of each feature. It is run with the installed commands: `rexreplace` with Node and `rexreplace-bun` with Bun, by the scripts in [test/runtime](https://github.com/mathiasrw/rexreplace/tree/main/test/runtime). You must have both. - **CLI tests** in [`test/cli/*.test.js`](https://github.com/mathiasrw/rexreplace/tree/main/test/cli) run the CLI with Bun's shell, and check stdout, stderr and the exit code. This is where combinations of flags are checked. - **Engine tests** in [`test/engine/*.test.js`](https://github.com/mathiasrw/rexreplace/tree/main/test/engine) call the engine directly (no build and no CLI involved), so they are fast enough to run while developing. There is one file per flag, named after the long version of the flag. +- **Fix tests** in [`test/fix/.test.js`](https://github.com/mathiasrw/rexreplace/tree/main/test/fix) test that an issue stays fixed, with the engine, the CLI or both. - **Fuzzy tests** in [`test/fuzzy/*.test.js`](https://github.com/mathiasrw/rexreplace/tree/main/test/fuzzy) feed the engine random input and compare the result with what plain javascript gives. -The build and the CLI, engine and fuzzy tests are run by [bun](https://bun.sh). After `git clone`'ing the repo and `yarn install`'ing you can invoke the tests with: +The build and the CLI, engine, fix and fuzzy tests are run by [bun](https://bun.sh). After `git clone`'ing the repo and `yarn install`'ing you can invoke the tests with: ```bash -> yarn test # build + demo + CLI and engine tests + fuzzy tests -> yarn test-js # only the CLI and engine tests +> yarn test # build + demo + CLI, engine and fix tests + fuzzy tests +> yarn test-js # only the CLI, engine and fix tests > yarn test-fuzzy # only the fuzzy tests > yarn test-cli # only the demo (build first) ``` diff --git a/devguide.md b/devguide.md index 73b3a8e5..69bdb9a7 100644 --- a/devguide.md +++ b/devguide.md @@ -37,7 +37,7 @@ All output goes through `src/output.ts`. Pick the function by who the message is - Expect the full result where it matters (`{out, err, exit}` from `rr()`), so nothing extra slips through. Use `rrStatus()` for the status of the same run. - A known bug is a `test.todo('BUG #: ...')` until it is fixed. Then it becomes a real test. - The folder tells what is tested, so a file is named after the feature: `test/engine/.test.js` tests the engine with `rr()`, and `test/cli/.test.js` tests the CLI by running it with Bun's `$` shell, like ``sh($`bun ${cli} o x my.file`)``. -- A test for an issue goes in `test/engine/fix/.test.js` or `test/cli/fix/.test.js`. +- A test for an issue goes in `test/fix/.test.js`. It tests the issue with whatever it takes: the engine with `rr()`, the CLI with `sh()`, or both. - `test/cli/run.sh` is a demo of how RexReplace is used: a few clear examples for each feature. Checks of many combinations go in `test/cli/.test.js`. - Handle files in tests with the sync functions of `fs`, like `fs.writeFileSync` and `fs.readFileSync`, so there is no `await` for files. Each test writes the files it uses itself, so you can see them next to the command. - Names in tests say what they hold, like `expectedOutput`, not `named`. diff --git a/package.json b/package.json index 98d5548f..e7c25b65 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "bundle-bun": "bun build src/cli.bun.ts --target=bun --format=cjs --packages=external", "prebuild": "rm -fr bin && yarn format", "sync-version": "bun npm/sync-version.js", - "test-js": "AGENT=1 bun test ./test/engine ./test/cli", + "test-js": "AGENT=1 bun test ./test/engine ./test/cli ./test/fix", "test-fuzzy": "AGENT=1 bun test ./test/fuzzy", "test-minify": "AGENT=1 yarn build-minify && yarn test-cli && yarn test-js", "test-cli": "yarn test-cli-node && yarn test-cli-bun", diff --git a/test/cli/fix/742.test.js b/test/cli/fix/742.test.js deleted file mode 100644 index 0ec96d7d..00000000 --- a/test/cli/fix/742.test.js +++ /dev/null @@ -1,50 +0,0 @@ -// https://github.com/mathiasrw/rexreplace/issues/742 -// The CLI is the only part that acts on the process. It prints the status report with --status and --status-compact. -import {describe, test, expect} from 'bun:test'; -import fs from 'fs'; -import {$} from 'bun'; -import {cli, sh} from '../../helper.js'; - -describe('#742 the CLI', () => { - test('--status prints a line break and the status report with an indent of two spaces after the result', async () => { - fs.writeFileSync('a.file', 'foo'); - const result = await sh($`bun ${cli} o x a.file --status`); - const status = { - files: {searched: ['a.file'], updated: ['a.file']}, - pipe: null, - warnings: [], - errors: [], - }; - expect(result).toEqual({ - out: 'a.file\n\n' + JSON.stringify(status, null, 2) + '\n', - err: '', - exit: 0, - }); - }); - - test.each(['--status-compact', '-c'])( - '%s prints the status report on one line, with no line break in front', - async flag => { - fs.writeFileSync('a.file', 'foo'); - const result = await sh($`bun ${cli} o x a.file ${flag} --quiet-total`); - expect(result.out).toBe( - '{"files":{"searched":["a.file"],"updated":["a.file"]},"pipe":null,"warnings":[],"errors":[]}\n' - ); - } - ); - - test('sets the exit code to 1 when a file gives an error, and prints the status report', async () => { - fs.writeFileSync('a.file', 'foo'); - fs.chmodSync('a.file', 0); - const result = await sh($`bun ${cli} o x a.file --status-compact --quiet-total`); - expect(JSON.parse(result.out).errors[0]).toContain('EACCES'); - expect(result.exit).toBe(1); - }); - - test('ends the process on a panic, and still prints the status report', async () => { - fs.writeFileSync('a.file', 'foo'); - const result = await sh($`bun ${cli} '(' x a.file --status-compact --quiet-total`); - expect(JSON.parse(result.out).errors).toHaveLength(1); - expect(result.exit).toBe(1); - }); -}); diff --git a/test/engine/fix/681.test.js b/test/fix/681.test.js similarity index 96% rename from test/engine/fix/681.test.js rename to test/fix/681.test.js index beef3631..8a07a794 100644 --- a/test/engine/fix/681.test.js +++ b/test/fix/681.test.js @@ -2,7 +2,7 @@ // `--halt` used the error message as exit code: Node 18 exited with 0, newer Node versions crashed with ERR_INVALID_ARG_TYPE. import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from '../../helper.js'; +import {rr} from '../helper.js'; describe('#681 --halt exits with a numeric non-zero code', () => { test('a file that can not be read', async () => { diff --git a/test/engine/fix/682.test.js b/test/fix/682.test.js similarity index 97% rename from test/engine/fix/682.test.js rename to test/fix/682.test.js index 73d23ecd..cd2f45b1 100644 --- a/test/engine/fix/682.test.js +++ b/test/fix/682.test.js @@ -2,7 +2,7 @@ // `--quiet` promises "Only display errors (no other info)" but used to hide errors too (making it behave like `--quiet-total`). import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from '../../helper.js'; +import {rr} from '../helper.js'; describe('#682 --quiet still displays errors', () => { test('invalid regular expression', async () => { diff --git a/test/engine/fix/683.test.js b/test/fix/683.test.js similarity index 98% rename from test/engine/fix/683.test.js rename to test/fix/683.test.js index 343c0f88..e73ab08c 100644 --- a/test/engine/fix/683.test.js +++ b/test/fix/683.test.js @@ -2,7 +2,7 @@ // `--exclude-re` promises to "follow same regex flags and setup as the main search" but was used as a regex with no flags (so it was always case sensitive). import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from '../../helper.js'; +import {rr} from '../helper.js'; describe('#683 --exclude-re follows the regex flags of the main search', () => { test('the example from the issue', async () => { diff --git a/test/cli/fix/691.test.js b/test/fix/691.test.js similarity index 98% rename from test/cli/fix/691.test.js rename to test/fix/691.test.js index ebf07034..75f818a1 100644 --- a/test/cli/fix/691.test.js +++ b/test/fix/691.test.js @@ -4,7 +4,7 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; import {$} from 'bun'; -import {cli, sh} from '../../helper.js'; +import {cli, sh} from '../helper.js'; // With --output the name of the file goes to stderr const expectedOutput = 'my.file\n'; diff --git a/test/engine/fix/699.test.js b/test/fix/699.test.js similarity index 99% rename from test/engine/fix/699.test.js rename to test/fix/699.test.js index 0446bb9b..7b3ac504 100644 --- a/test/engine/fix/699.test.js +++ b/test/fix/699.test.js @@ -3,7 +3,7 @@ // These tests pin that file globs, `--exclude-glob` and piped data are used exactly as they are. import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from '../../helper.js'; +import {rr} from '../helper.js'; const voided = {voidEuro: true, voidSection: true}; diff --git a/test/cli/fix/700.test.js b/test/fix/700.test.js similarity index 98% rename from test/cli/fix/700.test.js rename to test/fix/700.test.js index 2bb3fd3d..173d9050 100644 --- a/test/cli/fix/700.test.js +++ b/test/fix/700.test.js @@ -3,7 +3,7 @@ // The replacement is unescaped by the CLI (not the engine), so these tests run the CLI itself. import {describe, test, expect} from 'bun:test'; import {$} from 'bun'; -import {cli, sh} from '../../helper.js'; +import {cli, sh} from '../helper.js'; // Give `input` to the CLI as piped data and return what it gives back const run = (input, ...args) => sh($`bun ${cli} ${args} < ${new Response(input)}`); diff --git a/test/engine/fix/703.test.js b/test/fix/703.test.js similarity index 99% rename from test/engine/fix/703.test.js rename to test/fix/703.test.js index c078ce27..3792ce60 100644 --- a/test/engine/fix/703.test.js +++ b/test/fix/703.test.js @@ -3,7 +3,7 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; import path from 'path'; -import {rr} from '../../helper.js'; +import {rr} from '../helper.js'; // Note that rr() from the helper sets voidAsync per default, so the names come in the order of the file list diff --git a/test/engine/fix/705.test.js b/test/fix/705.test.js similarity index 97% rename from test/engine/fix/705.test.js rename to test/fix/705.test.js index b40376c5..cfe7bed3 100644 --- a/test/engine/fix/705.test.js +++ b/test/fix/705.test.js @@ -2,8 +2,8 @@ // The engine used to return before the files were done, and there was no way to wait for it. import {describe, test, expect, spyOn} from 'bun:test'; import fs from 'fs'; -import {engine} from '../../../src/engine.ts'; -import {rr} from '../../helper.js'; +import {engine} from '../../src/engine.ts'; +import {rr} from '../helper.js'; const content = names => names.map(name => fs.readFileSync(name, 'utf8')); diff --git a/test/engine/fix/708.test.js b/test/fix/708.test.js similarity index 98% rename from test/engine/fix/708.test.js rename to test/fix/708.test.js index 9eee43dc..f1ddf837 100644 --- a/test/engine/fix/708.test.js +++ b/test/fix/708.test.js @@ -3,7 +3,7 @@ import {describe, test, expect, spyOn} from 'bun:test'; import fs from 'fs'; import os from 'os'; -import {rr} from '../../helper.js'; +import {rr} from '../helper.js'; const workers = Math.max(16, os.availableParallelism() * 2); diff --git a/test/engine/fix/710.test.js b/test/fix/710.test.js similarity index 98% rename from test/engine/fix/710.test.js rename to test/fix/710.test.js index 7e807d3f..44703061 100644 --- a/test/engine/fix/710.test.js +++ b/test/fix/710.test.js @@ -2,7 +2,7 @@ // There was no way to see which files a command would change without changing them. import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr, rrStatus} from '../../helper.js'; +import {rr, rrStatus} from '../helper.js'; // Note that rr() from the helper sets voidAsync per default, so the async flow is `voidAsync: false` const flows = [ diff --git a/test/engine/fix/711.test.js b/test/fix/711.test.js similarity index 98% rename from test/engine/fix/711.test.js rename to test/fix/711.test.js index 37a6b15d..d847e0b3 100644 --- a/test/engine/fix/711.test.js +++ b/test/fix/711.test.js @@ -2,7 +2,7 @@ // A list of files from another command could only be used with `xargs` or `$(…)`. import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr, rrStatus} from '../../helper.js'; +import {rr, rrStatus} from '../helper.js'; const content = name => fs.readFileSync(name, 'utf8'); diff --git a/test/engine/fix/716.test.js b/test/fix/716.test.js similarity index 97% rename from test/engine/fix/716.test.js rename to test/fix/716.test.js index f57dfe77..1873dae4 100644 --- a/test/engine/fix/716.test.js +++ b/test/fix/716.test.js @@ -2,7 +2,7 @@ // The search was case-insensitive by default, but the globs were case-sensitive. import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from '../../helper.js'; +import {rr} from '../helper.js'; // The names differ in more than case, so they can both be there on a file system that is case-insensitive describe('#716 the globs are case-insensitive like the search', () => { diff --git a/test/engine/fix/718.test.js b/test/fix/718.test.js similarity index 99% rename from test/engine/fix/718.test.js rename to test/fix/718.test.js index 4ff45080..c25d5147 100644 --- a/test/engine/fix/718.test.js +++ b/test/fix/718.test.js @@ -3,7 +3,7 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; import path from 'path'; -import {rr} from '../../helper.js'; +import {rr} from '../helper.js'; const content = name => fs.readFileSync(name, 'utf8'); diff --git a/test/engine/fix/719.test.js b/test/fix/719.test.js similarity index 98% rename from test/engine/fix/719.test.js rename to test/fix/719.test.js index 26d4774b..be96a67d 100644 --- a/test/engine/fix/719.test.js +++ b/test/fix/719.test.js @@ -2,7 +2,7 @@ // Data piped in together with files was not used, and nothing told about it. import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from '../../helper.js'; +import {rr} from '../helper.js'; const warning = ' 🟡 Data is piped in, but no flag tells what to do with it. It is not used.\n\n'; const expectedOutput = 'my.file\n'; diff --git a/test/engine/fix/720.test.js b/test/fix/720.test.js similarity index 98% rename from test/engine/fix/720.test.js rename to test/fix/720.test.js index a2513fe4..e00e78c5 100644 --- a/test/engine/fix/720.test.js +++ b/test/fix/720.test.js @@ -3,7 +3,7 @@ // Finding no files was an error with the exit code 1, also when there was just nothing to do. import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr, rrStatus} from '../../helper.js'; +import {rr, rrStatus} from '../helper.js'; describe('#720 every error starts with ❌', () => { test('an error from a file, and the other files are still changed', async () => { diff --git a/test/engine/fix/726.test.js b/test/fix/726.test.js similarity index 99% rename from test/engine/fix/726.test.js rename to test/fix/726.test.js index 4d667901..f02ca3f1 100644 --- a/test/engine/fix/726.test.js +++ b/test/fix/726.test.js @@ -3,7 +3,7 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; import path from 'path'; -import {rr} from '../../helper.js'; +import {rr} from '../helper.js'; // Note that rr() from the helper sets voidAsync per default, so the async flow is `voidAsync: false` const flows = [ diff --git a/test/engine/fix/728.test.js b/test/fix/728.test.js similarity index 98% rename from test/engine/fix/728.test.js rename to test/fix/728.test.js index 30c08e57..4bfb7289 100644 --- a/test/engine/fix/728.test.js +++ b/test/fix/728.test.js @@ -2,7 +2,7 @@ // An error from a file was printed, but the exit code was 0, so a script could not see that something went wrong. import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from '../../helper.js'; +import {rr} from '../helper.js'; // Note that rr() from the helper sets voidAsync per default, so the async flow is `voidAsync: false` const flows = [ diff --git a/test/cli/fix/733.test.js b/test/fix/733.test.js similarity index 93% rename from test/cli/fix/733.test.js rename to test/fix/733.test.js index a46a0b25..f1327363 100644 --- a/test/cli/fix/733.test.js +++ b/test/fix/733.test.js @@ -3,7 +3,7 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; import {$} from 'bun'; -import {cli, sh} from '../../helper.js'; +import {cli, sh} from '../helper.js'; describe('#733 a file glob that reads as a number', () => { test.each(['2024', '1e3', '0x10'])('is kept as a string, like %s', async name => { diff --git a/test/cli/fix/736.test.js b/test/fix/736.test.js similarity index 96% rename from test/cli/fix/736.test.js rename to test/fix/736.test.js index 64d105d0..c7fbac2b 100644 --- a/test/cli/fix/736.test.js +++ b/test/fix/736.test.js @@ -3,7 +3,7 @@ // The flags that ask for the piped data are tested in test/cli/pipe.test.js. import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {cli} from '../../helper.js'; +import {cli} from '../helper.js'; // Bun.spawn and not sh() from the helper: the test needs a pipe that stays open, and a pipe in a shell command ends diff --git a/test/engine/fix/742.test.js b/test/fix/742.test.js similarity index 79% rename from test/engine/fix/742.test.js rename to test/fix/742.test.js index 454db424..7320568f 100644 --- a/test/engine/fix/742.test.js +++ b/test/fix/742.test.js @@ -1,9 +1,10 @@ // https://github.com/mathiasrw/rexreplace/issues/742 // The engine ended the process itself and resolved with nothing useful. Now it resolves with a status, a panic is thrown -// when the run can not go on, and only the CLI acts on the process. +// when the run can not go on, and only the CLI acts on the process. It prints the status report with --status and --status-compact. import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr, rrStatus} from '../../helper.js'; +import {$} from 'bun'; +import {rr, rrStatus, cli, sh} from '../helper.js'; describe('#742 the engine resolves with a status', () => { test('with the files searched and updated', async () => { @@ -183,3 +184,47 @@ describe('#742 the engine part of the status', () => { expect(status.engine.execution.replacement).toBe('y'); }); }); + +describe('#742 the CLI', () => { + test('--status prints a line break and the status report with an indent of two spaces after the result', async () => { + fs.writeFileSync('a.file', 'foo'); + const result = await sh($`bun ${cli} o x a.file --status`); + const status = { + files: {searched: ['a.file'], updated: ['a.file']}, + pipe: null, + warnings: [], + errors: [], + }; + expect(result).toEqual({ + out: 'a.file\n\n' + JSON.stringify(status, null, 2) + '\n', + err: '', + exit: 0, + }); + }); + + test.each(['--status-compact', '-c'])( + '%s prints the status report on one line, with no line break in front', + async flag => { + fs.writeFileSync('a.file', 'foo'); + const result = await sh($`bun ${cli} o x a.file ${flag} --quiet-total`); + expect(result.out).toBe( + '{"files":{"searched":["a.file"],"updated":["a.file"]},"pipe":null,"warnings":[],"errors":[]}\n' + ); + } + ); + + test('sets the exit code to 1 when a file gives an error, and prints the status report', async () => { + fs.writeFileSync('a.file', 'foo'); + fs.chmodSync('a.file', 0); + const result = await sh($`bun ${cli} o x a.file --status-compact --quiet-total`); + expect(JSON.parse(result.out).errors[0]).toContain('EACCES'); + expect(result.exit).toBe(1); + }); + + test('ends the process on a panic, and still prints the status report', async () => { + fs.writeFileSync('a.file', 'foo'); + const result = await sh($`bun ${cli} '(' x a.file --status-compact --quiet-total`); + expect(JSON.parse(result.out).errors).toHaveLength(1); + expect(result.exit).toBe(1); + }); +}); From 4b69a2f02c86cd781647ab2258b53b9639f9d8e0 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 15:40:20 +1100 Subject: [PATCH 16/27] Bring back the tests for #692 as test/fix/692.test.js --- test/fix/692.test.js | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 test/fix/692.test.js diff --git a/test/fix/692.test.js b/test/fix/692.test.js new file mode 100644 index 00000000..decbc4fc --- /dev/null +++ b/test/fix/692.test.js @@ -0,0 +1,31 @@ +// https://github.com/mathiasrw/rexreplace/issues/692 +// A single argument containing -v or -h printed the version or help. The checks live in the CLI, so the CLI is run. +import {describe, test, expect} from 'bun:test'; +import {$} from 'bun'; +import {cli, sh} from '../helper.js'; + +describe('#692 a single argument', () => { + test.each(['-v', '-version', '--version'])('%s prints only the version', async arg => { + const result = await sh($`bun ${cli} ${arg}`); + expect(result).toEqual({out: (await sh($`bun ${cli} --version`)).out, err: '', exit: 0}); + expect(result.out).toMatch(/^\S+\n$/); + }); + + test.each(['-h', '-help', '--help'])('%s prints the help to stdout', async arg => { + const result = await sh($`bun ${cli} ${arg}`); + expect(result.out).toContain('Options:'); + expect(result.err).toBe(''); + expect(result.exit).toBe(0); + }); + + // -V is verbose and -H is halt: other flags, not a request for the version or the help + test.each(['my-vile', '-V', 'o-ho', '-H', 'a-v', 'a-h', 'xversion', 'xhelp'])( + '%s is wrong use: the help goes to stderr, nothing to stdout, and the exit code is 3', + async arg => { + const result = await sh($`bun ${cli} ${arg}`); + expect(result.out).toBe(''); + expect(result.err).toContain('Options:'); + expect(result.exit).toBe(3); + } + ); +}); From aec17df533b4ab9ce28185bcaa7c05ac5a88a9aa Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 15:42:44 +1100 Subject: [PATCH 17/27] Put failed checks in errors too, and give 1 when they are the only errors --- src/cli.ts | 6 ++++-- src/output.ts | 3 ++- test/engine/expect-files.test.js | 4 ++-- test/helper.js | 5 +++-- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index e4f40143..7a5725dc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -595,9 +595,11 @@ const run = () => rexreplace.engine(config).then( status => { printStatus(config, status); - if (status.errors.length) { + // The failed checks are errors too, so 1 is when they are the only errors + const checks = status.checks?.length ?? 0; + if (status.errors.length > checks) { process.exitCode = 2; - } else if (status.checks?.length) { + } else if (checks) { process.exitCode = 1; } }, diff --git a/src/output.ts b/src/output.ts index 82c903d9..3b77f490 100644 --- a/src/output.ts +++ b/src/output.ts @@ -115,8 +115,9 @@ export const panic = function (msg = '', data = '', displayHelp = false) { throw new Panic(text); }; -// A check from --expect-* that is not met. It is told like an error, but goes into `checks` in the status report. +// A check from --expect-* that is not met. It is an error, and is also in `checks`, so the exit code can tell it from the other errors. export const checkFailed = function (text) { + config.statusInfo?.errors.push(text); config.statusInfo?.checks.push(text); printError(text, false); }; diff --git a/test/engine/expect-files.test.js b/test/engine/expect-files.test.js index 6fd00d1e..93f0309f 100644 --- a/test/engine/expect-files.test.js +++ b/test/engine/expect-files.test.js @@ -20,10 +20,10 @@ describe('--expect-files', () => { }); }); - test('is told in `checks` in the status report, and not in `errors`', async () => { + test('is in `errors` and in `checks` in the status report', async () => { const status = await rrStatus('o', 'x', {includeGlob: ['nope.file'], expectFiles: true}); + expect(status.errors).toEqual(['Expected files, but 0 files found']); expect(status.checks).toEqual(['Expected files, but 0 files found']); - expect(status.errors).toEqual([]); }); test('is still shown with --quiet, but not with --quiet-total', async () => { diff --git a/test/helper.js b/test/helper.js index eecee799..041a53d2 100644 --- a/test/helper.js +++ b/test/helper.js @@ -70,9 +70,10 @@ async function run(pattern, replacement, config) { alias(options); status = await engine(options); // Like the CLI, an error gives the exit code 2 and a check that is not met 1, when the run is done - if (status.errors.length) { + const checks = status.checks?.length ?? 0; + if (status.errors.length > checks) { result.exit = 2; - } else if (status.checks?.length) { + } else if (checks) { result.exit = 1; } } catch (e) { From 717d202318410ab12f4ffbf078731cf558742134 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 15:44:36 +1100 Subject: [PATCH 18/27] Put each path of a rename clash on its own line --- src/engine.ts | 27 +++++++++++++++++---------- test/engine/rename.test.js | 23 ++++++++++++++++------- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index 4c1ac180..f8419d4d 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -350,21 +350,28 @@ function findClashes(plan, renames, config) { const ontoRun = targets.intersection(inRun); const ontoOthers = targets.difference(inRun); + // Each path on its own line, so long paths can be read + const clash = (title, items) => + title + items.map(item => '\n From: ' + item.old).join('') + '\n To: ' + items[0].new; + const clashes = []; byTarget.forEach((items, target) => { - const [item] = items; if (1 < items.length) { + clashes.push(clash('Renames to the same path are not allowed.', items)); + } else if (ontoRun.has(target) && pathKey(items[0].old) !== target) { + // Not even --rename-force writes over a file in the run. A file can still change the case of its own name. + clashes.push(clash('Renames clashing with other files in this run are not allowed.', items)); + } else if ( + ontoOthers.has(target) && + !config.renameForce && + isOtherFile(items[0].old, items[0].new) + ) { clashes.push( - items.map(each => each.old).join(' and ') + - (2 === items.length ? ' would both' : ' would all') + - ' be renamed to ' + - item.new + clash( + 'Renames clashing with files that are there already are not allowed without --rename-force.', + items + ) ); - } else if (ontoRun.has(target) && pathKey(item.old) !== target) { - // Not even --rename-force writes over a file in the run. A file can still change the case of its own name. - clashes.push(item.old + ' would be renamed to ' + item.new + ', which is a file in this run'); - } else if (ontoOthers.has(target) && !config.renameForce && isOtherFile(item.old, item.new)) { - clashes.push(item.old + ' would be renamed to ' + item.new + ', which is there already'); } }); diff --git a/test/engine/rename.test.js b/test/engine/rename.test.js index 4a48d824..d833c7e8 100644 --- a/test/engine/rename.test.js +++ b/test/engine/rename.test.js @@ -112,7 +112,9 @@ describe('--rename stops the run before any file is renamed, when planned rename fs.writeFileSync('b.md', 'foo'); expect(await rr('^[ab]', 'c', {includeGlob: ['*.md'], rename: true})).toEqual({ out: '', - err: ' ❌ a.md and b.md would both be renamed to c.md\n\n' + stopped, + err: + ' ❌ Renames to the same path are not allowed.\n From: a.md\n From: b.md\n To: c.md\n\n' + + stopped, exit: 3, }); expect(files()).toEqual(['a.md', 'b.md']); @@ -123,7 +125,8 @@ describe('--rename stops the run before any file is renamed, when planned rename fs.writeFileSync('b.md', 'foo'); const config = {includeGlob: ['a.md', './b.md'], rename: true}; expect((await rr('[ab]\\.md$', 'c.md', config)).err).toBe( - ' ❌ ./b.md and a.md would both be renamed to ./c.md\n\n' + stopped + ' ❌ Renames to the same path are not allowed.\n From: ./b.md\n From: a.md\n To: ./c.md\n\n' + + stopped ); expect(files()).toEqual(['a.md', 'b.md']); }); @@ -135,8 +138,8 @@ describe('--rename stops the run before any file is renamed, when planned rename expect(await rr('^[ab]', '$0 == "a" ? "b" : "a"', config)).toEqual({ out: '', err: - ' ❌ a.md would be renamed to b.md, which is a file in this run\n\n' + - ' ❌ b.md would be renamed to a.md, which is a file in this run\n\n' + + ' ❌ Renames clashing with other files in this run are not allowed.\n From: a.md\n To: b.md\n\n' + + ' ❌ Renames clashing with other files in this run are not allowed.\n From: b.md\n To: a.md\n\n' + stopped, exit: 3, }); @@ -148,7 +151,9 @@ describe('--rename stops the run before any file is renamed, when planned rename fs.writeFileSync('b.txt', 'bar'); expect(await rr('^a\\.md$', 'b.txt', {includeGlob: ['a.md', 'b.txt'], rename: true})).toEqual({ out: '', - err: ' ❌ a.md would be renamed to b.txt, which is a file in this run\n\n' + stopped, + err: + ' ❌ Renames clashing with other files in this run are not allowed.\n From: a.md\n To: b.txt\n\n' + + stopped, exit: 3, }); expect(fs.readFileSync('b.txt', 'utf8')).toBe('bar'); @@ -160,7 +165,9 @@ describe('--rename stops the run before any file is renamed, when planned rename const config = {includeGlob: ['*.md'], rename: true, replacementJs: true}; expect(await rr('^[ab]', '$0 == "a" ? "X" : "x"', config)).toEqual({ out: '', - err: ' ❌ a.md and b.md would both be renamed to X.md\n\n' + stopped, + err: + ' ❌ Renames to the same path are not allowed.\n From: a.md\n From: b.md\n To: X.md\n\n' + + stopped, exit: 3, }); expect(files()).toEqual(['a.md', 'b.md']); @@ -172,7 +179,9 @@ describe('--rename stops the run before any file is renamed, when planned rename fs.writeFileSync('b.md', 'foo'); expect(await rr('md', 'txt', {includeGlob: ['*.md'], rename: true})).toEqual({ out: '', - err: ' ❌ a.md would be renamed to a.txt, which is there already\n\n' + stopped, + err: + ' ❌ Renames clashing with files that are there already are not allowed without --rename-force.\n From: a.md\n To: a.txt\n\n' + + stopped, exit: 3, }); expect(files()).toEqual(['a.md', 'a.txt', 'b.md']); From 0918434dccf85786332bb9227a8c3c5c06bff0e2 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 15:40:27 +1100 Subject: [PATCH 19/27] =?UTF-8?q?Name=20panic()=20in=20the=20devguide=20an?= =?UTF-8?q?d=20tell=20that=20errors=20start=20with=20=E2=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devguide.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/devguide.md b/devguide.md index 69bdb9a7..c393494e 100644 --- a/devguide.md +++ b/devguide.md @@ -23,13 +23,13 @@ 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, but the run goes on. Also added to `status.warnings`. | `Data is piped in, but no flag tells what to do with it` | -| `error(msg, data)` | Unless `--quiet-total` | Something went wrong. 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 1. | `File not found: a.md` | -| `die(msg, data, help)` | Unless `--quiet-total` | Something went wrong and the run can not go on. Throws a panic, and the CLI exits with 1. Also added to `status.errors`. | `No data piped into replacement` | +| `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 1. | `File not found: a.md` | +| `panic(msg, data, help)` | Unless `--quiet-total` | Something went wrong and the run can not go on. Starts with ❌ and ends with `No further processing.` Throws a panic, and the CLI exits with 1. 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` | - 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. -- `warn()`, `error()` and `die()` add to the status report as they go, also when the quiet flags hide what they print. +- `warn()`, `error()` and `panic()` add to the status report as they go, also when the quiet flags hide what they print. ## Tests From 184578c8258dc48922a5afa67f5ef30164ccc7d7 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 15:40:37 +1100 Subject: [PATCH 20/27] Give the exit codes 2 and 3 in the devguide --- devguide.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/devguide.md b/devguide.md index c393494e..e02049e6 100644 --- a/devguide.md +++ b/devguide.md @@ -23,13 +23,14 @@ 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, but the run goes on. Also added to `status.warnings`. | `Data is piped in, but no flag tells what to do with it` | -| `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 1. | `File not found: a.md` | -| `panic(msg, data, help)` | Unless `--quiet-total` | Something went wrong and the run can not go on. Starts with ❌ and ends with `No further processing.` Throws a panic, and the CLI exits with 1. Also added to `status.errors`. | `No data piped into replacement` | +| `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` | +| `panic(msg, data, help)` | Unless `--quiet-total` | Something went wrong and the run can not go on. Starts with ❌ and ends with `No further processing.` Throws a panic, and 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` | - 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. - `warn()`, `error()` 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, 2 when a file gave an error, and 3 for a panic or wrong flags. 1 is kept for a check that says no, like `grep`. See Exit codes in the README. ## Tests From 18d5d03b362c60f9287d7d0092c8f98db58cb47c Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 16:07:40 +1100 Subject: [PATCH 21/27] Fix --rename writing over a file whose name differs only in case, and record only the renames that were done --- src/engine.ts | 24 ++++++++------ src/shim.ts | 6 +--- test/engine/rename-force.test.js | 27 ++++++++++++++++ test/engine/rename.test.js | 55 ++++++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 15 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index f8419d4d..677d78dc 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -300,10 +300,10 @@ function renameFiles(files, config) { return {old: file, new: file.replace(config.regex, replacement)}; }); const renames = plan.filter(item => item.new !== item.old); - config.statusInfo.renames.push(...renames); // With --output-match the matches are printed by the replacement, and the names go to stderr if (config.outputMatch) { + config.statusInfo.renames.push(...renames); return plan.forEach(item => item.new !== item.old ? updated(item.old, config) : config.nameAll && info(item.old) ); @@ -319,6 +319,7 @@ function renameFiles(files, config) { // --simulate and --output do everything but rename if (config.simulate || config.output) { config.statusInfo.files.updated.push(item.old); + config.statusInfo.renames.push(item); return nameRename(item, config); } renameFile(item, config); @@ -346,9 +347,8 @@ function findClashes(plan, renames, config) { }); // All files in the run, also the ones that keep their name const inRun = new Set(plan.map(item => pathKey(item.old))); - const targets = new Set(byTarget.keys()); - const ontoRun = targets.intersection(inRun); - const ontoOthers = targets.difference(inRun); + const inRunExact = new Set(plan.map(item => path.resolve(item.old))); + const ontoRun = new Set(byTarget.keys()).intersection(inRun); // Each path on its own line, so long paths can be read const clash = (title, items) => @@ -356,16 +356,19 @@ function findClashes(plan, renames, config) { const clashes = []; byTarget.forEach((items, target) => { + const [item] = items; + // The new path is another file, also when it differs from the old one only in case (on a file system that is case-sensitive) + const other = isOtherFile(item.old, item.new); + const self = pathKey(item.old) === target; if (1 < items.length) { clashes.push(clash('Renames to the same path are not allowed.', items)); - } else if (ontoRun.has(target) && pathKey(items[0].old) !== target) { - // Not even --rename-force writes over a file in the run. A file can still change the case of its own name. - clashes.push(clash('Renames clashing with other files in this run are not allowed.', items)); } else if ( - ontoOthers.has(target) && - !config.renameForce && - isOtherFile(items[0].old, items[0].new) + ontoRun.has(target) && + (!self || (other && inRunExact.has(path.resolve(item.new)))) ) { + // Not even --rename-force writes over a file in the run. A file can still change the case of its own name. + clashes.push(clash('Renames clashing with other files in this run are not allowed.', items)); + } else if (other && !config.renameForce) { clashes.push( clash( 'Renames clashing with files that are there already are not allowed without --rename-force.', @@ -418,6 +421,7 @@ function renameFile(item, config) { return error(e); } config.statusInfo.files.updated.push(item.old); + config.statusInfo.renames.push(item); info(item.new); } diff --git a/src/shim.ts b/src/shim.ts index 3d576fd5..d6142fea 100644 --- a/src/shim.ts +++ b/src/shim.ts @@ -1,11 +1,7 @@ -// The Set methods came in Node 22. They are added here for older runtimes, with only what RexReplace uses. +// Set.prototype.intersection() came in Node 22, so it is added here for older runtimes const proto: any = Set.prototype; proto.intersection ??= function (other) { return new Set([...this].filter(item => other.has(item))); }; - -proto.difference ??= function (other) { - return new Set([...this].filter(item => !other.has(item))); -}; diff --git a/test/engine/rename-force.test.js b/test/engine/rename-force.test.js index 2fdb4f80..52027eba 100644 --- a/test/engine/rename-force.test.js +++ b/test/engine/rename-force.test.js @@ -1,7 +1,18 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; +import os from 'os'; +import path from 'path'; import {rr} from '../helper.js'; +// On a file system that is case-sensitive (like on Linux), `a.md` and `A.md` can be two files +const caseSensitive = (() => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rexreplace-case-')); + fs.writeFileSync(path.join(dir, 'a'), ''); + const result = !fs.existsSync(path.join(dir, 'A')); + fs.rmSync(dir, {recursive: true}); + return result; +})(); + describe('--rename-force', () => { test('writes over a file that is there already', async () => { fs.writeFileSync('a.md', 'foo'); @@ -30,6 +41,22 @@ describe('--rename-force', () => { expect(fs.readFileSync('b.txt', 'utf8')).toBe('bar'); }); + test.if(caseSensitive)( + 'does not write over a file in the run that differs only in case', + async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('A.md', 'bar'); + const config = { + includeGlob: ['a.md', 'A.md'], + rename: true, + renameForce: true, + voidIgnoreCase: true, + }; + expect((await rr('^a', 'A', config)).exit).toBe(3); + expect(fs.readFileSync('A.md', 'utf8')).toBe('bar'); + } + ); + test('does not let two new paths differ only in case', async () => { fs.writeFileSync('a.md', 'foo'); fs.writeFileSync('b.md', 'bar'); diff --git a/test/engine/rename.test.js b/test/engine/rename.test.js index d833c7e8..5c2602f9 100644 --- a/test/engine/rename.test.js +++ b/test/engine/rename.test.js @@ -1,10 +1,20 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; +import os from 'os'; import path from 'path'; import {rr, rrStatus} from '../helper.js'; const files = () => fs.readdirSync('.', {recursive: true}).sort(); +// On a file system that is case-sensitive (like on Linux), `a.md` and `A.md` can be two files +const caseSensitive = (() => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rexreplace-case-')); + fs.writeFileSync(path.join(dir, 'a'), ''); + const result = !fs.existsSync(path.join(dir, 'A')); + fs.rmSync(dir, {recursive: true}); + return result; +})(); + describe('--rename', () => { test('renames each file to the result of the search and replace on its path', async () => { fs.writeFileSync('a.markdown', 'foo'); @@ -65,6 +75,23 @@ describe('--rename', () => { expect(status.files).toEqual({searched: ['a.md', 'b.txt'], updated: ['a.md']}); }); + test('tells in the status report only the renames that were done', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.mkdirSync('locked'); + fs.writeFileSync('locked/b.md', 'foo'); + fs.chmodSync('locked', 0o555); + try { + const status = await rrStatus('md', 'txt', { + includeGlob: ['a.md', 'locked/b.md'], + rename: true, + }); + expect(status.renames).toEqual([{old: 'a.md', new: 'a.txt'}]); + expect(status.errors[0]).toContain('EACCES'); + } finally { + fs.chmodSync('locked', 0o755); + } + }); + describe.each([['simulate'], ['output']])('with --%s', flag => { test('the old names go to stderr and the new ones to stdout, and nothing is renamed', async () => { fs.writeFileSync('a.md', 'foo'); @@ -187,6 +214,34 @@ describe('--rename stops the run before any file is renamed, when planned rename expect(files()).toEqual(['a.md', 'a.txt', 'b.md']); }); + test.if(caseSensitive)( + 'when the new path is another file in the run that differs only in case', + async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('A.md', 'bar'); + const config = {includeGlob: ['a.md', 'A.md'], rename: true, voidIgnoreCase: true}; + expect((await rr('^a', 'A', config)).err).toBe( + ' ❌ Renames clashing with other files in this run are not allowed.\n From: a.md\n To: A.md\n\n' + + stopped + ); + expect(fs.readFileSync('A.md', 'utf8')).toBe('bar'); + } + ); + + test.if(caseSensitive)( + 'when the new path is a file outside the run that differs only in case', + async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('A.md', 'bar'); + const config = {includeGlob: ['a.md'], rename: true, voidIgnoreCase: true}; + expect((await rr('^a', 'A', config)).err).toBe( + ' ❌ Renames clashing with files that are there already are not allowed without --rename-force.\n From: a.md\n To: A.md\n\n' + + stopped + ); + expect(fs.readFileSync('A.md', 'utf8')).toBe('bar'); + } + ); + test('with --simulate too', async () => { fs.writeFileSync('a.md', 'foo'); fs.writeFileSync('a.txt', 'bar'); From 240efdce8d128ed08ee52174a61a1f7e33430d0e Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 16:29:30 +1100 Subject: [PATCH 22/27] Keep --output-match with --rename to the matches, and show --rename with small examples in the README --- README.md | 51 ++++++++++++++++++++++++++++++++++++++ src/engine.ts | 4 +-- test/engine/rename.test.js | 15 ++++++++--- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 536762a4..91b6fbde 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,57 @@ Use --js-pipe to access the pipe data in your javascript code. --- +#### Renaming files + +With `--rename` the search and replace is done on the path of each file, and the file is renamed to the result. The content is not touched. + +```bash +> rexreplace '\.markdown$' '.md' '**/*.markdown' --rename +docs/intro.md +docs/setup.md +``` + +The path is the text, so a `/` in the replacement moves the file. Folders that are not there are made. + +```bash +> rexreplace '^' 'archive/' '*.log' --rename +archive/a.log +archive/b.log +``` + +See what would happen first with `--simulate`. Each old name is printed (to stderr) just before its new name (to stdout), and nothing is renamed. + +```bash +> rexreplace '\.markdown$' '.md' '**/*.markdown' --rename --simulate +docs/intro.markdown +docs/intro.md +docs/setup.markdown +docs/setup.md +``` + +If planned renames clash, the process is halted and no change is applied to any file. + +```bash +> rexreplace '^[ab]' 'c' a.md b.md --rename + ❌ Renames to the same path are not allowed. + From: a.md + From: b.md + To: c.md + + ❌ Planned renames clash, so no change is applied to any file + No further processing. +``` + +`--output-match` shows what the pattern finds in the paths. The replacement is not used, so nothing would be renamed. + +```bash +> rexreplace '\.\w+$' _ 'docs/*' --rename --output-match 2>/dev/null +.md +.md +``` + +--- + ## Usage ```bash diff --git a/src/engine.ts b/src/engine.ts index 677d78dc..1a59d9a9 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -301,9 +301,9 @@ function renameFiles(files, config) { }); const renames = plan.filter(item => item.new !== item.old); - // With --output-match the matches are printed by the replacement, and the names go to stderr + // With --output-match the matches are printed by the replacement, and the names go to stderr. + // The replacement is not used, so nothing would be renamed: no clash check and no renames in the status. if (config.outputMatch) { - config.statusInfo.renames.push(...renames); return plan.forEach(item => item.new !== item.old ? updated(item.old, config) : config.nameAll && info(item.old) ); diff --git a/test/engine/rename.test.js b/test/engine/rename.test.js index 5c2602f9..73e488f1 100644 --- a/test/engine/rename.test.js +++ b/test/engine/rename.test.js @@ -110,11 +110,18 @@ describe('--rename', () => { }); }); - test('with --output-match the matches go to stdout and the names to stderr', async () => { + test('with --output-match the matches go to stdout and the names to stderr, and nothing would be renamed', async () => { fs.writeFileSync('a.md', 'foo'); - const config = {includeGlob: ['a.md'], rename: true, outputMatch: true}; - expect(await rr('md', '_', config)).toEqual({out: 'md\n', err: 'a.md\n', exit: null}); - expect(files()).toEqual(['a.md']); + fs.writeFileSync('b.md', 'foo'); + // The replacement is not used, so `_` gives no clash + const config = {includeGlob: ['*.md'], rename: true, outputMatch: true}; + expect(await rr('^[ab]', '_', config)).toEqual({ + out: 'a\nb\n', + err: 'a.md\nb.md\n', + exit: null, + }); + expect((await rrStatus('^[ab]', '_', config)).renames).toEqual([]); + expect(files()).toEqual(['a.md', 'b.md']); }); }); From 308d7de6fb0010e326de56415ce202ef2cde4c95 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 16:47:16 +1100 Subject: [PATCH 23/27] Split --rename (the name) from --rename-path (the whole path) --- README.md | 30 ++++++++--------- src/cli.ts | 15 +++++++-- src/engine.ts | 49 +++++++++++++++++++++------- test/cli/rename.test.js | 8 ++++- test/cli/run.sh | 5 +-- test/engine/rename-path.test.js | 45 +++++++++++++++++++++++++ test/engine/rename.test.js | 58 ++++++++++++++++++--------------- 7 files changed, 151 insertions(+), 59 deletions(-) create mode 100644 test/engine/rename-path.test.js diff --git a/README.md b/README.md index 91b6fbde..2cf3e558 100644 --- a/README.md +++ b/README.md @@ -220,9 +220,9 @@ Use --js-pipe to access the pipe data in your javascript code. --- -#### Renaming files +#### Renaming and moving files -With `--rename` the search and replace is done on the path of each file, and the file is renamed to the result. The content is not touched. +With `--rename` the search and replace is done on the name of each file, and the file is renamed to the result. The folder and the content are not touched. ```bash > rexreplace '\.markdown$' '.md' '**/*.markdown' --rename @@ -230,10 +230,17 @@ docs/intro.md docs/setup.md ``` -The path is the text, so a `/` in the replacement moves the file. Folders that are not there are made. +With `--replacement-js` the name is the `text`: ```bash -> rexreplace '^' 'archive/' '*.log' --rename +> rexreplace '^.*$' 'text.toLowerCase()' 'docs/*' --rename --replacement-js +docs/readme.md +``` + +With `--rename-path` it is done on the whole path, so a `/` in the replacement moves the file. Folders that are not there are made. + +```bash +> rexreplace '^' 'archive/' '*.log' --rename-path archive/a.log archive/b.log ``` @@ -261,14 +268,6 @@ If planned renames clash, the process is halted and no change is applied to any No further processing. ``` -`--output-match` shows what the pattern finds in the paths. The replacement is not used, so nothing would be renamed. - -```bash -> rexreplace '\.\w+$' _ 'docs/*' --rename --output-match 2>/dev/null -.md -.md -``` - --- ## Usage @@ -313,8 +312,9 @@ If planned renames clash, the process is halted and no change is applied to any | `-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`** Do everything but write the files. The names of the files that would be changed are printed as usual, and no backup is made. [boolean] | -| `-r` | **`--rename`** Search and replace in the path of each file, and rename the file to the result. The content is not touched. A / in the replacement moves the file, and folders that are not there are made. If planned renames clash the process is halted and no change is applied to any file. [boolean] | -| | **`--rename-force`** With --rename, write over a file that is there already. [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] | +| | **`--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. 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] | | `-T` | **`--trim-pipe`** Trim piped data before processing. If piped data only consists of chars that can be trimmed (new line, space, tabs...) it will become an empty string. [boolean] | | `-p` | **`--pipe`** Read the piped data and make it available to --replacement-js as `pipe`. [boolean] | @@ -348,7 +348,7 @@ Files identified for potential processing are identified by: - Data can be piped in, both as the text to search and as the replacement - The names of the changed files are printed to stdout, so they can be piped on (to stderr with `--output` and `--output-match`) - Set the delimiter between names and between the content of files -- Rename and move files with a search and replace on their path (`--rename`). If planned renames clash the process is halted and no change is applied to any file, and `--simulate` shows each old name next to its new one +- Rename files with a search and replace on their name (`--rename`), or move them with one on their path (`--rename-path`). If planned renames clash the process is halted and no change is applied to any file, and `--simulate` shows each old name next to its new one - See the [release note](https://github.com/mathiasrw/rexreplace/releases) for a log of changes. Descriptions are given in latest patch version. ### Quirks diff --git a/src/cli.ts b/src/cli.ts index 97393036..4fd55707 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -250,13 +250,22 @@ const yargs = require('yargs') .boolean('r') .describe( 'r', - 'Search and replace in the path of each file, and rename the file to the result. The content is not touched. A / in the replacement moves the file, and folders that are not there are made. 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.' ) .alias('r', 'rename') + .boolean('rename-path') + .describe( + '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.' + ) + .conflicts('r', 'rename-path') + .boolean('rename-force') - .describe('rename-force', 'With --rename, write over a file that is there already.') - .implies('rename-force', 'rename') + .describe( + 'rename-force', + 'With --rename or --rename-path, write over a file that is there already.' + ) .boolean('m') .describe( diff --git a/src/engine.ts b/src/engine.ts index 1a59d9a9..05d37c22 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -58,7 +58,7 @@ function newStatus(config) { const status: any = {files: {searched: [], updated: []}, pipe: null, warnings: [], errors: []}; // The old and the new path of each file that is renamed, or would be - if (config.rename) { + if (config.rename || config.renamePath) { status.renames = []; } @@ -137,8 +137,16 @@ async function run(config) { config.contentDelimiterTail = config.contentDelimiterTail ?? config.tail ?? !(piped && config.output); - if (config.rename && piped) { - panic('--rename needs files, as piped data has no name to change'); + if (config.rename && config.renamePath) { + panic('--rename can not be used with --rename-path'); + } + + if (config.renameForce && !config.rename && !config.renamePath) { + panic('--rename-force needs --rename or --rename-path'); + } + + if ((config.rename || config.renamePath) && piped) { + panic('--rename and --rename-path need files, as piped data has no name to change'); } if (piped) { @@ -162,7 +170,7 @@ async function run(config) { fs.existsSync(filepath) ? true : error('File not found:', filepath) ); - if (config.rename) { + if (config.rename || config.renamePath) { return renameFiles(files, config); } @@ -288,16 +296,31 @@ function updated(file, config) { info(file); } -// --rename: the path of each file is the content that is searched and replaced, and the file is renamed to the result +// --rename: the name of each file is the text that is searched and replaced, and the file is renamed to the result. +// --rename-path: the same with the whole path, so the file can be moved. function renameFiles(files, config) { + // With --rename a / in the new name would move the file + const slashed = []; const plan = files.map(file => { config.statusInfo.files.searched.push(file); - chat('Work on the path of: ' + file); + const text = config.renamePath ? file : path.basename(file); + chat('Work on the ' + (config.renamePath ? 'path' : 'name') + ' of: ' + file); const replacement = config.replacementJs && !config.outputMatch - ? dynamicReplacement(file, config, file) + ? dynamicReplacement(file, config, text) : config.replacement; - return {old: file, new: file.replace(config.regex, replacement)}; + const result = text.replace(config.regex, replacement); + if (result === text) { + return {old: file, new: file}; + } + if (config.renamePath) { + return {old: file, new: result}; + } + const item = {old: file, new: path.join(path.dirname(file), result)}; + if (/[\\/]/.test(result)) { + slashed.push(item); + } + return item; }); const renames = plan.filter(item => item.new !== item.old); @@ -309,7 +332,7 @@ function renameFiles(files, config) { ); } - findClashes(plan, renames, config); + findClashes(plan, renames, slashed, config); plan.forEach(item => { if (item.new === item.old) { @@ -339,7 +362,7 @@ function nameRename(item, config) { // and the case is ignored, as `A.md` and `a.md` are the same file on a file system that is case-insensitive. const pathKey = file => path.resolve(file).toLowerCase(); -function findClashes(plan, renames, config) { +function findClashes(plan, renames, slashed, config) { const byTarget = new Map(); renames.forEach(item => { const target = pathKey(item.new); @@ -354,7 +377,11 @@ function findClashes(plan, renames, config) { const clash = (title, items) => title + items.map(item => '\n From: ' + item.old).join('') + '\n To: ' + items[0].new; - const clashes = []; + const clashes = slashed.map(item => + clash('Renames with a / in the new name are not allowed. Use --rename-path to move files.', [ + item, + ]) + ); byTarget.forEach((items, target) => { const [item] = items; // The new path is another file, also when it differs from the old one only in case (on a file system that is case-sensitive) diff --git a/test/cli/rename.test.js b/test/cli/rename.test.js index 271aeae0..3c66a01a 100644 --- a/test/cli/rename.test.js +++ b/test/cli/rename.test.js @@ -15,10 +15,16 @@ describe('--rename', () => { expect(fs.readFileSync('a.txt', 'utf8')).toBe('xoo'); }); - test('--rename-force needs --rename', async () => { + test('--rename-force needs --rename or --rename-path', async () => { fs.writeFileSync('a.md', 'foo'); const result = await sh($`bun ${cli} md txt a.md --rename-force`); expect(result.exit).toBe(3); expect(fs.readdirSync('.')).toEqual(['a.md']); }); + + test('can not be used with --rename-path', async () => { + fs.writeFileSync('a.md', 'foo'); + expect((await sh($`bun ${cli} md txt a.md -r --rename-path`)).exit).toBe(3); + expect(fs.readdirSync('.')).toEqual(['a.md']); + }); }); diff --git a/test/cli/run.sh b/test/cli/run.sh index a7f4d151..30b51a93 100644 --- a/test/cli/run.sh +++ b/test/cli/run.sh @@ -233,11 +233,12 @@ assert "rexreplace 'oo' 'x' my.file your.file --simulate" 'my.file' assert "cat my.file" 'foobar' -# --rename (-r) does the search and replace on the path, and renames the file to the result +# --rename (-r) does the search and replace on the name, and renames the file to the result reset assert "rexreplace '^my' 'our' my.file --rename" 'our.file' assert "cat our.file" 'foobar' -assert "rexreplace '^' 'sub/' our.file -r" 'sub/our.file' +# --rename-path does it on the whole path, so the file can be moved +assert "rexreplace '^' 'sub/' our.file --rename-path" 'sub/our.file' assert "cat sub/our.file" 'foobar' rm -rf sub diff --git a/test/engine/rename-path.test.js b/test/engine/rename-path.test.js new file mode 100644 index 00000000..aa44af8d --- /dev/null +++ b/test/engine/rename-path.test.js @@ -0,0 +1,45 @@ +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import path from 'path'; +import {rr} from '../helper.js'; + +const files = () => fs.readdirSync('.', {recursive: true}).sort(); + +describe('--rename-path', () => { + test('moves a file with a / in the replacement, and makes the folders', async () => { + fs.writeFileSync('a.md', 'foo'); + const result = await rr('^', 'docs/old/', { + includeGlob: ['a.md'], + renamePath: true, + verbose: true, + }); + expect(result.out).toBe('docs/old/a.md\n'); + expect(result.err).toContain('Make folder: docs/old\nRename: a.md to docs/old/a.md\n'); + expect(fs.readFileSync('docs/old/a.md', 'utf8')).toBe('foo'); + }); + + test('works on the full path with --full-path', async () => { + fs.writeFileSync('a.md', 'foo'); + const config = {includeGlob: ['a.md'], renamePath: true, fullPath: true}; + expect((await rr('^.*/a\\.md$', path.resolve('b.md'), config)).out).toBe( + path.resolve('b.md') + '\n' + ); + expect(files()).toEqual(['b.md']); + }); +}); + +describe('--rename-path stops the run before any file is renamed, when planned renames clash', () => { + const stopped = + ' ❌ Planned renames clash, so no change is applied to any file\n No further processing.\n\n'; + + test('when the same new path is written in two ways', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'foo'); + const config = {includeGlob: ['a.md', './b.md'], renamePath: true}; + expect((await rr('[ab]\\.md$', 'c.md', config)).err).toBe( + ' ❌ Renames to the same path are not allowed.\n From: ./b.md\n From: a.md\n To: ./c.md\n\n' + + stopped + ); + expect(files()).toEqual(['a.md', 'b.md']); + }); +}); diff --git a/test/engine/rename.test.js b/test/engine/rename.test.js index 73e488f1..eace2a1c 100644 --- a/test/engine/rename.test.js +++ b/test/engine/rename.test.js @@ -16,7 +16,7 @@ const caseSensitive = (() => { })(); describe('--rename', () => { - test('renames each file to the result of the search and replace on its path', async () => { + test('renames each file to the result of the search and replace on its name', async () => { fs.writeFileSync('a.markdown', 'foo'); fs.writeFileSync('b.markdown', 'bar'); const config = {includeGlob: ['*.markdown'], rename: true}; @@ -39,25 +39,32 @@ describe('--rename', () => { expect(files()).toEqual(['a.text', 'b.md']); }); - test('moves a file with a / in the replacement, and makes the folders', async () => { - fs.writeFileSync('a.md', 'foo'); - const result = await rr('^', 'docs/old/', {includeGlob: ['a.md'], rename: true, verbose: true}); - expect(result.out).toBe('docs/old/a.md\n'); - expect(result.err).toContain('Make folder: docs/old\nRename: a.md to docs/old/a.md\n'); - expect(fs.readFileSync('docs/old/a.md', 'utf8')).toBe('foo'); + test('leaves the folder as it is', async () => { + fs.mkdirSync('docs'); + fs.writeFileSync('docs/docs.md', 'foo'); + expect((await rr('^docs', 'notes', {includeGlob: ['docs/*'], rename: true})).out).toBe( + 'docs/notes.md\n' + ); + expect(files()).toEqual(['docs', 'docs/notes.md']); }); - test('works on the full path with --full-path', async () => { + test('a / in the new name stops the run, as that is --rename-path', async () => { fs.writeFileSync('a.md', 'foo'); - const config = {includeGlob: ['a.md'], rename: true, fullPath: true}; - expect((await rr('a\\.md$', 'b.md', config)).out).toBe(path.resolve('b.md') + '\n'); - expect(files()).toEqual(['b.md']); + expect(await rr('^', 'sub/', {includeGlob: ['a.md'], rename: true})).toEqual({ + out: '', + err: + ' ❌ Renames with a / in the new name are not allowed. Use --rename-path to move files.\n From: a.md\n To: sub/a.md\n\n' + + ' ❌ Planned renames clash, so no change is applied to any file\n No further processing.\n\n', + exit: 3, + }); + expect(files()).toEqual(['a.md']); }); - test('runs the code of --replacement-js with the variables of the file', async () => { - fs.writeFileSync('a.md', 'foo'); - const config = {includeGlob: ['a.md'], rename: true, replacementJs: true}; - expect((await rr('^.*$', 'name.toUpperCase() + ext', config)).out).toBe('A.md\n'); + test('runs the code of --replacement-js with the name as the text', async () => { + fs.mkdirSync('docs'); + fs.writeFileSync('docs/Intro.md', 'foo'); + const config = {includeGlob: ['docs/*'], rename: true, replacementJs: true}; + expect((await rr('^.*$', 'text.toLowerCase()', config)).out).toBe('docs/intro.md\n'); }); test('renames only the case of a name', async () => { @@ -154,17 +161,6 @@ describe('--rename stops the run before any file is renamed, when planned rename expect(files()).toEqual(['a.md', 'b.md']); }); - test('when the same new path is written in two ways', async () => { - fs.writeFileSync('a.md', 'foo'); - fs.writeFileSync('b.md', 'foo'); - const config = {includeGlob: ['a.md', './b.md'], rename: true}; - expect((await rr('[ab]\\.md$', 'c.md', config)).err).toBe( - ' ❌ Renames to the same path are not allowed.\n From: ./b.md\n From: a.md\n To: ./c.md\n\n' + - stopped - ); - expect(files()).toEqual(['a.md', 'b.md']); - }); - test('when two files would swap names', async () => { fs.writeFileSync('a.md', 'foo'); fs.writeFileSync('b.md', 'bar'); @@ -259,9 +255,17 @@ describe('--rename stops the run before any file is renamed, when planned rename }); describe('--rename can not be used', () => { + test('with --rename-path', async () => { + fs.writeFileSync('a.md', 'foo'); + expect( + (await rr('md', 'txt', {includeGlob: ['a.md'], rename: true, renamePath: true})).exit + ).toBe(3); + expect(files()).toEqual(['a.md']); + }); + test('with piped data, as it has no name', async () => { const result = await rr('o', 'x', {pipedData: 'foo', rename: true}); - expect(result.err).toStartWith(' ❌ --rename needs files'); + expect(result.err).toStartWith(' ❌ --rename and --rename-path need files'); expect(result.exit).toBe(3); }); }); From 27f241210d8c06e1da24feeb8aca84c044c141ea Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 17:00:15 +1100 Subject: [PATCH 24/27] Say names can't contain / --- src/engine.ts | 4 +--- test/engine/rename.test.js | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index 05d37c22..7724e7f1 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -378,9 +378,7 @@ function findClashes(plan, renames, slashed, config) { title + items.map(item => '\n From: ' + item.old).join('') + '\n To: ' + items[0].new; const clashes = slashed.map(item => - clash('Renames with a / in the new name are not allowed. Use --rename-path to move files.', [ - item, - ]) + clash("Names can't contain /. Use --rename-path to move files.", [item]) ); byTarget.forEach((items, target) => { const [item] = items; diff --git a/test/engine/rename.test.js b/test/engine/rename.test.js index eace2a1c..9c0a844a 100644 --- a/test/engine/rename.test.js +++ b/test/engine/rename.test.js @@ -53,7 +53,7 @@ describe('--rename', () => { expect(await rr('^', 'sub/', {includeGlob: ['a.md'], rename: true})).toEqual({ out: '', err: - ' ❌ Renames with a / in the new name are not allowed. Use --rename-path to move files.\n From: a.md\n To: sub/a.md\n\n' + + " ❌ Names can't contain /. Use --rename-path to move files.\n From: a.md\n To: sub/a.md\n\n" + ' ❌ Planned renames clash, so no change is applied to any file\n No further processing.\n\n', exit: 3, }); From 1bae1e592520f258153f780355efed8a660580f5 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 17:02:04 +1100 Subject: [PATCH 25/27] Use --js in the docs and tests that are not about --replacement-js --- README.md | 8 ++++---- src/cli.ts | 4 ++-- test/cli/pipe.test.js | 10 +++++----- test/engine/crossover.test.js | 2 +- test/engine/encoding.test.js | 4 ++-- test/engine/keep-backup.test.js | 2 +- test/engine/literal.test.js | 2 +- test/engine/output-match.test.js | 2 +- test/engine/output.test.js | 2 +- test/engine/rename.test.js | 2 +- test/engine/replacement-pipe.test.js | 2 +- test/engine/unicode.test.js | 2 +- test/engine/utf8.test.js | 2 +- test/engine/verbose.test.js | 2 +- test/engine/void-async.test.js | 2 +- test/engine/void-backup.test.js | 2 +- test/engine/void-euro.test.js | 2 +- test/engine/void-global.test.js | 2 +- test/engine/void-ignore-case.test.js | 2 +- test/engine/void-multiline.test.js | 2 +- test/engine/void-section.test.js | 2 +- test/fix/699.test.js | 2 +- test/fix/700.test.js | 6 +++--- test/fix/726.test.js | 2 +- test/fix/742.test.js | 2 +- 25 files changed, 36 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 85df2f65..b625a908 100644 --- a/README.md +++ b/README.md @@ -230,10 +230,10 @@ docs/intro.md docs/setup.md ``` -With `--replacement-js` the name is the `text`: +With `--js` the name is the `text`: ```bash -> rexreplace '^.*$' 'text.toLowerCase()' 'docs/*' --rename --replacement-js +> rexreplace '^.*$' 'text.toLowerCase()' 'docs/*' --rename --js docs/readme.md ``` @@ -321,8 +321,8 @@ If planned renames clash, the process is halted and no change is applied to any | | **`--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. 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] | | `-T` | **`--trim-pipe`** Trim piped data before processing. If piped data only consists of chars that can be trimmed (new line, space, tabs...) it will become an empty string. [boolean] | -| `-p` | **`--pipe`** Read the piped data and make it available to --replacement-js as `pipe`. [boolean] | -| | **`--js-pipe`** Shorthand for --replacement-js --pipe (-jp). [boolean] | +| `-p` | **`--pipe`** Read the piped data and make it available to --js as `pipe`. [boolean] | +| | **`--js-pipe`** Shorthand for --js --pipe (-jp). [boolean] | | `-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] | diff --git a/src/cli.ts b/src/cli.ts index fd8c4ef1..0614ed9f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -314,10 +314,10 @@ const yargs = require('yargs') .boolean('p') .alias('p', 'pipe') - .describe('p', `Read the piped data and make it available to --replacement-js as \`pipe\`.`) + .describe('p', `Read the piped data and make it available to --js as \`pipe\`.`) .boolean('js-pipe') - .describe('js-pipe', `Shorthand for --replacement-js --pipe (-jp).`) + .describe('js-pipe', `Shorthand for --js --pipe (-jp).`) .boolean('g') .alias('g', 'glob-pipe') diff --git a/test/cli/pipe.test.js b/test/cli/pipe.test.js index c7221078..a763a5ea 100644 --- a/test/cli/pipe.test.js +++ b/test/cli/pipe.test.js @@ -8,18 +8,18 @@ const warning = ' 🟡 Data is piped in, but no flag tells what to do with it. I describe('--pipe and --js-pipe', () => { test('without --pipe the code does not get the piped data', async () => { fs.writeFileSync('my.file', 'foobar\n'); - expect(await sh($`echo -n x | bun ${cli} foo pipe my.file -o --replacement-js`)).toEqual({ + expect(await sh($`echo -n x | bun ${cli} foo pipe my.file -o --js`)).toEqual({ out: 'nullbar\n', err: 'my.file\n', exit: 0, }); }); - test('the code gets the piped data with --replacement-js --pipe', async () => { + test('the code gets the piped data with --js --pipe', async () => { fs.writeFileSync('my.file', 'foobar\n'); - expect( - (await sh($`echo -n x | bun ${cli} foo pipe my.file -o --replacement-js --pipe`)).out - ).toBe('xbar\n'); + expect((await sh($`echo -n x | bun ${cli} foo pipe my.file -o --js --pipe`)).out).toBe( + 'xbar\n' + ); }); test('the code gets the piped data with -jp and --js-pipe', async () => { diff --git a/test/engine/crossover.test.js b/test/engine/crossover.test.js index ffd705e8..7f5bdeb2 100644 --- a/test/engine/crossover.test.js +++ b/test/engine/crossover.test.js @@ -118,7 +118,7 @@ describe('same result no matter how it is delivered', () => { expect(fs.readdirSync('.')).toHaveLength(files); }); - test('--replacement-pipe and --replacement-js', async () => { + test('--replacement-pipe and --js', async () => { fs.writeFileSync('my.file', text); const config = {includeGlob: ['my.file'], output: true, ...search}; expect((await rr('a.b', '_', {...config, replacementPipe: true, pipedData: '[$&]'})).out).toBe( diff --git a/test/engine/encoding.test.js b/test/engine/encoding.test.js index e18fdd2e..a04f1234 100644 --- a/test/engine/encoding.test.js +++ b/test/engine/encoding.test.js @@ -116,7 +116,7 @@ describe('--encoding', () => { expect(fs.readFileSync('my.file', 'utf16le')).toBe('🦕 🦖'); }); - test('with --replacement-js the text is decoded', async () => { + test('with --js the text is decoded', async () => { fs.writeFileSync('my.file', latin1()); const config = { includeGlob: ['my.file'], @@ -127,7 +127,7 @@ describe('--encoding', () => { expect((await rr('æ', 'text.length + text', config)).out).toBe('f3fæoo\n'); }); - test('with --replacement-js `bytes` and `size` are the size of the file in its encoding (#688)', async () => { + test('with --js `bytes` and `size` are the size of the file in its encoding (#688)', async () => { // 1500 times `æ`: one byte each in latin1, but two in utf8 (that would give 3000 and 2.9 KB) fs.writeFileSync('my.file', Buffer.alloc(1500, 0xe6)); const config = { diff --git a/test/engine/keep-backup.test.js b/test/engine/keep-backup.test.js index 3f7974c4..58960f22 100644 --- a/test/engine/keep-backup.test.js +++ b/test/engine/keep-backup.test.js @@ -63,7 +63,7 @@ describe('--keep-backup', () => { expect(fs.readdirSync('.')).toEqual(['my.file']); }); - test('with --replacement-js', async () => { + test('with --js', async () => { fs.writeFileSync('my.file', 'foobar'); await rr('foo', 'name', { includeGlob: ['my.file'], diff --git a/test/engine/literal.test.js b/test/engine/literal.test.js index 50b96328..52a5d234 100644 --- a/test/engine/literal.test.js +++ b/test/engine/literal.test.js @@ -68,7 +68,7 @@ describe('--literal', () => { ).toBe('o.\no.\n'); }); - test('with --replacement-js', async () => { + test('with --js', async () => { const config = {pipedData: '1+1 = 2', literal: true, replacementJs: true}; expect((await rr('1+1', '$0.length', config)).out).toBe('3 = 2'); }); diff --git a/test/engine/output-match.test.js b/test/engine/output-match.test.js index 868907d1..d7ed3b5e 100644 --- a/test/engine/output-match.test.js +++ b/test/engine/output-match.test.js @@ -89,7 +89,7 @@ describe('--output-match', () => { ).toBe('a.\n'); }); - test('with --replacement-js the code is not run', async () => { + test('with --js the code is not run', async () => { const config = {pipedData: 'foobar', outputMatch: true, replacementJs: true}; expect((await rr('[fb].', 'throw new Error("should not run")', config)).out).toBe('fo\nba\n'); }); diff --git a/test/engine/output.test.js b/test/engine/output.test.js index 07a8f315..a39f4583 100644 --- a/test/engine/output.test.js +++ b/test/engine/output.test.js @@ -69,7 +69,7 @@ describe('--output', () => { expect(result.err).toContain('Output result from: my.file'); }); - test('with --replacement-js and several files', async () => { + test('with --js and several files', async () => { fs.writeFileSync('a.file', 'foo'); fs.writeFileSync('b.file', 'foo'); const config = {includeGlob: ['*.file'], output: true, replacementJs: true}; diff --git a/test/engine/rename.test.js b/test/engine/rename.test.js index 9c0a844a..0a346191 100644 --- a/test/engine/rename.test.js +++ b/test/engine/rename.test.js @@ -60,7 +60,7 @@ describe('--rename', () => { expect(files()).toEqual(['a.md']); }); - test('runs the code of --replacement-js with the name as the text', async () => { + test('runs the code of --js with the name as the text', async () => { fs.mkdirSync('docs'); fs.writeFileSync('docs/Intro.md', 'foo'); const config = {includeGlob: ['docs/*'], rename: true, replacementJs: true}; diff --git a/test/engine/replacement-pipe.test.js b/test/engine/replacement-pipe.test.js index 6ce19b0e..5c7ffa30 100644 --- a/test/engine/replacement-pipe.test.js +++ b/test/engine/replacement-pipe.test.js @@ -68,7 +68,7 @@ describe('--replacement-pipe', () => { }); describe('combined', () => { - test('with --replacement-js the piped data is the code to run', async () => { + test('with --js the piped data is the code to run', async () => { fs.writeFileSync('my.file', 'foobar'); const config = { includeGlob: ['my.file'], diff --git a/test/engine/unicode.test.js b/test/engine/unicode.test.js index 0b412258..9beea484 100644 --- a/test/engine/unicode.test.js +++ b/test/engine/unicode.test.js @@ -65,7 +65,7 @@ describe('--unicode', () => { expect((await rr('\\w(.)', '_', config)).out).toBe('🦖\n🦕\n'); }); - test('with --replacement-js', async () => { + test('with --js', async () => { const config = {pipedData: 'a🦖b', unicode: true, replacementJs: true}; expect((await rr('a(.)b', '$1.codePointAt(0).toString(16)', config)).out).toBe('1f996'); }); diff --git a/test/engine/utf8.test.js b/test/engine/utf8.test.js index 7070595e..ac2c2e64 100644 --- a/test/engine/utf8.test.js +++ b/test/engine/utf8.test.js @@ -41,7 +41,7 @@ describe('#735 UTF-8 in the replacement', () => { expect(asUtf8((await rr('§d', '🦖', {pipedData: 'a1b'})).out)).toBe('a🦖b'); }); - test('can come from --replacement-js, where an emoji has the length 2', async () => { + test('can come from --js, where an emoji has the length 2', async () => { const config = {pipedData: 'a🦖', replacementJs: true}; expect(asUtf8((await rr('🦖', '$0 + $0.length', config)).out)).toBe('a🦖2'); }); diff --git a/test/engine/verbose.test.js b/test/engine/verbose.test.js index bcb53ce0..8ce551d6 100644 --- a/test/engine/verbose.test.js +++ b/test/engine/verbose.test.js @@ -72,7 +72,7 @@ describe('--verbose', () => { ); }); - test('with --replacement-js piped data counts as used', async () => { + test('with --js piped data counts as used', async () => { fs.writeFileSync('my.file', 'foobar'); const config = { includeGlob: ['my.file'], diff --git a/test/engine/void-async.test.js b/test/engine/void-async.test.js index 592c3eb8..22d0078b 100644 --- a/test/engine/void-async.test.js +++ b/test/engine/void-async.test.js @@ -85,7 +85,7 @@ describe('--void-async', () => { } ); - test('with --replacement-js and several files', async () => { + test('with --js and several files', async () => { ['a', 'b', 'c'].forEach(name => fs.writeFileSync(name + '.file', 'foo')); await rr('foo', 'name + name', { includeGlob: ['*.file'], diff --git a/test/engine/void-backup.test.js b/test/engine/void-backup.test.js index 3ee1ed1e..c01fe33d 100644 --- a/test/engine/void-backup.test.js +++ b/test/engine/void-backup.test.js @@ -51,7 +51,7 @@ describe('--void-backup', () => { expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar'); }); - test('with --replacement-js', async () => { + test('with --js', async () => { fs.writeFileSync('my.file', 'foobar'); const config = {includeGlob: ['my.file'], voidBackup: true, replacementJs: true}; await rr('foo', 'filename', config); diff --git a/test/engine/void-euro.test.js b/test/engine/void-euro.test.js index c0c64234..3477118e 100644 --- a/test/engine/void-euro.test.js +++ b/test/engine/void-euro.test.js @@ -50,7 +50,7 @@ describe('--void-euro', () => { ); }); - test('with --replacement-js the € is how to get to the groups', async () => { + test('with --js the € is how to get to the groups', async () => { expect( (await rr('(o)', '€1.toUpperCase()', {pipedData: 'fo', replacementJs: true})).out ).toBe('fO'); diff --git a/test/engine/void-global.test.js b/test/engine/void-global.test.js index 5893b81b..861eb05b 100644 --- a/test/engine/void-global.test.js +++ b/test/engine/void-global.test.js @@ -51,7 +51,7 @@ describe('--void-global', () => { expect((await rr('a+', 'x', config)).out).toBe('A+ x a+'); }); - test('with --replacement-js the code only runs for the first match', async () => { + test('with --js the code only runs for the first match', async () => { const config = {pipedData: 'a a a', voidGlobal: true, replacementJs: true}; expect((await rr('a', '$0.toUpperCase()', config)).out).toBe('A a a'); }); diff --git a/test/engine/void-ignore-case.test.js b/test/engine/void-ignore-case.test.js index b8f1444f..f881c204 100644 --- a/test/engine/void-ignore-case.test.js +++ b/test/engine/void-ignore-case.test.js @@ -40,7 +40,7 @@ describe('--void-ignore-case', () => { expect((await rr('F.o', '_', config)).out).toBe('Foo\n'); }); - test('with --replacement-js', async () => { + test('with --js', async () => { const config = {pipedData: 'foo Foo', replacementJs: true, voidIgnoreCase: true}; expect((await rr('F(oo)', '$1.toUpperCase()', config)).out).toBe('foo OO'); }); diff --git a/test/engine/void-multiline.test.js b/test/engine/void-multiline.test.js index 7df40933..1cb34b1a 100644 --- a/test/engine/void-multiline.test.js +++ b/test/engine/void-multiline.test.js @@ -53,7 +53,7 @@ describe('--void-multiline', () => { expect((await rr('^.|.$', '_', config)).out).toBe('a\nf\n'); }); - test('with --replacement-js', async () => { + test('with --js', async () => { const config = {pipedData: 'ab\ncd', voidMultiline: true, replacementJs: true}; expect((await rr('^(.)', '$1.toUpperCase()', config)).out).toBe('Ab\ncd'); }); diff --git a/test/engine/void-section.test.js b/test/engine/void-section.test.js index 5bab9a63..88e6e918 100644 --- a/test/engine/void-section.test.js +++ b/test/engine/void-section.test.js @@ -64,7 +64,7 @@ describe('--void-section', () => { ).toBe('x a\\b'); }); - test('with --replacement-js', async () => { + test('with --js', async () => { const config = {pipedData: 'a1', replacementJs: true}; expect((await rr('§d', '"§n".length', config)).out).toBe('a1'); expect((await rr('§d', '"§n".length', {...config, voidSection: true})).out).toBe('a1'); diff --git a/test/fix/699.test.js b/test/fix/699.test.js index 7b3ac504..0c481c9a 100644 --- a/test/fix/699.test.js +++ b/test/fix/699.test.js @@ -129,7 +129,7 @@ describe('#699 € and § are only aliases in the pattern, the replacement and - expect((await rr('(b)', '[€1§]', {pipedData: 'foobar', ...voided})).out).toBe('foo[€1§]ar'); }); - test('the replacement with --replacement-js', async () => { + test('the replacement with --js', async () => { const config = {pipedData: 'foobar', replacementJs: true}; expect((await rr('(b)', '€1 + "§n"', config)).out).toBe('foob\nar'); }); diff --git a/test/fix/700.test.js b/test/fix/700.test.js index 474fa405..79cd47d8 100644 --- a/test/fix/700.test.js +++ b/test/fix/700.test.js @@ -51,8 +51,8 @@ describe('#700 § is a full substitute for \\ in the replacement', () => { expect((await replaceB('§\\n', '-§')).out).toBe('foo§\nar'); }); - test('--replacement-js still gets § as a backslash in the source', async () => { - expect((await replaceB('"a§nb"', '--replacement-js')).out).toBe('fooa\nbar'); - expect((await replaceB('"a§nb"', '--replacement-js', '--void-section')).out).toBe('fooa§nbar'); + test('--js still gets § as a backslash in the source', async () => { + expect((await replaceB('"a§nb"', '--js')).out).toBe('fooa\nbar'); + expect((await replaceB('"a§nb"', '--js', '--void-section')).out).toBe('fooa§nbar'); }); }); diff --git a/test/fix/726.test.js b/test/fix/726.test.js index f02ca3f1..c5b3df2f 100644 --- a/test/fix/726.test.js +++ b/test/fix/726.test.js @@ -67,7 +67,7 @@ describe('#726 a file given with an absolute path is changed', () => { } ); - test('--replacement-js gets the path of the file as it is', async () => { + test('--js gets the path of the file as it is', async () => { fs.mkdirSync('work'); fs.mkdirSync('other'); const file = path.resolve('other', 'my.file'); diff --git a/test/fix/742.test.js b/test/fix/742.test.js index fd346f15..0c9b8ff6 100644 --- a/test/fix/742.test.js +++ b/test/fix/742.test.js @@ -135,7 +135,7 @@ describe('#742 the engine part of the status', () => { test.each([ ['a replacement', {}, 'x', null], - ['--replacement-js', {replacementJs: true}, null, '"x"'], + ['--js', {replacementJs: true}, null, '"x"'], ['--output-match', {outputMatch: true}, null, null], ])( 'tells what the regex engine was asked to do with %s', From bae6361eff57df356dfa70a27cc9218a45e51f0a Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 17:10:24 +1100 Subject: [PATCH 26/27] Say "/" is not allowed in names --- src/engine.ts | 2 +- test/engine/rename.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index 7724e7f1..08e29d9e 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -378,7 +378,7 @@ function findClashes(plan, renames, slashed, config) { title + items.map(item => '\n From: ' + item.old).join('') + '\n To: ' + items[0].new; const clashes = slashed.map(item => - clash("Names can't contain /. Use --rename-path to move files.", [item]) + clash('"/" is not allowed in names. Use --rename-path to move files.', [item]) ); byTarget.forEach((items, target) => { const [item] = items; diff --git a/test/engine/rename.test.js b/test/engine/rename.test.js index 9c0a844a..2f7ad927 100644 --- a/test/engine/rename.test.js +++ b/test/engine/rename.test.js @@ -53,7 +53,7 @@ describe('--rename', () => { expect(await rr('^', 'sub/', {includeGlob: ['a.md'], rename: true})).toEqual({ out: '', err: - " ❌ Names can't contain /. Use --rename-path to move files.\n From: a.md\n To: sub/a.md\n\n" + + ' ❌ "/" is not allowed in names. Use --rename-path to move files.\n From: a.md\n To: sub/a.md\n\n' + ' ❌ Planned renames clash, so no change is applied to any file\n No further processing.\n\n', exit: 3, }); From 612bd2de48e4454de56828b5108e66ba3f7de360 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 17:47:48 +1100 Subject: [PATCH 27/27] Show how to run code from a file with r() in the README --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index b625a908..0e3e1a62 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,17 @@ Require has been given the alias `r` and both are expanded to understand relativ > rexreplace 'VERSION' 'r("package").version' -j dist/*.js ``` +Longer code can live in its own file. Let the file export a function, and give it the match: + +```js +// fix.js +module.exports = match => match.toUpperCase(); +``` + +```bash +> rexreplace 'foo' 'r("fix")($0)' --js myfile.md +``` + --- #### Reference a matching group