Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
e0d4cf9
Handle files in tests with the sync functions of fs
mathiasrw Oct 23, 2026
b86edd9
Write the files in the test itself, also where a helper ran the command
mathiasrw Oct 23, 2026
fb83f6e
Write the files in the test itself in the tests for #726 and #728
mathiasrw Oct 23, 2026
db32953
Start every error with ❌, and make 0 files found a warning
mathiasrw Oct 23, 2026
f96fc00
Write the files in the test itself in the last tests with a helper
mathiasrw Oct 23, 2026
71c2744
Rename die() to panic(), and end a panic with No further processing
mathiasrw Oct 23, 2026
5bb35fc
Give an error in a file exit code 2 and a panic exit code 3
mathiasrw Oct 23, 2026
6905629
Add --rename to search and replace in the path of files
mathiasrw Oct 23, 2026
cba498d
Say planned renames clash, not that a file would be lost
mathiasrw Oct 23, 2026
260d8bb
Test the same new path written in two ways on its own
mathiasrw Oct 23, 2026
d57bd96
Add --expect-files, --expect-diff, --expect-zero-diff and --expect-di…
mathiasrw Oct 23, 2026
332bc99
Merge branch 'fix/780' into fix/782
mathiasrw Oct 23, 2026
821fa68
Show the --expect-* flags in the demo and the README
mathiasrw Oct 23, 2026
242e912
Say the process is halted and no change is applied when planned renam…
mathiasrw Oct 23, 2026
91e32c0
Merge branch 'fix/780' into fix/782
mathiasrw Oct 23, 2026
5889989
Fix --rename clashes with files in the run that keep their name, and …
mathiasrw Oct 23, 2026
a5a63ff
Merge branch 'fix/780' into fix/782
mathiasrw Oct 23, 2026
8442cf2
Move the tests for issues to test/fix, one file for each issue
mathiasrw Oct 23, 2026
92e48ee
Merge branch 'fix/720' into fix/783
mathiasrw Oct 23, 2026
4b69a2f
Bring back the tests for #692 as test/fix/692.test.js
mathiasrw Oct 23, 2026
1ea4254
Merge branch 'fix/783' into fix/780
mathiasrw Oct 23, 2026
a1b4aca
Merge branch 'fix/780' into fix/782
mathiasrw Oct 23, 2026
aec17df
Put failed checks in errors too, and give 1 when they are the only er…
mathiasrw Oct 23, 2026
717d202
Put each path of a rename clash on its own line
mathiasrw Oct 23, 2026
78968ba
Merge branch 'fix/780' into fix/782
mathiasrw Oct 23, 2026
0918434
Name panic() in the devguide and tell that errors start with ❌
mathiasrw Oct 23, 2026
a0f7b83
Merge branch 'fix/720' into fix/783
mathiasrw Oct 23, 2026
184578c
Give the exit codes 2 and 3 in the devguide
mathiasrw Oct 23, 2026
18d5d03
Fix --rename writing over a file whose name differs only in case, and…
mathiasrw Oct 23, 2026
ae22ff0
Merge branch 'fix/780' into fix/782
mathiasrw Oct 23, 2026
240efdc
Keep --output-match with --rename to the matches, and show --rename w…
mathiasrw Oct 23, 2026
20263d9
Merge branch 'fix/780' into fix/782
mathiasrw Oct 23, 2026
308d7de
Split --rename (the name) from --rename-path (the whole path)
mathiasrw Oct 23, 2026
aed1087
Merge branch 'fix/780' into fix/782
mathiasrw Oct 23, 2026
27f2412
Say names can't contain /
mathiasrw Oct 23, 2026
ed69e39
Merge branch 'fix/780' into fix/782
mathiasrw Oct 23, 2026
1bae1e5
Use --js in the docs and tests that are not about --replacement-js
mathiasrw Oct 23, 2026
bae6361
Say "/" is not allowed in names
mathiasrw Oct 23, 2026
2a46f83
Merge branch 'fix/780' into fix/782
mathiasrw Oct 23, 2026
612bd2d
Show how to run code from a file with r() in the README
mathiasrw Oct 23, 2026
a1f27b7
Merge remote-tracking branch 'origin/main' into fix/783
mathiasrw Oct 23, 2026
4cf40cc
Merge branch 'fix/783' into fix/780
mathiasrw Oct 23, 2026
15e3649
Merge branch 'fix/780' into fix/782
mathiasrw Oct 23, 2026
58c1a54
Merge remote-tracking branch 'origin/main' into fix/782
mathiasrw Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions README.md

Large diffs are not rendered by default.

33 changes: 25 additions & 8 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -297,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')
Expand Down Expand Up @@ -372,10 +389,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')
Expand Down Expand Up @@ -586,13 +599,17 @@ 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) {
// 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 (checks) {
process.exitCode = 1;
}
},
e => exitOnPanic(config, e)
Expand Down
55 changes: 54 additions & 1 deletion src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand All @@ -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)) {
Expand All @@ -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) {
Expand Down Expand Up @@ -450,6 +470,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) {
Expand Down
7 changes: 7 additions & 0 deletions src/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,13 @@ export const panic = function (msg = '', data = '', displayHelp = false) {
throw new Panic(text);
};

// 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);
};

// Something failed, and the run goes on. With --halt it is a panic.
export const error = function (msg, data = '') {
const text = msg + (data ? ' ' + data : '');
Expand Down
27 changes: 27 additions & 0 deletions test/cli/expect.test.js
Original file line number Diff line number Diff line change
@@ -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');
}
);
});
10 changes: 5 additions & 5 deletions test/cli/pipe.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
9 changes: 9 additions & 0 deletions test/cli/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,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"
Expand Down
2 changes: 1 addition & 1 deletion test/engine/crossover.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions test/engine/encoding.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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 = {
Expand Down
37 changes: 37 additions & 0 deletions test/engine/expect-diff-all.test.js
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
56 changes: 56 additions & 0 deletions test/engine/expect-diff.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading