replace-text.mjs 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. #!/usr/bin/env node
  2. import { existsSync } from 'node:fs';
  3. import { readdir, readFile, rename, writeFile } from 'node:fs/promises';
  4. import { basename, dirname, relative, resolve } from 'node:path';
  5. import { fileURLToPath } from 'node:url';
  6. const EXCLUDED_DIRECTORIES = new Set(['.git', '.hg', '.svn', 'node_modules', 'vendor', 'dist', 'target', '__pycache__']);
  7. const utf8Decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
  8. const argumentsList = process.argv.slice(2);
  9. const applyChanges = argumentsList.at(-1) === '--apply';
  10. if (applyChanges) {
  11. argumentsList.pop();
  12. }
  13. if (argumentsList.length !== 2 || argumentsList[0] === '') {
  14. console.error('Usage: node replace-text.mjs <old> <new> [--apply]');
  15. process.exit(1);
  16. }
  17. const [oldText, newText] = argumentsList;
  18. if (newText.includes('/') || newText.includes('\\')) {
  19. console.error('The new value cannot contain a path separator.');
  20. process.exit(1);
  21. }
  22. const rootDirectory = resolve(process.cwd());
  23. const scriptPath = resolve(fileURLToPath(import.meta.url));
  24. const textChanges = new Map();
  25. const pathChanges = new Map();
  26. await scanDirectory(rootDirectory);
  27. const sortedPathChanges = validatePathChanges(pathChanges);
  28. for (const file of textChanges.keys()) {
  29. console.log(`${applyChanges ? 'Updating' : 'Would update'} ${displayPath(file)}`);
  30. }
  31. for (const [source, target] of sortedPathChanges) {
  32. console.log(`${applyChanges ? 'Renaming' : 'Would rename'} ${displayPath(source)} to ${displayPath(target)}`);
  33. }
  34. if (!applyChanges) {
  35. console.log('\nDry run only. Re-run with --apply to write the changes.');
  36. process.exit(0);
  37. }
  38. for (const [file, contents] of textChanges) {
  39. await writeFile(file, contents);
  40. }
  41. for (const [source, target] of sortedPathChanges) {
  42. await rename(source, target);
  43. }
  44. console.log(`Replaced text in ${textChanges.size} file(s) and renamed ${pathChanges.size} path(s).`);
  45. /**
  46. * Scans a directory tree for text and path replacements.
  47. *
  48. * @param {string} directory
  49. * @returns {Promise<void>}
  50. */
  51. async function scanDirectory(directory) {
  52. let entries;
  53. try {
  54. entries = await readdir(directory, { withFileTypes: true });
  55. } catch {
  56. console.error(`Unable to read directory: ${displayPath(directory)}.`);
  57. process.exit(1);
  58. }
  59. for (const entry of entries) {
  60. const path = resolve(directory, entry.name);
  61. if (path === scriptPath || entry.isSymbolicLink()) {
  62. continue;
  63. }
  64. collectPathChange(path, oldText, newText, pathChanges);
  65. if (entry.isDirectory()) {
  66. if (!EXCLUDED_DIRECTORIES.has(entry.name)) {
  67. await scanDirectory(path);
  68. }
  69. continue;
  70. }
  71. if (entry.isFile()) {
  72. await collectTextChange(path, oldText, newText, textChanges);
  73. }
  74. }
  75. }
  76. /**
  77. * Records a rename when a file or directory basename contains the old text.
  78. *
  79. * @param {string} path
  80. * @param {string} oldValue
  81. * @param {string} newValue
  82. * @param {Map<string, string>} changes
  83. */
  84. function collectPathChange(path, oldValue, newValue, changes) {
  85. if (basename(path).includes(oldValue)) {
  86. changes.set(path, resolve(dirname(path), basename(path).replaceAll(oldValue, newValue)));
  87. }
  88. }
  89. /**
  90. * Records a replacement when a file is text and contains the old text.
  91. *
  92. * @param {string} path
  93. * @param {string} oldValue
  94. * @param {string} newValue
  95. * @param {Map<string, string>} changes
  96. * @returns {Promise<void>}
  97. */
  98. async function collectTextChange(path, oldValue, newValue, changes) {
  99. let contents;
  100. try {
  101. contents = await readFile(path);
  102. } catch {
  103. console.error(`Unable to read file: ${displayPath(path)}.`);
  104. process.exit(1);
  105. }
  106. if (contents.includes(0) || !contents.includes(oldValue)) {
  107. return;
  108. }
  109. const text = decodeUtf8(contents);
  110. if (text === null) {
  111. console.warn(`Skipped non-UTF-8 file: ${displayPath(path)}.`);
  112. return;
  113. }
  114. changes.set(path, text.replaceAll(oldValue, newValue));
  115. }
  116. /**
  117. * Decodes a buffer as strict UTF-8, preserving any BOM.
  118. *
  119. * @param {Buffer} contents
  120. * @returns {string|null} Decoded text, or null when the bytes are not valid UTF-8.
  121. */
  122. function decodeUtf8(contents) {
  123. try {
  124. return utf8Decoder.decode(contents);
  125. } catch (error) {
  126. if (error instanceof TypeError) {
  127. return null;
  128. }
  129. throw error;
  130. }
  131. }
  132. /**
  133. * Validates path targets and orders renames from deepest to shallowest.
  134. *
  135. * @param {Map<string, string>} changes
  136. * @returns {[string, string][]}
  137. */
  138. function validatePathChanges(changes) {
  139. const targets = [...changes.values()];
  140. if (new Set(targets).size !== targets.length) {
  141. console.error('The replacement would create duplicate paths.');
  142. process.exit(1);
  143. }
  144. for (const [source, target] of changes) {
  145. // Case-insensitive filesystems (NTFS, APFS) resolve a case-only target to the source itself.
  146. const isCaseOnlyRename = source.toLowerCase() === target.toLowerCase();
  147. if (existsSync(target) && !changes.has(target) && !isCaseOnlyRename) {
  148. console.error(`Target path already exists: ${displayPath(target)}.`);
  149. process.exit(1);
  150. }
  151. }
  152. return [...changes].sort(([left], [right]) => right.length - left.length);
  153. }
  154. /**
  155. * Formats an absolute path relative to the scanned directory.
  156. *
  157. * @param {string} path
  158. * @returns {string}
  159. */
  160. function displayPath(path) {
  161. return relative(rootDirectory, path) || '.';
  162. }