replace-text.mjs 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  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']);
  7. const argumentsList = process.argv.slice(2);
  8. const applyChanges = argumentsList.at(-1) === '--apply';
  9. if (applyChanges) {
  10. argumentsList.pop();
  11. }
  12. if (argumentsList.length !== 2 || argumentsList[0] === '') {
  13. console.error('Usage: node replace-text.mjs <old> <new> [--apply]');
  14. process.exit(1);
  15. }
  16. const [oldText, newText] = argumentsList;
  17. if (newText.includes('/') || newText.includes('\\')) {
  18. console.error('The new value cannot contain a path separator.');
  19. process.exit(1);
  20. }
  21. const rootDirectory = resolve(process.cwd());
  22. const scriptPath = resolve(fileURLToPath(import.meta.url));
  23. const textChanges = new Map();
  24. const pathChanges = new Map();
  25. await scanDirectory(rootDirectory);
  26. const sortedPathChanges = validatePathChanges(pathChanges);
  27. for (const file of textChanges.keys()) {
  28. console.log(`${applyChanges ? 'Updating' : 'Would update'} ${displayPath(file)}`);
  29. }
  30. for (const [source, target] of sortedPathChanges) {
  31. console.log(`${applyChanges ? 'Renaming' : 'Would rename'} ${displayPath(source)} to ${displayPath(target)}`);
  32. }
  33. if (!applyChanges) {
  34. console.log('\nDry run only. Re-run with --apply to write the changes.');
  35. process.exit(0);
  36. }
  37. for (const [file, contents] of textChanges) {
  38. await writeFile(file, contents);
  39. }
  40. for (const [source, target] of sortedPathChanges) {
  41. await rename(source, target);
  42. }
  43. console.log(`Replaced text in ${textChanges.size} file(s) and renamed ${pathChanges.size} path(s).`);
  44. /**
  45. * Scans a directory tree for text and path replacements.
  46. *
  47. * @param {string} directory
  48. * @returns {Promise<void>}
  49. */
  50. async function scanDirectory(directory) {
  51. let entries;
  52. try {
  53. entries = await readdir(directory, { withFileTypes: true });
  54. } catch {
  55. console.error(`Unable to read directory: ${displayPath(directory)}.`);
  56. process.exit(1);
  57. }
  58. for (const entry of entries) {
  59. const path = resolve(directory, entry.name);
  60. if (path === scriptPath || entry.isSymbolicLink()) {
  61. continue;
  62. }
  63. collectPathChange(path, oldText, newText, pathChanges);
  64. if (entry.isDirectory()) {
  65. if (!EXCLUDED_DIRECTORIES.has(entry.name)) {
  66. await scanDirectory(path);
  67. }
  68. continue;
  69. }
  70. if (entry.isFile()) {
  71. await collectTextChange(path, oldText, newText, textChanges);
  72. }
  73. }
  74. }
  75. /**
  76. * Records a rename when a file or directory basename contains the old text.
  77. *
  78. * @param {string} path
  79. * @param {string} oldValue
  80. * @param {string} newValue
  81. * @param {Map<string, string>} changes
  82. */
  83. function collectPathChange(path, oldValue, newValue, changes) {
  84. if (basename(path).includes(oldValue)) {
  85. changes.set(path, resolve(dirname(path), basename(path).replaceAll(oldValue, newValue)));
  86. }
  87. }
  88. /**
  89. * Records a replacement when a file is text and contains the old text.
  90. *
  91. * @param {string} path
  92. * @param {string} oldValue
  93. * @param {string} newValue
  94. * @param {Map<string, string>} changes
  95. * @returns {Promise<void>}
  96. */
  97. async function collectTextChange(path, oldValue, newValue, changes) {
  98. let contents;
  99. try {
  100. contents = await readFile(path);
  101. } catch {
  102. console.error(`Unable to read file: ${displayPath(path)}.`);
  103. process.exit(1);
  104. }
  105. if (!contents.includes(0) && contents.includes(oldValue)) {
  106. changes.set(path, contents.toString().replaceAll(oldValue, newValue));
  107. }
  108. }
  109. /**
  110. * Validates path targets and orders renames from deepest to shallowest.
  111. *
  112. * @param {Map<string, string>} changes
  113. * @returns {[string, string][]}
  114. */
  115. function validatePathChanges(changes) {
  116. const targets = [...changes.values()];
  117. if (new Set(targets).size !== targets.length) {
  118. console.error('The replacement would create duplicate paths.');
  119. process.exit(1);
  120. }
  121. for (const [source, target] of changes) {
  122. if (existsSync(target) && !changes.has(target)) {
  123. console.error(`Target path already exists: ${displayPath(target)}.`);
  124. process.exit(1);
  125. }
  126. }
  127. return [...changes].sort(([left], [right]) => right.length - left.length);
  128. }
  129. /**
  130. * Formats an absolute path relative to the scanned directory.
  131. *
  132. * @param {string} path
  133. * @returns {string}
  134. */
  135. function displayPath(path) {
  136. return relative(rootDirectory, path) || '.';
  137. }