| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192 |
- #!/usr/bin/env node
- import { existsSync } from 'node:fs';
- import { readdir, readFile, rename, writeFile } from 'node:fs/promises';
- import { basename, dirname, relative, resolve } from 'node:path';
- import { fileURLToPath } from 'node:url';
- const EXCLUDED_DIRECTORIES = new Set(['.git', '.hg', '.svn', 'node_modules', 'vendor', 'dist', 'target', '__pycache__']);
- const utf8Decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
- const argumentsList = process.argv.slice(2);
- const applyChanges = argumentsList.at(-1) === '--apply';
- if (applyChanges) {
- argumentsList.pop();
- }
- if (argumentsList.length !== 2 || argumentsList[0] === '') {
- console.error('Usage: node replace-text.mjs <old> <new> [--apply]');
- process.exit(1);
- }
- const [oldText, newText] = argumentsList;
- if (newText.includes('/') || newText.includes('\\')) {
- console.error('The new value cannot contain a path separator.');
- process.exit(1);
- }
- const rootDirectory = resolve(process.cwd());
- const scriptPath = resolve(fileURLToPath(import.meta.url));
- const textChanges = new Map();
- const pathChanges = new Map();
- await scanDirectory(rootDirectory);
- const sortedPathChanges = validatePathChanges(pathChanges);
- for (const file of textChanges.keys()) {
- console.log(`${applyChanges ? 'Updating' : 'Would update'} ${displayPath(file)}`);
- }
- for (const [source, target] of sortedPathChanges) {
- console.log(`${applyChanges ? 'Renaming' : 'Would rename'} ${displayPath(source)} to ${displayPath(target)}`);
- }
- if (!applyChanges) {
- console.log('\nDry run only. Re-run with --apply to write the changes.');
- process.exit(0);
- }
- for (const [file, contents] of textChanges) {
- await writeFile(file, contents);
- }
- for (const [source, target] of sortedPathChanges) {
- await rename(source, target);
- }
- console.log(`Replaced text in ${textChanges.size} file(s) and renamed ${pathChanges.size} path(s).`);
- /**
- * Scans a directory tree for text and path replacements.
- *
- * @param {string} directory
- * @returns {Promise<void>}
- */
- async function scanDirectory(directory) {
- let entries;
- try {
- entries = await readdir(directory, { withFileTypes: true });
- } catch {
- console.error(`Unable to read directory: ${displayPath(directory)}.`);
- process.exit(1);
- }
- for (const entry of entries) {
- const path = resolve(directory, entry.name);
- if (path === scriptPath || entry.isSymbolicLink()) {
- continue;
- }
- collectPathChange(path, oldText, newText, pathChanges);
- if (entry.isDirectory()) {
- if (!EXCLUDED_DIRECTORIES.has(entry.name)) {
- await scanDirectory(path);
- }
- continue;
- }
- if (entry.isFile()) {
- await collectTextChange(path, oldText, newText, textChanges);
- }
- }
- }
- /**
- * Records a rename when a file or directory basename contains the old text.
- *
- * @param {string} path
- * @param {string} oldValue
- * @param {string} newValue
- * @param {Map<string, string>} changes
- */
- function collectPathChange(path, oldValue, newValue, changes) {
- if (basename(path).includes(oldValue)) {
- changes.set(path, resolve(dirname(path), basename(path).replaceAll(oldValue, newValue)));
- }
- }
- /**
- * Records a replacement when a file is text and contains the old text.
- *
- * @param {string} path
- * @param {string} oldValue
- * @param {string} newValue
- * @param {Map<string, string>} changes
- * @returns {Promise<void>}
- */
- async function collectTextChange(path, oldValue, newValue, changes) {
- let contents;
- try {
- contents = await readFile(path);
- } catch {
- console.error(`Unable to read file: ${displayPath(path)}.`);
- process.exit(1);
- }
- if (contents.includes(0) || !contents.includes(oldValue)) {
- return;
- }
- const text = decodeUtf8(contents);
- if (text === null) {
- console.warn(`Skipped non-UTF-8 file: ${displayPath(path)}.`);
- return;
- }
- changes.set(path, text.replaceAll(oldValue, newValue));
- }
- /**
- * Decodes a buffer as strict UTF-8, preserving any BOM.
- *
- * @param {Buffer} contents
- * @returns {string|null} Decoded text, or null when the bytes are not valid UTF-8.
- */
- function decodeUtf8(contents) {
- try {
- return utf8Decoder.decode(contents);
- } catch (error) {
- if (error instanceof TypeError) {
- return null;
- }
- throw error;
- }
- }
- /**
- * Validates path targets and orders renames from deepest to shallowest.
- *
- * @param {Map<string, string>} changes
- * @returns {[string, string][]}
- */
- function validatePathChanges(changes) {
- const targets = [...changes.values()];
- if (new Set(targets).size !== targets.length) {
- console.error('The replacement would create duplicate paths.');
- process.exit(1);
- }
- for (const [source, target] of changes) {
- // Case-insensitive filesystems (NTFS, APFS) resolve a case-only target to the source itself.
- const isCaseOnlyRename = source.toLowerCase() === target.toLowerCase();
- if (existsSync(target) && !changes.has(target) && !isCaseOnlyRename) {
- console.error(`Target path already exists: ${displayPath(target)}.`);
- process.exit(1);
- }
- }
- return [...changes].sort(([left], [right]) => right.length - left.length);
- }
- /**
- * Formats an absolute path relative to the scanned directory.
- *
- * @param {string} path
- * @returns {string}
- */
- function displayPath(path) {
- return relative(rootDirectory, path) || '.';
- }
|