Skip to content

Commit b5d18fc

Browse files
committed
Add transform option for custom file transformations
Fixes #5
1 parent 8482e99 commit b5d18fc

File tree

5 files changed

+451
-60
lines changed

5 files changed

+451
-60
lines changed

api.d.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ export type FileChange = {
44
newContent: string;
55
};
66

7+
export type ReplaceInFilesOptions = {
8+
find?: Array<string | RegExp>;
9+
replacement?: string; // Required when `find` exists
10+
ignoreCase?: boolean;
11+
glob?: boolean;
12+
dryRun?: boolean;
13+
transform?: (content: string, filePath: string) => string;
14+
};
15+
716
export default function replaceInFiles(
8-
path: string | string[],
9-
options: {
10-
find: Array<string | RegExp>;
11-
replacement: string;
12-
ignoreCase?: boolean;
13-
glob?: boolean;
14-
dryRun?: boolean;
15-
},
17+
paths: string | readonly string[],
18+
options?: ReplaceInFilesOptions,
1619
): Promise<void | FileChange[]>;

api.js

Lines changed: 98 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,133 @@
11
import process from 'node:process';
22
import path from 'node:path';
3-
import {promises as fsPromises} from 'node:fs';
3+
import {promises as fs} from 'node:fs';
44
import normalizePath_ from 'normalize-path';
55
import writeFileAtomic from 'write-file-atomic';
66
import escapeStringRegexp from 'escape-string-regexp';
77
import {globby} from 'globby';
88

99
const normalizePath = process.platform === 'win32' ? normalizePath_ : x => x;
1010

11-
// TODO(sindresorhus): I will extract this to a separate module at some point when it's more mature.
12-
// `find` is expected to be `Array<string | RegExp>`
13-
// The `ignoreCase` option overrides the `i` flag for regexes in `find`
14-
export default async function replaceInFiler(filePaths, {find, replacement, ignoreCase, glob, dryRun} = {}) {
15-
filePaths = [filePaths].flat();
11+
const toArray = value => {
12+
if (value === undefined) {
13+
return [];
14+
}
15+
16+
return Array.isArray(value) ? value : [value];
17+
};
1618

17-
if (filePaths.length === 0) {
18-
return;
19+
const normalizeRegex = (pattern, forceIgnoreCase) => {
20+
if (!(pattern instanceof RegExp)) {
21+
throw new TypeError('Expected a RegExp');
1922
}
2023

21-
if (find.length === 0) {
22-
throw new Error('Expected at least one `find` pattern');
24+
const flags = new Set(pattern.flags);
25+
flags.add('g');
26+
27+
// Override case handling
28+
if (forceIgnoreCase) {
29+
flags.add('i');
30+
} else if (forceIgnoreCase === false) {
31+
flags.delete('i');
2332
}
2433

25-
if (replacement === undefined) {
26-
throw new Error('The `replacement` option is required');
34+
return new RegExp(pattern.source, [...flags].join(''));
35+
};
36+
37+
/**
38+
* Replace matching strings and regexes in files.
39+
* - If `find` is provided, `replacement` must be a string.
40+
* - If `transform` is provided, it runs after all find/replace operations.
41+
* - When `dryRun` is true, returns a list of proposed changes.
42+
*/
43+
export default async function replaceInFiles(
44+
inputPaths,
45+
{
46+
find,
47+
replacement,
48+
ignoreCase = false,
49+
glob = true,
50+
dryRun = false,
51+
transform,
52+
} = {},
53+
) {
54+
const filePathPatterns = toArray(inputPaths);
55+
56+
if (transform !== undefined && typeof transform !== 'function') {
57+
throw new TypeError('The `transform` option must be a function');
2758
}
2859

29-
// Replace the replacement string with the string unescaped (only one backslash) if it's escaped
30-
replacement = replacement
31-
.replaceAll('\\n', '\n')
32-
.replaceAll('\\r', '\r')
33-
.replaceAll('\\t', '\t');
60+
const hasFind = Array.isArray(find) && find.length > 0;
61+
const hasTransform = typeof transform === 'function';
62+
63+
if (!hasFind && !hasTransform) {
64+
throw new Error('Expected at least one `find` pattern or a `transform` function');
65+
}
3466

35-
// TODO: Drop the `normalizePath` call when `convertPathToPattern` from `fast-glob` is added to globby.
36-
filePaths = glob ? await globby(filePaths.map(filePath => normalizePath(filePath))) : [...new Set(filePaths.map(filePath => normalizePath(path.resolve(filePath))))];
67+
if (hasFind && typeof replacement !== 'string') {
68+
throw new TypeError('Expected `replacement` to be a string when `find` is provided');
69+
}
3770

38-
find = find.map(element => {
39-
const iFlag = ignoreCase ? 'i' : '';
71+
// Resolve file paths
72+
let filePaths;
73+
if (glob) {
74+
const patterns = filePathPatterns.map(p => normalizePath(p));
75+
filePaths = await globby(patterns, {dot: true, absolute: true, expandDirectories: false});
76+
} else {
77+
filePaths = filePathPatterns.map(p => path.resolve(String(p)));
78+
}
4079

41-
if (typeof element === 'string') {
42-
return new RegExp(escapeStringRegexp(element), `g${iFlag}`);
43-
}
80+
// De-duplicate
81+
filePaths = [...new Set(filePaths)];
4482

45-
return new RegExp(element.source, `${element.flags.replace('i', '')}${iFlag}`);
46-
});
83+
// Replace escape sequences in replacement if we have find patterns
84+
if (hasFind && typeof replacement === 'string') {
85+
replacement = replacement
86+
.replaceAll('\\n', '\n')
87+
.replaceAll('\\r', '\r')
88+
.replaceAll('\\t', '\t');
89+
}
90+
91+
// Prepare replacers
92+
const replacers = hasFind
93+
? toArray(find).map(pattern => {
94+
if (typeof pattern === 'string') {
95+
const flags = ignoreCase ? 'gi' : 'g';
96+
return new RegExp(escapeStringRegexp(pattern), flags);
97+
}
98+
99+
return normalizeRegex(pattern, ignoreCase);
100+
})
101+
: [];
47102

48103
const changes = [];
49104

50105
await Promise.all(filePaths.map(async filePath => {
51-
const string = await fsPromises.readFile(filePath, 'utf8');
106+
const originalContent = await fs.readFile(filePath, 'utf8');
107+
108+
let nextContent = originalContent;
52109

53-
let newString = string;
54-
for (const pattern of find) {
55-
newString = newString.replace(pattern, replacement);
110+
for (const rx of replacers) {
111+
nextContent = nextContent.replace(rx, replacement);
56112
}
57113

58-
if (newString === string) {
114+
if (hasTransform) {
115+
nextContent = transform(nextContent, filePath);
116+
if (typeof nextContent !== 'string') {
117+
throw new TypeError('`transform` must return a string');
118+
}
119+
}
120+
121+
if (nextContent === originalContent) {
59122
return;
60123
}
61124

62125
if (dryRun) {
63-
changes.push({
64-
filePath,
65-
originalContent: string,
66-
newContent: newString,
67-
});
68-
} else {
69-
await writeFileAtomic(filePath, newString);
126+
changes.push({filePath, originalContent, newContent: nextContent});
127+
return;
70128
}
129+
130+
await writeFileAtomic(filePath, nextContent);
71131
}));
72132

73133
if (dryRun) {

cli.js

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const cli = meow(`
1111
Options
1212
--regex Regex pattern to find (Can be set multiple times)
1313
--string String to find (Can be set multiple times)
14-
--replacement Replacement string (Required)
14+
--replacement Replacement string (Required when using --regex/--string)
1515
--ignore-case Search case-insensitively
1616
--no-glob Disable globbing
1717
--dry-run Show what would be replaced without making changes
@@ -39,7 +39,6 @@ const cli = meow(`
3939
},
4040
replacement: {
4141
type: 'string',
42-
isRequired: true,
4342
},
4443
ignoreCase: {
4544
type: 'boolean',
@@ -61,11 +60,31 @@ if (cli.input.length === 0) {
6160
process.exit(1);
6261
}
6362

64-
if (!cli.flags.regex && !cli.flags.string) {
63+
const toArray = v => v === undefined ? [] : (Array.isArray(v) ? v : [v]);
64+
65+
const findPatterns = [
66+
...toArray(cli.flags.string),
67+
...toArray(cli.flags.regex).map(rx => new RegExp(rx, 'g')),
68+
];
69+
70+
if (findPatterns.length === 0) {
6571
console.error('Specify at least `--regex` or `--string`');
6672
process.exit(1);
6773
}
6874

75+
if (findPatterns.length > 0 && typeof cli.flags.replacement !== 'string') {
76+
console.error('The `--replacement` option is required when using `--string` or `--regex`');
77+
process.exit(1);
78+
}
79+
80+
const result = await replaceInFiles(cli.input, {
81+
find: findPatterns,
82+
replacement: cli.flags.replacement,
83+
ignoreCase: cli.flags.ignoreCase,
84+
glob: cli.flags.glob,
85+
dryRun: cli.flags.dryRun,
86+
});
87+
6988
function displayDryRunResults(changes) {
7089
if (changes.length === 0) {
7190
console.log('No matches found.');
@@ -113,17 +132,6 @@ function highlightDifferences(text, otherText, color) {
113132
return text.slice(0, start) + styleText(color, text.slice(start, end)) + text.slice(end);
114133
}
115134

116-
const result = await replaceInFiles(cli.input, {
117-
find: [
118-
...cli.flags.string,
119-
...cli.flags.regex.map(regexString => new RegExp(regexString, 'g')),
120-
],
121-
replacement: cli.flags.replacement,
122-
ignoreCase: cli.flags.ignoreCase,
123-
glob: cli.flags.glob,
124-
dryRun: cli.flags.dryRun,
125-
});
126-
127135
if (cli.flags.dryRun) {
128136
displayDryRunResults(result);
129137
}

readme.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,36 @@ $ replace-in-files --help
3939
Real-world use-case: [Bumping version number in a file when publishing to npm](https://github.com/sindresorhus/modern-normalize/commit/c1d65e3f7daba2b695ccf837d2aef19d586d1ca6)
4040

4141
The regex should be [JavaScript flavor](https://www.regular-expressions.info/javascript.html).
42+
43+
## Programmatic API
44+
45+
You can also use this package programmatically:
46+
47+
```js
48+
import replaceInFiles from 'replace-in-files-cli';
49+
50+
// Find and replace
51+
await replaceInFiles('*.js', {
52+
find: ['old', /version \d+/],
53+
replacement: 'new'
54+
});
55+
56+
// Transform entire file content
57+
await replaceInFiles('*.js', {
58+
transform: (content, filePath) => `/* Banner */\n${content}`
59+
});
60+
61+
// Combine find/replace with transform (transform runs after find/replace)
62+
await replaceInFiles('*.js', {
63+
find: ['old'],
64+
replacement: 'new',
65+
transform: (content, filePath) => `/* ${filePath} */\n${content}`
66+
});
67+
```
68+
69+
### Transform Option
70+
71+
The `transform` option provides full control over file content:
72+
- **Standalone**: Use alone for prepend, append, or complex transformations
73+
- **Combined**: Use with `find`/`replacement` - transform runs after find/replace operations
74+
- **Parameters**: Receives `(content, filePath)` for context-aware transformations

0 commit comments

Comments
 (0)