|
1 | 1 | import process from 'node:process'; |
2 | 2 | import path from 'node:path'; |
3 | | -import {promises as fsPromises} from 'node:fs'; |
| 3 | +import {promises as fs} from 'node:fs'; |
4 | 4 | import normalizePath_ from 'normalize-path'; |
5 | 5 | import writeFileAtomic from 'write-file-atomic'; |
6 | 6 | import escapeStringRegexp from 'escape-string-regexp'; |
7 | 7 | import {globby} from 'globby'; |
8 | 8 |
|
9 | 9 | const normalizePath = process.platform === 'win32' ? normalizePath_ : x => x; |
10 | 10 |
|
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 | +}; |
16 | 18 |
|
17 | | - if (filePaths.length === 0) { |
18 | | - return; |
| 19 | +const normalizeRegex = (pattern, forceIgnoreCase) => { |
| 20 | + if (!(pattern instanceof RegExp)) { |
| 21 | + throw new TypeError('Expected a RegExp'); |
19 | 22 | } |
20 | 23 |
|
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'); |
23 | 32 | } |
24 | 33 |
|
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'); |
27 | 58 | } |
28 | 59 |
|
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 | + } |
34 | 66 |
|
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 | + } |
37 | 70 |
|
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 | + } |
40 | 79 |
|
41 | | - if (typeof element === 'string') { |
42 | | - return new RegExp(escapeStringRegexp(element), `g${iFlag}`); |
43 | | - } |
| 80 | + // De-duplicate |
| 81 | + filePaths = [...new Set(filePaths)]; |
44 | 82 |
|
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 | + : []; |
47 | 102 |
|
48 | 103 | const changes = []; |
49 | 104 |
|
50 | 105 | 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; |
52 | 109 |
|
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); |
56 | 112 | } |
57 | 113 |
|
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) { |
59 | 122 | return; |
60 | 123 | } |
61 | 124 |
|
62 | 125 | 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; |
70 | 128 | } |
| 129 | + |
| 130 | + await writeFileAtomic(filePath, nextContent); |
71 | 131 | })); |
72 | 132 |
|
73 | 133 | if (dryRun) { |
|
0 commit comments