Skip to content

Commit 4e07ca1

Browse files
committed
Add dry run option
Fixes #1
1 parent 9e394e0 commit 4e07ca1

File tree

5 files changed

+113
-4
lines changed

5 files changed

+113
-4
lines changed

api.d.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1+
export type FileChange = {
2+
filePath: string;
3+
originalContent: string;
4+
newContent: string;
5+
};
6+
17
export default function replaceInFiles(
28
path: string | string[],
39
options: {
410
find: Array<string | RegExp>;
511
replacement: string;
612
ignoreCase?: boolean;
713
glob?: boolean;
14+
dryRun?: boolean;
815
},
9-
): Promise<void>;
16+
): Promise<void | FileChange[]>;

api.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const normalizePath = process.platform === 'win32' ? normalizePath_ : x => x;
1111
// TODO(sindresorhus): I will extract this to a separate module at some point when it's more mature.
1212
// `find` is expected to be `Array<string | RegExp>`
1313
// The `ignoreCase` option overrides the `i` flag for regexes in `find`
14-
export default async function replaceInFiler(filePaths, {find, replacement, ignoreCase, glob} = {}) {
14+
export default async function replaceInFiler(filePaths, {find, replacement, ignoreCase, glob, dryRun} = {}) {
1515
filePaths = [filePaths].flat();
1616

1717
if (filePaths.length === 0) {
@@ -45,6 +45,8 @@ export default async function replaceInFiler(filePaths, {find, replacement, igno
4545
return new RegExp(element.source, `${element.flags.replace('i', '')}${iFlag}`);
4646
});
4747

48+
const changes = [];
49+
4850
await Promise.all(filePaths.map(async filePath => {
4951
const string = await fsPromises.readFile(filePath, 'utf8');
5052

@@ -57,6 +59,18 @@ export default async function replaceInFiler(filePaths, {find, replacement, igno
5759
return;
5860
}
5961

60-
await writeFileAtomic(filePath, newString);
62+
if (dryRun) {
63+
changes.push({
64+
filePath,
65+
originalContent: string,
66+
newContent: newString,
67+
});
68+
} else {
69+
await writeFileAtomic(filePath, newString);
70+
}
6171
}));
72+
73+
if (dryRun) {
74+
return changes;
75+
}
6276
}

cli.js

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#!/usr/bin/env node
22
import process from 'node:process';
3+
import {styleText} from 'node:util';
34
import meow from 'meow';
45
import replaceInFiles from './api.js';
56

@@ -13,11 +14,13 @@ const cli = meow(`
1314
--replacement Replacement string (Required)
1415
--ignore-case Search case-insensitively
1516
--no-glob Disable globbing
17+
--dry-run Show what would be replaced without making changes
1618
1719
Examples
1820
$ replace-in-files --string='horse' --regex='unicorn|rainbow' --replacement='🦄' foo.md
1921
$ replace-in-files --regex='v\\d+\\.\\d+\\.\\d+' --replacement=v$npm_package_version foo.css
2022
$ replace-in-files --string='blob' --replacement='blog' 'some/**/[gb]lob/*' '!some/glob/foo'
23+
$ replace-in-files --dry-run --string='old' --replacement='new' file.txt
2124
2225
You can use the same replacement patterns as with \`String#replace()\`, like \`$&\`.
2326
`, {
@@ -43,6 +46,10 @@ const cli = meow(`
4346
type: 'boolean',
4447
default: true,
4548
},
49+
dryRun: {
50+
type: 'boolean',
51+
default: false,
52+
},
4653
},
4754
});
4855

@@ -56,12 +63,64 @@ if (!cli.flags.regex && !cli.flags.string) {
5663
process.exit(1);
5764
}
5865

59-
await replaceInFiles(cli.input, {
66+
function displayDryRunResults(changes) {
67+
if (changes.length === 0) {
68+
console.log('No matches found.');
69+
return;
70+
}
71+
72+
for (const change of changes) {
73+
console.log(`\n${styleText('magenta', change.filePath)}`);
74+
75+
const lines = change.originalContent.split('\n');
76+
const newLines = change.newContent.split('\n');
77+
78+
for (const [index, line] of lines.entries()) {
79+
if (line !== newLines[index]) {
80+
const lineNumber = index + 1;
81+
const linePrefix = styleText('cyan', `${lineNumber}:`);
82+
83+
console.log(`${linePrefix}${highlightDifferences(line, newLines[index], 'red')}`);
84+
console.log(`${linePrefix}${highlightDifferences(newLines[index], line, 'green')}`);
85+
}
86+
}
87+
}
88+
}
89+
90+
function highlightDifferences(text, otherText, color) {
91+
let start = 0;
92+
let end = text.length;
93+
let otherEnd = otherText.length;
94+
95+
// Find common prefix
96+
while (start < Math.min(text.length, otherText.length) && text[start] === otherText[start]) {
97+
start++;
98+
}
99+
100+
// Find common suffix
101+
while (end > start && otherEnd > start && text[end - 1] === otherText[otherEnd - 1]) {
102+
end--;
103+
otherEnd--;
104+
}
105+
106+
if (start >= end) {
107+
return text;
108+
}
109+
110+
return text.slice(0, start) + styleText(color, text.slice(start, end)) + text.slice(end);
111+
}
112+
113+
const result = await replaceInFiles(cli.input, {
60114
find: [
61115
...cli.flags.string,
62116
...cli.flags.regex.map(regexString => new RegExp(regexString, 'g')),
63117
],
64118
replacement: cli.flags.replacement,
65119
ignoreCase: cli.flags.ignoreCase,
66120
glob: cli.flags.glob,
121+
dryRun: cli.flags.dryRun,
67122
});
123+
124+
if (cli.flags.dryRun) {
125+
displayDryRunResults(result);
126+
}

readme.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@ $ replace-in-files --help
2222
--replacement Replacement string (Required)
2323
--ignore-case Search case-insensitively
2424
--no-glob Disable globbing
25+
--dry-run Show what would be replaced without making changes
2526
2627
Examples
2728
$ replace-in-files --string='horse' --regex='unicorn|rainbow' --replacement='🦄' foo.md
2829
$ replace-in-files --regex='v\d+\.\d+\.\d+' --replacement=v$npm_package_version foo.css
2930
$ replace-in-files --string='blob' --replacement='blog' 'some/**/[gb]lob/*' '!some/glob/foo'
31+
$ replace-in-files --dry-run --string='old' --replacement='new' file.txt
3032
3133
You can use the same replacement patterns as with `String#replace()`, like `$&`.
3234
```

test.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,30 @@ test('no globs', async t => {
6363
},
6464
);
6565
});
66+
67+
test('--dry-run shows changes without modifying files', async t => {
68+
const filePath = await tempWrite('foo bar foo');
69+
const {stdout} = await execa('./cli.js', ['--dry-run', '--string=bar', '--replacement=baz', filePath]);
70+
71+
t.is(fs.readFileSync(filePath, 'utf8'), 'foo bar foo');
72+
t.true(stdout.includes(filePath));
73+
t.true(stdout.includes('foo bar foo'));
74+
t.true(stdout.includes('foo baz foo'));
75+
});
76+
77+
test('--dry-run with no matches', async t => {
78+
const filePath = await tempWrite('foo bar foo');
79+
const {stdout} = await execa('./cli.js', ['--dry-run', '--string=notfound', '--replacement=replacement', filePath]);
80+
81+
t.is(fs.readFileSync(filePath, 'utf8'), 'foo bar foo');
82+
t.is(stdout.trim(), 'No matches found.');
83+
});
84+
85+
test('--dry-run with regex', async t => {
86+
const filePath = await tempWrite('version 1.2.3 here');
87+
const {stdout} = await execa('./cli.js', ['--dry-run', '--regex=\\d+\\.\\d+\\.\\d+', '--replacement=2.0.0', filePath]);
88+
89+
t.is(fs.readFileSync(filePath, 'utf8'), 'version 1.2.3 here');
90+
t.true(stdout.includes('version 1.2.3 here'));
91+
t.true(stdout.includes('version 2.0.0 here'));
92+
});

0 commit comments

Comments
 (0)