From 0a560c5983823340b54976e7882465c5959fdb59 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 15:16:30 +1100 Subject: [PATCH 01/11] Add --rename to search and replace in the path of files --- README.md | 5 +- src/cli.ts | 17 +++- src/engine.ts | 136 +++++++++++++++++++++++++ src/output.ts | 4 +- src/shim.ts | 11 +++ test/cli/rename.test.js | 24 +++++ test/cli/run.sh | 19 ++++ test/engine/rename-force.test.js | 32 ++++++ test/engine/rename.test.js | 164 +++++++++++++++++++++++++++++++ 9 files changed, 404 insertions(+), 8 deletions(-) create mode 100644 src/shim.ts create mode 100644 test/cli/rename.test.js create mode 100644 test/engine/rename-force.test.js create mode 100644 test/engine/rename.test.js diff --git a/README.md b/README.md index b1061590..7a733b38 100644 --- a/README.md +++ b/README.md @@ -260,8 +260,10 @@ Use --js-pipe to access the pipe data in your javascript code. | `-o` | **`--output`** Output the final result instead of saving to file. Will also output content even if no replacement has taken place. [boolean] | | `-A` | **`--void-async`** Handle files in a synchronous flow. Good to limit memory usage when handling large files. [boolean] | | `-B` | **`--void-backup`** Avoid temporary backing up file. Works async (independent of -A flag) and will speed up things but at one point data lives only in memory, and you will lose the content if the process is interrupted. [boolean] | -| `-b` | **`--keep-backup`** Keep a backup file of the original content. [boolean] | +| `-b` | **`--keep-backup`** Keep a backup file of the original content. With --rename the old file is kept. [boolean] | | `-S` | **`--simulate`** Do everything but write the files. The names of the files that would be changed are printed as usual, and no backup is made. [boolean] | +| `-r` | **`--rename`** Search and replace in the path of each file, and rename the file to the result. The content is not touched. A / in the replacement moves the file, and folders that are not there are made. If a rename would lose a file, no file is renamed. [boolean] | +| | **`--rename-force`** With --rename, write over a file that is there already. [boolean] | | `-m` | **`--output-match`** Output each match on a new line. Will not replace any content but you still need to provide a dummy value (like `_`) as replacement parameter. If search pattern does not contain matching groups the full match will be outputted. If search pattern does contain matching groups only matching groups will be outputted (same line with no delimiter). [boolean] | | `-T` | **`--trim-pipe`** Trim piped data before processing. If piped data only consists of chars that can be trimmed (new line, space, tabs...) it will become an empty string. [boolean] | | `-p` | **`--pipe`** Read the piped data and make it available to --replacement-js as `pipe`. [boolean] | @@ -295,6 +297,7 @@ Files identified for potential processing are identified by: - Data can be piped in, both as the text to search and as the replacement - The names of the changed files are printed to stdout, so they can be piped on (to stderr with `--output` and `--output-match`) - Set the delimiter between names and between the content of files +- Rename and move files with a search and replace on their path (`--rename`). A rename that would lose a file stops the run before any file is renamed, and `--simulate` shows each old name next to its new one - See the [release note](https://github.com/mathiasrw/rexreplace/releases) for a log of changes. Descriptions are given in latest patch version. ### Quirks diff --git a/src/cli.ts b/src/cli.ts index adc11505..884d3c7a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -237,7 +237,7 @@ const yargs = require('yargs') .alias('B', 'void-backup') .boolean('b') - .describe('b', 'Keep a backup file of the original content.') + .describe('b', 'Keep a backup file of the original content. With --rename the old file is kept.') .alias('b', 'keep-backup') .boolean('S') @@ -247,6 +247,17 @@ const yargs = require('yargs') ) .alias('S', 'simulate') + .boolean('r') + .describe( + 'r', + 'Search and replace in the path of each file, and rename the file to the result. The content is not touched. A / in the replacement moves the file, and folders that are not there are made. If a rename would lose a file, no file is renamed.' + ) + .alias('r', 'rename') + + .boolean('rename-force') + .describe('rename-force', 'With --rename, write over a file that is there already.') + .implies('rename-force', 'rename') + .boolean('m') .describe( 'm', @@ -373,10 +384,6 @@ All variables, except from module, date objects, \`nl\` and \`_\`, has a corresp /* // Ideas - .boolean('n') - .describe('n', "Do replacement on file path/names instead of file content (rename/move the files)") - .alias('n', 'name') - // https://github.com/eugeneware/replacestream .integer('M') .describe('M', "Maximum length of match. Set this value only if any single file of your ") diff --git a/src/engine.ts b/src/engine.ts index a41b7263..501050c1 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -10,6 +10,8 @@ const now = new Date(); import {outputConfig, chat, info, print, printDone, warn, error, panic} from './output.ts'; +import './shim.ts'; + const re = { mctime: /[mc]time/, colon: /:/g, @@ -55,6 +57,11 @@ export async function engine(config: any = {engine: 'default'}) { function newStatus(config) { const status: any = {files: {searched: [], updated: []}, pipe: null, warnings: [], errors: []}; + // The old and the new path of each file that is renamed, or would be + if (config.rename) { + status.renames = []; + } + // The status report with --verbose also tells what went into the engine and what it did. // The config is copied before it gets the status, and the piped data is told about in `pipe`. if (config.verbose && (config.status || config.statusCompact)) { @@ -130,6 +137,10 @@ async function run(config) { config.contentDelimiterTail = config.contentDelimiterTail ?? config.tail ?? !(piped && config.output); + if (config.rename && piped) { + panic('--rename needs files, as piped data has no name to change'); + } + if (piped) { return doReplacement('Piped data', config, config.pipedData); } @@ -151,6 +162,10 @@ async function run(config) { fs.existsSync(filepath) ? true : error('File not found:', filepath) ); + if (config.rename) { + return renameFiles(files, config); + } + // A limited number of files are in work at the same time, so the memory and the open files stay within bounds. // Each worker takes the next file when it is done with one. Up to 16 files are all handled at once. const workers = config.voidAsync @@ -273,6 +288,127 @@ function updated(file, config) { info(file); } +// --rename: the path of each file is the content that is searched and replaced, and the file is renamed to the result +function renameFiles(files, config) { + const plan = files.map(file => { + config.statusInfo.files.searched.push(file); + chat('Work on the path of: ' + file); + const replacement = + config.replacementJs && !config.outputMatch + ? dynamicReplacement(file, config, file) + : config.replacement; + return {old: file, new: file.replace(config.regex, replacement)}; + }); + const renames = plan.filter(item => item.new !== item.old); + config.statusInfo.renames.push(...renames); + + // With --output-match the matches are printed by the replacement, and the names go to stderr + if (config.outputMatch) { + return plan.forEach(item => + item.new !== item.old ? updated(item.old, config) : config.nameAll && info(item.old) + ); + } + + findClashes(renames, config); + + plan.forEach(item => { + if (item.new === item.old) { + chat('Same name: ' + item.old); + return config.nameAll && nameRename(item, config); + } + // --simulate and --output do everything but rename + if (config.simulate || config.output) { + config.statusInfo.files.updated.push(item.old); + return nameRename(item, config); + } + renameFile(item, config); + }); +} + +// With --simulate and --output the old name goes to stderr and the new one to stdout, so they come in pairs in a terminal +function nameRename(item, config) { + if (config.simulate || config.output) { + info(item.old, process.stderr); + } + info(item.new, process.stdout); +} + +// A rename must not lose a file, so any clash stops the run before a file is renamed. +// The paths are compared as absolute paths, as two relative paths can lead to the same file. +function findClashes(renames, config) { + const byTarget = new Map(); + renames.forEach(item => { + const target = path.resolve(item.new); + byTarget.set(target, (byTarget.get(target) || []).concat(item)); + }); + const sources = new Set(renames.map(item => path.resolve(item.old))); + const targets = new Set(byTarget.keys()); + const ontoRenamed = targets.intersection(sources); + const ontoOthers = targets.difference(sources); + + const clashes = []; + byTarget.forEach((items, target) => { + const [item] = items; + if (1 < items.length) { + clashes.push( + items.map(each => each.old).join(' and ') + + (2 === items.length ? ' would both' : ' would all') + + ' be renamed to ' + + item.new + ); + } else if (ontoRenamed.has(target)) { + clashes.push(item.old + ' would be renamed to ' + item.new + ', which is also renamed'); + } else if (ontoOthers.has(target) && !config.renameForce && isOtherFile(item.old, target)) { + clashes.push(item.old + ' would be renamed to ' + item.new + ', which is there already'); + } + }); + + if (clashes.length) { + clashes.forEach(clash => error(clash)); + panic('No file is renamed, as a rename would lose a file'); + } +} + +// On a file system that is case-insensitive, `A.md` is the same file as `a.md`, so a rename of only the case is not a clash +function isOtherFile(file, target) { + const stats = fs.statSync(target, {throwIfNoEntry: false}); + if (!stats) { + return false; + } + const own = fs.statSync(file); + return own.dev !== stats.dev || own.ino !== stats.ino; +} + +function renameFile(item, config) { + const target = path.resolve(item.new); + try { + const folder = path.dirname(target); + if (!fs.existsSync(folder)) { + chat('Make folder: ' + path.dirname(item.new)); + fs.mkdirSync(folder, {recursive: true}); + } + // Checked again, as the files can have changed since the clashes were found + if (isOtherFile(item.old, target)) { + if (!config.renameForce) { + return error(item.new + ' is there already, so ' + item.old + ' is not renamed'); + } + chat('Write over: ' + item.new); + } + // With --keep-backup the old file is kept, so the file is copied + if (config.keepBackup) { + chat('Copy: ' + item.old + ' to ' + item.new); + fs.copyFileSync(item.old, target); + } else { + chat('Rename: ' + item.old + ' to ' + item.new); + fs.renameSync(item.old, target); + } + } catch (e) { + return error(e); + } + config.statusInfo.files.updated.push(item.old); + info(item.new); +} + // How the piped data was used function pipeType(config, piped) { if (config.globPipe) { diff --git a/src/output.ts b/src/output.ts index 7f50bdaf..ff418041 100644 --- a/src/output.ts +++ b/src/output.ts @@ -62,12 +62,12 @@ export const printDone = function () { }; // The name of a changed file goes to stdout so the names can be piped -export const info = function (name) { +export const info = function (name, stream = null) { if (config.quiet || config.quietTotal) { return; } // stdout is taken when the result is printed, so then the names go to stderr - const stream = config.output || config.outputMatch ? process.stderr : process.stdout; + stream ??= config.output || config.outputMatch ? process.stderr : process.stdout; print(name, config.nameDelimiter, config.nameDelimiterTail, stream); }; diff --git a/src/shim.ts b/src/shim.ts new file mode 100644 index 00000000..3d576fd5 --- /dev/null +++ b/src/shim.ts @@ -0,0 +1,11 @@ +// The Set methods came in Node 22. They are added here for older runtimes, with only what RexReplace uses. + +const proto: any = Set.prototype; + +proto.intersection ??= function (other) { + return new Set([...this].filter(item => other.has(item))); +}; + +proto.difference ??= function (other) { + return new Set([...this].filter(item => !other.has(item))); +}; diff --git a/test/cli/rename.test.js b/test/cli/rename.test.js new file mode 100644 index 00000000..271aeae0 --- /dev/null +++ b/test/cli/rename.test.js @@ -0,0 +1,24 @@ +// --rename (-r) does the search and replace on the path of each file, and renames the file to the result +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {$} from 'bun'; +import {cli, sh} from '../helper.js'; + +describe('--rename', () => { + test('gives the new names to stdout, so they can be piped on', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'foo'); + const result = await sh( + $`bun ${cli} md txt '*.md' -r --null-out | bun ${cli} f x --glob-pipe --null-in -A` + ); + expect(result).toEqual({out: 'a.txt\nb.txt\n', err: '', exit: 0}); + expect(fs.readFileSync('a.txt', 'utf8')).toBe('xoo'); + }); + + test('--rename-force needs --rename', async () => { + fs.writeFileSync('a.md', 'foo'); + const result = await sh($`bun ${cli} md txt a.md --rename-force`); + expect(result.exit).toBe(3); + expect(fs.readdirSync('.')).toEqual(['a.md']); + }); +}); diff --git a/test/cli/run.sh b/test/cli/run.sh index b4ac8284..c6861845 100644 --- a/test/cli/run.sh +++ b/test/cli/run.sh @@ -233,6 +233,25 @@ assert "rexreplace 'oo' 'x' my.file your.file --simulate" 'my.file' assert "cat my.file" 'foobar' +# --rename (-r) does the search and replace on the path, and renames the file to the result +reset +assert "rexreplace '^my' 'our' my.file --rename" 'our.file' +assert "cat our.file" 'foobar' +assert "rexreplace '^' 'sub/' our.file -r" 'sub/our.file' +assert "cat sub/our.file" 'foobar' +rm -rf sub + +# --rename with --simulate prints the old names to stderr and the new ones to stdout +reset +assert "rexreplace '^my' 'our' my.file -r --simulate 2>/dev/null" 'our.file' +assert "rexreplace '^my' 'our' my.file -r --simulate 2>&1 >/dev/null" 'my.file' + +# A rename that would lose a file stops the run before any file is renamed +reset +assert_raises "rexreplace '^(my|your)' 'our' my.file your.file -r" 3 +assert "cat my.file" 'foobar' + + # --full-path prints the full path of each name reset assert "rexreplace 'b' 'x' my.file --full-path" "$(pwd -P)/my.file" diff --git a/test/engine/rename-force.test.js b/test/engine/rename-force.test.js new file mode 100644 index 00000000..2cd09317 --- /dev/null +++ b/test/engine/rename-force.test.js @@ -0,0 +1,32 @@ +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {rr} from '../helper.js'; + +describe('--rename-force', () => { + test('writes over a file that is there already', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('a.txt', 'bar'); + const config = {includeGlob: ['a.md'], rename: true, renameForce: true, verbose: true}; + const result = await rr('md', 'txt', config); + expect(result.out).toBe('a.txt\n'); + expect(result.err).toContain('Write over: a.txt\nRename: a.md to a.txt\n'); + expect(fs.readdirSync('.')).toEqual(['a.txt']); + expect(fs.readFileSync('a.txt', 'utf8')).toBe('foo'); + }); + + test('does not write over a file that is renamed in the same run', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'bar'); + const config = {includeGlob: ['*.md'], rename: true, renameForce: true, replacementJs: true}; + expect((await rr('^[ab]', '$0 == "a" ? "b" : "a"', config)).exit).toBe(3); + expect(fs.readFileSync('a.md', 'utf8')).toBe('foo'); + }); + + test('does not let two files get the same new path', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'bar'); + const config = {includeGlob: ['*.md'], rename: true, renameForce: true}; + expect((await rr('^[ab]', 'c', config)).exit).toBe(3); + expect(fs.readdirSync('.').sort()).toEqual(['a.md', 'b.md']); + }); +}); diff --git a/test/engine/rename.test.js b/test/engine/rename.test.js new file mode 100644 index 00000000..2147335a --- /dev/null +++ b/test/engine/rename.test.js @@ -0,0 +1,164 @@ +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import path from 'path'; +import {rr, rrStatus} from '../helper.js'; + +const files = () => fs.readdirSync('.', {recursive: true}).sort(); + +describe('--rename', () => { + test('renames each file to the result of the search and replace on its path', async () => { + fs.writeFileSync('a.markdown', 'foo'); + fs.writeFileSync('b.markdown', 'bar'); + const config = {includeGlob: ['*.markdown'], rename: true}; + expect(await rr('\\.markdown$', '.md', config)).toEqual({ + out: 'a.md\nb.md\n', + err: '', + exit: null, + }); + expect(files()).toEqual(['a.md', 'b.md']); + expect(fs.readFileSync('a.md', 'utf8')).toBe('foo'); + }); + + test('leaves a file where the pattern changes nothing, and names it with --name-all', async () => { + fs.writeFileSync('a.txt', 'foo'); + fs.writeFileSync('b.md', 'foo'); + const config = {includeGlob: ['*.*'], rename: true}; + expect((await rr('txt', 'text', config)).out).toBe('a.text\n'); + fs.renameSync('a.text', 'a.txt'); + expect((await rr('txt', 'text', {...config, nameAll: true})).out).toBe('a.text\nb.md\n'); + expect(files()).toEqual(['a.text', 'b.md']); + }); + + test('moves a file with a / in the replacement, and makes the folders', async () => { + fs.writeFileSync('a.md', 'foo'); + const result = await rr('^', 'docs/old/', {includeGlob: ['a.md'], rename: true, verbose: true}); + expect(result.out).toBe('docs/old/a.md\n'); + expect(result.err).toContain('Make folder: docs/old\nRename: a.md to docs/old/a.md\n'); + expect(fs.readFileSync('docs/old/a.md', 'utf8')).toBe('foo'); + }); + + test('works on the full path with --full-path', async () => { + fs.writeFileSync('a.md', 'foo'); + const config = {includeGlob: ['a.md'], rename: true, fullPath: true}; + expect((await rr('a\\.md$', 'b.md', config)).out).toBe(path.resolve('b.md') + '\n'); + expect(files()).toEqual(['b.md']); + }); + + test('runs the code of --replacement-js with the variables of the file', async () => { + fs.writeFileSync('a.md', 'foo'); + const config = {includeGlob: ['a.md'], rename: true, replacementJs: true}; + expect((await rr('^.*$', 'name.toUpperCase() + ext', config)).out).toBe('A.md\n'); + }); + + test('renames only the case of a name', async () => { + fs.writeFileSync('a.md', 'foo'); + const config = {includeGlob: ['a.md'], rename: true, voidIgnoreCase: true}; + expect(await rr('a', 'A', config)).toEqual({out: 'A.md\n', err: '', exit: null}); + expect(files()).toEqual(['A.md']); + }); + + test('tells the old and the new paths in the status report', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.txt', 'foo'); + const status = await rrStatus('md', 'txt', {includeGlob: ['a.md', 'b.txt'], rename: true}); + expect(status.renames).toEqual([{old: 'a.md', new: 'a.txt'}]); + expect(status.files).toEqual({searched: ['a.md', 'b.txt'], updated: ['a.md']}); + }); + + describe.each([['simulate'], ['output']])('with --%s', flag => { + test('the old names go to stderr and the new ones to stdout, and nothing is renamed', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'foo'); + const config = {includeGlob: ['*.md'], rename: true, [flag]: true}; + expect(await rr('md', 'txt', config)).toEqual({ + out: 'a.txt\nb.txt\n', + err: 'a.md\nb.md\n', + exit: null, + }); + expect(files()).toEqual(['a.md', 'b.md']); + expect((await rrStatus('md', 'txt', config)).renames).toEqual([ + {old: 'a.md', new: 'a.txt'}, + {old: 'b.md', new: 'b.txt'}, + ]); + }); + }); + + test('with --output-match the matches go to stdout and the names to stderr', async () => { + fs.writeFileSync('a.md', 'foo'); + const config = {includeGlob: ['a.md'], rename: true, outputMatch: true}; + expect(await rr('md', '_', config)).toEqual({out: 'md\n', err: 'a.md\n', exit: null}); + expect(files()).toEqual(['a.md']); + }); +}); + +describe('--rename with --keep-backup', () => { + test('keeps the old file', async () => { + fs.writeFileSync('a.md', 'foo'); + const config = {includeGlob: ['a.md'], rename: true, keepBackup: true, verbose: true}; + const result = await rr('md', 'txt', config); + expect(result.out).toBe('a.txt\n'); + expect(result.err).toContain('Copy: a.md to a.txt\n'); + expect(files()).toEqual(['a.md', 'a.txt']); + expect(fs.readFileSync('a.txt', 'utf8')).toBe('foo'); + }); +}); + +describe('--rename stops the run before any file is renamed, when a rename would lose a file', () => { + const stopped = + ' ❌ No file is renamed, as a rename would lose a file\n No further processing.\n\n'; + + test('when two files would get the same new path, also given in two ways', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'foo'); + const config = {includeGlob: ['a.md', './b.md'], rename: true}; + expect(await rr('^(\\./)?[ab]', 'c', config)).toEqual({ + out: '', + err: ' ❌ ./b.md and a.md would both be renamed to c.md\n\n' + stopped, + exit: 3, + }); + expect(files()).toEqual(['a.md', 'b.md']); + }); + + test('when two files would swap names', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'bar'); + const config = {includeGlob: ['*.md'], rename: true, replacementJs: true}; + expect(await rr('^[ab]', '$0 == "a" ? "b" : "a"', config)).toEqual({ + out: '', + err: + ' ❌ a.md would be renamed to b.md, which is also renamed\n\n' + + ' ❌ b.md would be renamed to a.md, which is also renamed\n\n' + + stopped, + exit: 3, + }); + expect(fs.readFileSync('a.md', 'utf8')).toBe('foo'); + }); + + test('when the new path is a file already, and other files are fine', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('a.txt', 'bar'); + fs.writeFileSync('b.md', 'foo'); + expect(await rr('md', 'txt', {includeGlob: ['*.md'], rename: true})).toEqual({ + out: '', + err: ' ❌ a.md would be renamed to a.txt, which is there already\n\n' + stopped, + exit: 3, + }); + expect(files()).toEqual(['a.md', 'a.txt', 'b.md']); + }); + + test('with --simulate too', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('a.txt', 'bar'); + expect( + (await rr('md', 'txt', {includeGlob: ['a.md'], rename: true, simulate: true})).exit + ).toBe(3); + }); +}); + +describe('--rename can not be used', () => { + test('with piped data, as it has no name', async () => { + const result = await rr('o', 'x', {pipedData: 'foo', rename: true}); + expect(result.err).toStartWith(' ❌ --rename needs files'); + expect(result.exit).toBe(3); + }); +}); From 768832cd7f25a5d66d764a0c5fbb4bfff28877af Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 15:17:03 +1100 Subject: [PATCH 02/11] Say planned renames clash, not that a file would be lost --- README.md | 4 ++-- src/cli.ts | 2 +- src/engine.ts | 4 ++-- test/cli/run.sh | 2 +- test/engine/rename.test.js | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7a733b38..17e1dae8 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,7 @@ Use --js-pipe to access the pipe data in your javascript code. | `-B` | **`--void-backup`** Avoid temporary backing up file. Works async (independent of -A flag) and will speed up things but at one point data lives only in memory, and you will lose the content if the process is interrupted. [boolean] | | `-b` | **`--keep-backup`** Keep a backup file of the original content. With --rename the old file is kept. [boolean] | | `-S` | **`--simulate`** Do everything but write the files. The names of the files that would be changed are printed as usual, and no backup is made. [boolean] | -| `-r` | **`--rename`** Search and replace in the path of each file, and rename the file to the result. The content is not touched. A / in the replacement moves the file, and folders that are not there are made. If a rename would lose a file, no file is renamed. [boolean] | +| `-r` | **`--rename`** Search and replace in the path of each file, and rename the file to the result. The content is not touched. A / in the replacement moves the file, and folders that are not there are made. If planned renames clash, no file is renamed. [boolean] | | | **`--rename-force`** With --rename, write over a file that is there already. [boolean] | | `-m` | **`--output-match`** Output each match on a new line. Will not replace any content but you still need to provide a dummy value (like `_`) as replacement parameter. If search pattern does not contain matching groups the full match will be outputted. If search pattern does contain matching groups only matching groups will be outputted (same line with no delimiter). [boolean] | | `-T` | **`--trim-pipe`** Trim piped data before processing. If piped data only consists of chars that can be trimmed (new line, space, tabs...) it will become an empty string. [boolean] | @@ -297,7 +297,7 @@ Files identified for potential processing are identified by: - Data can be piped in, both as the text to search and as the replacement - The names of the changed files are printed to stdout, so they can be piped on (to stderr with `--output` and `--output-match`) - Set the delimiter between names and between the content of files -- Rename and move files with a search and replace on their path (`--rename`). A rename that would lose a file stops the run before any file is renamed, and `--simulate` shows each old name next to its new one +- Rename and move files with a search and replace on their path (`--rename`). If planned renames clash, the run stops before any file is renamed, and `--simulate` shows each old name next to its new one - See the [release note](https://github.com/mathiasrw/rexreplace/releases) for a log of changes. Descriptions are given in latest patch version. ### Quirks diff --git a/src/cli.ts b/src/cli.ts index 884d3c7a..fb2d0dc7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -250,7 +250,7 @@ const yargs = require('yargs') .boolean('r') .describe( 'r', - 'Search and replace in the path of each file, and rename the file to the result. The content is not touched. A / in the replacement moves the file, and folders that are not there are made. If a rename would lose a file, no file is renamed.' + 'Search and replace in the path of each file, and rename the file to the result. The content is not touched. A / in the replacement moves the file, and folders that are not there are made. If planned renames clash, no file is renamed.' ) .alias('r', 'rename') diff --git a/src/engine.ts b/src/engine.ts index 501050c1..9836aaa6 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -333,7 +333,7 @@ function nameRename(item, config) { info(item.new, process.stdout); } -// A rename must not lose a file, so any clash stops the run before a file is renamed. +// If planned renames clash, the run stops before any file is renamed. // The paths are compared as absolute paths, as two relative paths can lead to the same file. function findClashes(renames, config) { const byTarget = new Map(); @@ -365,7 +365,7 @@ function findClashes(renames, config) { if (clashes.length) { clashes.forEach(clash => error(clash)); - panic('No file is renamed, as a rename would lose a file'); + panic('No file is renamed, as the planned renames clash'); } } diff --git a/test/cli/run.sh b/test/cli/run.sh index c6861845..a7f4d151 100644 --- a/test/cli/run.sh +++ b/test/cli/run.sh @@ -246,7 +246,7 @@ reset assert "rexreplace '^my' 'our' my.file -r --simulate 2>/dev/null" 'our.file' assert "rexreplace '^my' 'our' my.file -r --simulate 2>&1 >/dev/null" 'my.file' -# A rename that would lose a file stops the run before any file is renamed +# If two planned renames clash, the run stops before any file is renamed reset assert_raises "rexreplace '^(my|your)' 'our' my.file your.file -r" 3 assert "cat my.file" 'foobar' diff --git a/test/engine/rename.test.js b/test/engine/rename.test.js index 2147335a..d7b77b26 100644 --- a/test/engine/rename.test.js +++ b/test/engine/rename.test.js @@ -103,9 +103,9 @@ describe('--rename with --keep-backup', () => { }); }); -describe('--rename stops the run before any file is renamed, when a rename would lose a file', () => { +describe('--rename stops the run before any file is renamed, when planned renames clash', () => { const stopped = - ' ❌ No file is renamed, as a rename would lose a file\n No further processing.\n\n'; + ' ❌ No file is renamed, as the planned renames clash\n No further processing.\n\n'; test('when two files would get the same new path, also given in two ways', async () => { fs.writeFileSync('a.md', 'foo'); From f0bf9138734d8ae299288087b21f163ed0125806 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 15:18:48 +1100 Subject: [PATCH 03/11] Test the same new path written in two ways on its own --- test/engine/rename.test.js | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/test/engine/rename.test.js b/test/engine/rename.test.js index d7b77b26..9bc5c907 100644 --- a/test/engine/rename.test.js +++ b/test/engine/rename.test.js @@ -107,18 +107,27 @@ describe('--rename stops the run before any file is renamed, when planned rename const stopped = ' ❌ No file is renamed, as the planned renames clash\n No further processing.\n\n'; - test('when two files would get the same new path, also given in two ways', async () => { + test('when two files would get the same new path', async () => { fs.writeFileSync('a.md', 'foo'); fs.writeFileSync('b.md', 'foo'); - const config = {includeGlob: ['a.md', './b.md'], rename: true}; - expect(await rr('^(\\./)?[ab]', 'c', config)).toEqual({ + expect(await rr('^[ab]', 'c', {includeGlob: ['*.md'], rename: true})).toEqual({ out: '', - err: ' ❌ ./b.md and a.md would both be renamed to c.md\n\n' + stopped, + err: ' ❌ a.md and b.md would both be renamed to c.md\n\n' + stopped, exit: 3, }); expect(files()).toEqual(['a.md', 'b.md']); }); + test('when the same new path is written in two ways', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'foo'); + const config = {includeGlob: ['a.md', './b.md'], rename: true}; + expect((await rr('[ab]\\.md$', 'c.md', config)).err).toBe( + ' ❌ ./b.md and a.md would both be renamed to ./c.md\n\n' + stopped + ); + expect(files()).toEqual(['a.md', 'b.md']); + }); + test('when two files would swap names', async () => { fs.writeFileSync('a.md', 'foo'); fs.writeFileSync('b.md', 'bar'); From 3a72d96066a1c4ffc564c18b5d5533169d1d4ee8 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 15:29:53 +1100 Subject: [PATCH 04/11] Say the process is halted and no change is applied when planned renames clash --- README.md | 4 ++-- src/cli.ts | 2 +- src/engine.ts | 4 ++-- test/engine/rename.test.js | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 17e1dae8..536762a4 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,7 @@ Use --js-pipe to access the pipe data in your javascript code. | `-B` | **`--void-backup`** Avoid temporary backing up file. Works async (independent of -A flag) and will speed up things but at one point data lives only in memory, and you will lose the content if the process is interrupted. [boolean] | | `-b` | **`--keep-backup`** Keep a backup file of the original content. With --rename the old file is kept. [boolean] | | `-S` | **`--simulate`** Do everything but write the files. The names of the files that would be changed are printed as usual, and no backup is made. [boolean] | -| `-r` | **`--rename`** Search and replace in the path of each file, and rename the file to the result. The content is not touched. A / in the replacement moves the file, and folders that are not there are made. If planned renames clash, no file is renamed. [boolean] | +| `-r` | **`--rename`** Search and replace in the path of each file, and rename the file to the result. The content is not touched. A / in the replacement moves the file, and folders that are not there are made. If planned renames clash the process is halted and no change is applied to any file. [boolean] | | | **`--rename-force`** With --rename, write over a file that is there already. [boolean] | | `-m` | **`--output-match`** Output each match on a new line. Will not replace any content but you still need to provide a dummy value (like `_`) as replacement parameter. If search pattern does not contain matching groups the full match will be outputted. If search pattern does contain matching groups only matching groups will be outputted (same line with no delimiter). [boolean] | | `-T` | **`--trim-pipe`** Trim piped data before processing. If piped data only consists of chars that can be trimmed (new line, space, tabs...) it will become an empty string. [boolean] | @@ -297,7 +297,7 @@ Files identified for potential processing are identified by: - Data can be piped in, both as the text to search and as the replacement - The names of the changed files are printed to stdout, so they can be piped on (to stderr with `--output` and `--output-match`) - Set the delimiter between names and between the content of files -- Rename and move files with a search and replace on their path (`--rename`). If planned renames clash, the run stops before any file is renamed, and `--simulate` shows each old name next to its new one +- Rename and move files with a search and replace on their path (`--rename`). If planned renames clash the process is halted and no change is applied to any file, and `--simulate` shows each old name next to its new one - See the [release note](https://github.com/mathiasrw/rexreplace/releases) for a log of changes. Descriptions are given in latest patch version. ### Quirks diff --git a/src/cli.ts b/src/cli.ts index fb2d0dc7..97393036 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -250,7 +250,7 @@ const yargs = require('yargs') .boolean('r') .describe( 'r', - 'Search and replace in the path of each file, and rename the file to the result. The content is not touched. A / in the replacement moves the file, and folders that are not there are made. If planned renames clash, no file is renamed.' + 'Search and replace in the path of each file, and rename the file to the result. The content is not touched. A / in the replacement moves the file, and folders that are not there are made. If planned renames clash the process is halted and no change is applied to any file.' ) .alias('r', 'rename') diff --git a/src/engine.ts b/src/engine.ts index 9836aaa6..eccaea43 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -333,7 +333,7 @@ function nameRename(item, config) { info(item.new, process.stdout); } -// If planned renames clash, the run stops before any file is renamed. +// If planned renames clash the process is halted and no change is applied to any file. // The paths are compared as absolute paths, as two relative paths can lead to the same file. function findClashes(renames, config) { const byTarget = new Map(); @@ -365,7 +365,7 @@ function findClashes(renames, config) { if (clashes.length) { clashes.forEach(clash => error(clash)); - panic('No file is renamed, as the planned renames clash'); + panic('Planned renames clash, so no change is applied to any file'); } } diff --git a/test/engine/rename.test.js b/test/engine/rename.test.js index 9bc5c907..28449e77 100644 --- a/test/engine/rename.test.js +++ b/test/engine/rename.test.js @@ -105,7 +105,7 @@ describe('--rename with --keep-backup', () => { describe('--rename stops the run before any file is renamed, when planned renames clash', () => { const stopped = - ' ❌ No file is renamed, as the planned renames clash\n No further processing.\n\n'; + ' ❌ Planned renames clash, so no change is applied to any file\n No further processing.\n\n'; test('when two files would get the same new path', async () => { fs.writeFileSync('a.md', 'foo'); From 7cf4b7ecfc145bc5b3e546d841b5faaca2304f47 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 15:37:39 +1100 Subject: [PATCH 05/11] Fix --rename clashes with files in the run that keep their name, and with names that differ only in case --- src/engine.ts | 25 +++++++++++++++---------- test/engine/rename-force.test.js | 16 ++++++++++++++++ test/engine/rename.test.js | 27 +++++++++++++++++++++++++-- 3 files changed, 56 insertions(+), 12 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index eccaea43..4c1ac180 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -309,7 +309,7 @@ function renameFiles(files, config) { ); } - findClashes(renames, config); + findClashes(plan, renames, config); plan.forEach(item => { if (item.new === item.old) { @@ -334,17 +334,21 @@ function nameRename(item, config) { } // If planned renames clash the process is halted and no change is applied to any file. -// The paths are compared as absolute paths, as two relative paths can lead to the same file. -function findClashes(renames, config) { +// The paths are compared as absolute paths, as two relative paths can lead to the same file, +// and the case is ignored, as `A.md` and `a.md` are the same file on a file system that is case-insensitive. +const pathKey = file => path.resolve(file).toLowerCase(); + +function findClashes(plan, renames, config) { const byTarget = new Map(); renames.forEach(item => { - const target = path.resolve(item.new); + const target = pathKey(item.new); byTarget.set(target, (byTarget.get(target) || []).concat(item)); }); - const sources = new Set(renames.map(item => path.resolve(item.old))); + // All files in the run, also the ones that keep their name + const inRun = new Set(plan.map(item => pathKey(item.old))); const targets = new Set(byTarget.keys()); - const ontoRenamed = targets.intersection(sources); - const ontoOthers = targets.difference(sources); + const ontoRun = targets.intersection(inRun); + const ontoOthers = targets.difference(inRun); const clashes = []; byTarget.forEach((items, target) => { @@ -356,9 +360,10 @@ function findClashes(renames, config) { ' be renamed to ' + item.new ); - } else if (ontoRenamed.has(target)) { - clashes.push(item.old + ' would be renamed to ' + item.new + ', which is also renamed'); - } else if (ontoOthers.has(target) && !config.renameForce && isOtherFile(item.old, target)) { + } else if (ontoRun.has(target) && pathKey(item.old) !== target) { + // Not even --rename-force writes over a file in the run. A file can still change the case of its own name. + clashes.push(item.old + ' would be renamed to ' + item.new + ', which is a file in this run'); + } else if (ontoOthers.has(target) && !config.renameForce && isOtherFile(item.old, item.new)) { clashes.push(item.old + ' would be renamed to ' + item.new + ', which is there already'); } }); diff --git a/test/engine/rename-force.test.js b/test/engine/rename-force.test.js index 2cd09317..2fdb4f80 100644 --- a/test/engine/rename-force.test.js +++ b/test/engine/rename-force.test.js @@ -22,6 +22,22 @@ describe('--rename-force', () => { expect(fs.readFileSync('a.md', 'utf8')).toBe('foo'); }); + test('does not write over a file in the run that keeps its name', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.txt', 'bar'); + const config = {includeGlob: ['a.md', 'b.txt'], rename: true, renameForce: true}; + expect((await rr('^a\\.md$', 'b.txt', config)).exit).toBe(3); + expect(fs.readFileSync('b.txt', 'utf8')).toBe('bar'); + }); + + test('does not let two new paths differ only in case', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'bar'); + const config = {includeGlob: ['*.md'], rename: true, renameForce: true, replacementJs: true}; + expect((await rr('^[ab]', '$0 == "a" ? "X" : "x"', config)).exit).toBe(3); + expect(fs.readdirSync('.').sort()).toEqual(['a.md', 'b.md']); + }); + test('does not let two files get the same new path', async () => { fs.writeFileSync('a.md', 'foo'); fs.writeFileSync('b.md', 'bar'); diff --git a/test/engine/rename.test.js b/test/engine/rename.test.js index 28449e77..4a48d824 100644 --- a/test/engine/rename.test.js +++ b/test/engine/rename.test.js @@ -135,14 +135,37 @@ describe('--rename stops the run before any file is renamed, when planned rename expect(await rr('^[ab]', '$0 == "a" ? "b" : "a"', config)).toEqual({ out: '', err: - ' ❌ a.md would be renamed to b.md, which is also renamed\n\n' + - ' ❌ b.md would be renamed to a.md, which is also renamed\n\n' + + ' ❌ a.md would be renamed to b.md, which is a file in this run\n\n' + + ' ❌ b.md would be renamed to a.md, which is a file in this run\n\n' + stopped, exit: 3, }); expect(fs.readFileSync('a.md', 'utf8')).toBe('foo'); }); + test('when the new path is a file in the run that keeps its name', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.txt', 'bar'); + expect(await rr('^a\\.md$', 'b.txt', {includeGlob: ['a.md', 'b.txt'], rename: true})).toEqual({ + out: '', + err: ' ❌ a.md would be renamed to b.txt, which is a file in this run\n\n' + stopped, + exit: 3, + }); + expect(fs.readFileSync('b.txt', 'utf8')).toBe('bar'); + }); + + test('when two new paths differ only in case, as they are the same file on some file systems', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'bar'); + const config = {includeGlob: ['*.md'], rename: true, replacementJs: true}; + expect(await rr('^[ab]', '$0 == "a" ? "X" : "x"', config)).toEqual({ + out: '', + err: ' ❌ a.md and b.md would both be renamed to X.md\n\n' + stopped, + exit: 3, + }); + expect(files()).toEqual(['a.md', 'b.md']); + }); + test('when the new path is a file already, and other files are fine', async () => { fs.writeFileSync('a.md', 'foo'); fs.writeFileSync('a.txt', 'bar'); From eac71affe1564a2f288011aaad6ff78ef61d99e2 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 15:44:36 +1100 Subject: [PATCH 06/11] Put each path of a rename clash on its own line --- src/engine.ts | 27 +++++++++++++++++---------- test/engine/rename.test.js | 23 ++++++++++++++++------- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index 4c1ac180..f8419d4d 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -350,21 +350,28 @@ function findClashes(plan, renames, config) { const ontoRun = targets.intersection(inRun); const ontoOthers = targets.difference(inRun); + // Each path on its own line, so long paths can be read + const clash = (title, items) => + title + items.map(item => '\n From: ' + item.old).join('') + '\n To: ' + items[0].new; + const clashes = []; byTarget.forEach((items, target) => { - const [item] = items; if (1 < items.length) { + clashes.push(clash('Renames to the same path are not allowed.', items)); + } else if (ontoRun.has(target) && pathKey(items[0].old) !== target) { + // Not even --rename-force writes over a file in the run. A file can still change the case of its own name. + clashes.push(clash('Renames clashing with other files in this run are not allowed.', items)); + } else if ( + ontoOthers.has(target) && + !config.renameForce && + isOtherFile(items[0].old, items[0].new) + ) { clashes.push( - items.map(each => each.old).join(' and ') + - (2 === items.length ? ' would both' : ' would all') + - ' be renamed to ' + - item.new + clash( + 'Renames clashing with files that are there already are not allowed without --rename-force.', + items + ) ); - } else if (ontoRun.has(target) && pathKey(item.old) !== target) { - // Not even --rename-force writes over a file in the run. A file can still change the case of its own name. - clashes.push(item.old + ' would be renamed to ' + item.new + ', which is a file in this run'); - } else if (ontoOthers.has(target) && !config.renameForce && isOtherFile(item.old, item.new)) { - clashes.push(item.old + ' would be renamed to ' + item.new + ', which is there already'); } }); diff --git a/test/engine/rename.test.js b/test/engine/rename.test.js index 4a48d824..d833c7e8 100644 --- a/test/engine/rename.test.js +++ b/test/engine/rename.test.js @@ -112,7 +112,9 @@ describe('--rename stops the run before any file is renamed, when planned rename fs.writeFileSync('b.md', 'foo'); expect(await rr('^[ab]', 'c', {includeGlob: ['*.md'], rename: true})).toEqual({ out: '', - err: ' ❌ a.md and b.md would both be renamed to c.md\n\n' + stopped, + err: + ' ❌ Renames to the same path are not allowed.\n From: a.md\n From: b.md\n To: c.md\n\n' + + stopped, exit: 3, }); expect(files()).toEqual(['a.md', 'b.md']); @@ -123,7 +125,8 @@ describe('--rename stops the run before any file is renamed, when planned rename fs.writeFileSync('b.md', 'foo'); const config = {includeGlob: ['a.md', './b.md'], rename: true}; expect((await rr('[ab]\\.md$', 'c.md', config)).err).toBe( - ' ❌ ./b.md and a.md would both be renamed to ./c.md\n\n' + stopped + ' ❌ Renames to the same path are not allowed.\n From: ./b.md\n From: a.md\n To: ./c.md\n\n' + + stopped ); expect(files()).toEqual(['a.md', 'b.md']); }); @@ -135,8 +138,8 @@ describe('--rename stops the run before any file is renamed, when planned rename expect(await rr('^[ab]', '$0 == "a" ? "b" : "a"', config)).toEqual({ out: '', err: - ' ❌ a.md would be renamed to b.md, which is a file in this run\n\n' + - ' ❌ b.md would be renamed to a.md, which is a file in this run\n\n' + + ' ❌ Renames clashing with other files in this run are not allowed.\n From: a.md\n To: b.md\n\n' + + ' ❌ Renames clashing with other files in this run are not allowed.\n From: b.md\n To: a.md\n\n' + stopped, exit: 3, }); @@ -148,7 +151,9 @@ describe('--rename stops the run before any file is renamed, when planned rename fs.writeFileSync('b.txt', 'bar'); expect(await rr('^a\\.md$', 'b.txt', {includeGlob: ['a.md', 'b.txt'], rename: true})).toEqual({ out: '', - err: ' ❌ a.md would be renamed to b.txt, which is a file in this run\n\n' + stopped, + err: + ' ❌ Renames clashing with other files in this run are not allowed.\n From: a.md\n To: b.txt\n\n' + + stopped, exit: 3, }); expect(fs.readFileSync('b.txt', 'utf8')).toBe('bar'); @@ -160,7 +165,9 @@ describe('--rename stops the run before any file is renamed, when planned rename const config = {includeGlob: ['*.md'], rename: true, replacementJs: true}; expect(await rr('^[ab]', '$0 == "a" ? "X" : "x"', config)).toEqual({ out: '', - err: ' ❌ a.md and b.md would both be renamed to X.md\n\n' + stopped, + err: + ' ❌ Renames to the same path are not allowed.\n From: a.md\n From: b.md\n To: X.md\n\n' + + stopped, exit: 3, }); expect(files()).toEqual(['a.md', 'b.md']); @@ -172,7 +179,9 @@ describe('--rename stops the run before any file is renamed, when planned rename fs.writeFileSync('b.md', 'foo'); expect(await rr('md', 'txt', {includeGlob: ['*.md'], rename: true})).toEqual({ out: '', - err: ' ❌ a.md would be renamed to a.txt, which is there already\n\n' + stopped, + err: + ' ❌ Renames clashing with files that are there already are not allowed without --rename-force.\n From: a.md\n To: a.txt\n\n' + + stopped, exit: 3, }); expect(files()).toEqual(['a.md', 'a.txt', 'b.md']); From 1bdd29b8ec935164e9f6bcde77c65ed172aadd5d Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 16:07:40 +1100 Subject: [PATCH 07/11] Fix --rename writing over a file whose name differs only in case, and record only the renames that were done --- src/engine.ts | 24 ++++++++------ src/shim.ts | 6 +--- test/engine/rename-force.test.js | 27 ++++++++++++++++ test/engine/rename.test.js | 55 ++++++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 15 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index f8419d4d..677d78dc 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -300,10 +300,10 @@ function renameFiles(files, config) { return {old: file, new: file.replace(config.regex, replacement)}; }); const renames = plan.filter(item => item.new !== item.old); - config.statusInfo.renames.push(...renames); // With --output-match the matches are printed by the replacement, and the names go to stderr if (config.outputMatch) { + config.statusInfo.renames.push(...renames); return plan.forEach(item => item.new !== item.old ? updated(item.old, config) : config.nameAll && info(item.old) ); @@ -319,6 +319,7 @@ function renameFiles(files, config) { // --simulate and --output do everything but rename if (config.simulate || config.output) { config.statusInfo.files.updated.push(item.old); + config.statusInfo.renames.push(item); return nameRename(item, config); } renameFile(item, config); @@ -346,9 +347,8 @@ function findClashes(plan, renames, config) { }); // All files in the run, also the ones that keep their name const inRun = new Set(plan.map(item => pathKey(item.old))); - const targets = new Set(byTarget.keys()); - const ontoRun = targets.intersection(inRun); - const ontoOthers = targets.difference(inRun); + const inRunExact = new Set(plan.map(item => path.resolve(item.old))); + const ontoRun = new Set(byTarget.keys()).intersection(inRun); // Each path on its own line, so long paths can be read const clash = (title, items) => @@ -356,16 +356,19 @@ function findClashes(plan, renames, config) { const clashes = []; byTarget.forEach((items, target) => { + const [item] = items; + // The new path is another file, also when it differs from the old one only in case (on a file system that is case-sensitive) + const other = isOtherFile(item.old, item.new); + const self = pathKey(item.old) === target; if (1 < items.length) { clashes.push(clash('Renames to the same path are not allowed.', items)); - } else if (ontoRun.has(target) && pathKey(items[0].old) !== target) { - // Not even --rename-force writes over a file in the run. A file can still change the case of its own name. - clashes.push(clash('Renames clashing with other files in this run are not allowed.', items)); } else if ( - ontoOthers.has(target) && - !config.renameForce && - isOtherFile(items[0].old, items[0].new) + ontoRun.has(target) && + (!self || (other && inRunExact.has(path.resolve(item.new)))) ) { + // Not even --rename-force writes over a file in the run. A file can still change the case of its own name. + clashes.push(clash('Renames clashing with other files in this run are not allowed.', items)); + } else if (other && !config.renameForce) { clashes.push( clash( 'Renames clashing with files that are there already are not allowed without --rename-force.', @@ -418,6 +421,7 @@ function renameFile(item, config) { return error(e); } config.statusInfo.files.updated.push(item.old); + config.statusInfo.renames.push(item); info(item.new); } diff --git a/src/shim.ts b/src/shim.ts index 3d576fd5..d6142fea 100644 --- a/src/shim.ts +++ b/src/shim.ts @@ -1,11 +1,7 @@ -// The Set methods came in Node 22. They are added here for older runtimes, with only what RexReplace uses. +// Set.prototype.intersection() came in Node 22, so it is added here for older runtimes const proto: any = Set.prototype; proto.intersection ??= function (other) { return new Set([...this].filter(item => other.has(item))); }; - -proto.difference ??= function (other) { - return new Set([...this].filter(item => !other.has(item))); -}; diff --git a/test/engine/rename-force.test.js b/test/engine/rename-force.test.js index 2fdb4f80..52027eba 100644 --- a/test/engine/rename-force.test.js +++ b/test/engine/rename-force.test.js @@ -1,7 +1,18 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; +import os from 'os'; +import path from 'path'; import {rr} from '../helper.js'; +// On a file system that is case-sensitive (like on Linux), `a.md` and `A.md` can be two files +const caseSensitive = (() => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rexreplace-case-')); + fs.writeFileSync(path.join(dir, 'a'), ''); + const result = !fs.existsSync(path.join(dir, 'A')); + fs.rmSync(dir, {recursive: true}); + return result; +})(); + describe('--rename-force', () => { test('writes over a file that is there already', async () => { fs.writeFileSync('a.md', 'foo'); @@ -30,6 +41,22 @@ describe('--rename-force', () => { expect(fs.readFileSync('b.txt', 'utf8')).toBe('bar'); }); + test.if(caseSensitive)( + 'does not write over a file in the run that differs only in case', + async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('A.md', 'bar'); + const config = { + includeGlob: ['a.md', 'A.md'], + rename: true, + renameForce: true, + voidIgnoreCase: true, + }; + expect((await rr('^a', 'A', config)).exit).toBe(3); + expect(fs.readFileSync('A.md', 'utf8')).toBe('bar'); + } + ); + test('does not let two new paths differ only in case', async () => { fs.writeFileSync('a.md', 'foo'); fs.writeFileSync('b.md', 'bar'); diff --git a/test/engine/rename.test.js b/test/engine/rename.test.js index d833c7e8..5c2602f9 100644 --- a/test/engine/rename.test.js +++ b/test/engine/rename.test.js @@ -1,10 +1,20 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; +import os from 'os'; import path from 'path'; import {rr, rrStatus} from '../helper.js'; const files = () => fs.readdirSync('.', {recursive: true}).sort(); +// On a file system that is case-sensitive (like on Linux), `a.md` and `A.md` can be two files +const caseSensitive = (() => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rexreplace-case-')); + fs.writeFileSync(path.join(dir, 'a'), ''); + const result = !fs.existsSync(path.join(dir, 'A')); + fs.rmSync(dir, {recursive: true}); + return result; +})(); + describe('--rename', () => { test('renames each file to the result of the search and replace on its path', async () => { fs.writeFileSync('a.markdown', 'foo'); @@ -65,6 +75,23 @@ describe('--rename', () => { expect(status.files).toEqual({searched: ['a.md', 'b.txt'], updated: ['a.md']}); }); + test('tells in the status report only the renames that were done', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.mkdirSync('locked'); + fs.writeFileSync('locked/b.md', 'foo'); + fs.chmodSync('locked', 0o555); + try { + const status = await rrStatus('md', 'txt', { + includeGlob: ['a.md', 'locked/b.md'], + rename: true, + }); + expect(status.renames).toEqual([{old: 'a.md', new: 'a.txt'}]); + expect(status.errors[0]).toContain('EACCES'); + } finally { + fs.chmodSync('locked', 0o755); + } + }); + describe.each([['simulate'], ['output']])('with --%s', flag => { test('the old names go to stderr and the new ones to stdout, and nothing is renamed', async () => { fs.writeFileSync('a.md', 'foo'); @@ -187,6 +214,34 @@ describe('--rename stops the run before any file is renamed, when planned rename expect(files()).toEqual(['a.md', 'a.txt', 'b.md']); }); + test.if(caseSensitive)( + 'when the new path is another file in the run that differs only in case', + async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('A.md', 'bar'); + const config = {includeGlob: ['a.md', 'A.md'], rename: true, voidIgnoreCase: true}; + expect((await rr('^a', 'A', config)).err).toBe( + ' ❌ Renames clashing with other files in this run are not allowed.\n From: a.md\n To: A.md\n\n' + + stopped + ); + expect(fs.readFileSync('A.md', 'utf8')).toBe('bar'); + } + ); + + test.if(caseSensitive)( + 'when the new path is a file outside the run that differs only in case', + async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('A.md', 'bar'); + const config = {includeGlob: ['a.md'], rename: true, voidIgnoreCase: true}; + expect((await rr('^a', 'A', config)).err).toBe( + ' ❌ Renames clashing with files that are there already are not allowed without --rename-force.\n From: a.md\n To: A.md\n\n' + + stopped + ); + expect(fs.readFileSync('A.md', 'utf8')).toBe('bar'); + } + ); + test('with --simulate too', async () => { fs.writeFileSync('a.md', 'foo'); fs.writeFileSync('a.txt', 'bar'); From 87221818fa53508e432fed98c80f7f9222d62a82 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 16:29:30 +1100 Subject: [PATCH 08/11] Keep --output-match with --rename to the matches, and show --rename with small examples in the README --- README.md | 51 ++++++++++++++++++++++++++++++++++++++ src/engine.ts | 4 +-- test/engine/rename.test.js | 15 ++++++++--- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 536762a4..91b6fbde 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,57 @@ Use --js-pipe to access the pipe data in your javascript code. --- +#### Renaming files + +With `--rename` the search and replace is done on the path of each file, and the file is renamed to the result. The content is not touched. + +```bash +> rexreplace '\.markdown$' '.md' '**/*.markdown' --rename +docs/intro.md +docs/setup.md +``` + +The path is the text, so a `/` in the replacement moves the file. Folders that are not there are made. + +```bash +> rexreplace '^' 'archive/' '*.log' --rename +archive/a.log +archive/b.log +``` + +See what would happen first with `--simulate`. Each old name is printed (to stderr) just before its new name (to stdout), and nothing is renamed. + +```bash +> rexreplace '\.markdown$' '.md' '**/*.markdown' --rename --simulate +docs/intro.markdown +docs/intro.md +docs/setup.markdown +docs/setup.md +``` + +If planned renames clash, the process is halted and no change is applied to any file. + +```bash +> rexreplace '^[ab]' 'c' a.md b.md --rename + ❌ Renames to the same path are not allowed. + From: a.md + From: b.md + To: c.md + + ❌ Planned renames clash, so no change is applied to any file + No further processing. +``` + +`--output-match` shows what the pattern finds in the paths. The replacement is not used, so nothing would be renamed. + +```bash +> rexreplace '\.\w+$' _ 'docs/*' --rename --output-match 2>/dev/null +.md +.md +``` + +--- + ## Usage ```bash diff --git a/src/engine.ts b/src/engine.ts index 677d78dc..1a59d9a9 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -301,9 +301,9 @@ function renameFiles(files, config) { }); const renames = plan.filter(item => item.new !== item.old); - // With --output-match the matches are printed by the replacement, and the names go to stderr + // With --output-match the matches are printed by the replacement, and the names go to stderr. + // The replacement is not used, so nothing would be renamed: no clash check and no renames in the status. if (config.outputMatch) { - config.statusInfo.renames.push(...renames); return plan.forEach(item => item.new !== item.old ? updated(item.old, config) : config.nameAll && info(item.old) ); diff --git a/test/engine/rename.test.js b/test/engine/rename.test.js index 5c2602f9..73e488f1 100644 --- a/test/engine/rename.test.js +++ b/test/engine/rename.test.js @@ -110,11 +110,18 @@ describe('--rename', () => { }); }); - test('with --output-match the matches go to stdout and the names to stderr', async () => { + test('with --output-match the matches go to stdout and the names to stderr, and nothing would be renamed', async () => { fs.writeFileSync('a.md', 'foo'); - const config = {includeGlob: ['a.md'], rename: true, outputMatch: true}; - expect(await rr('md', '_', config)).toEqual({out: 'md\n', err: 'a.md\n', exit: null}); - expect(files()).toEqual(['a.md']); + fs.writeFileSync('b.md', 'foo'); + // The replacement is not used, so `_` gives no clash + const config = {includeGlob: ['*.md'], rename: true, outputMatch: true}; + expect(await rr('^[ab]', '_', config)).toEqual({ + out: 'a\nb\n', + err: 'a.md\nb.md\n', + exit: null, + }); + expect((await rrStatus('^[ab]', '_', config)).renames).toEqual([]); + expect(files()).toEqual(['a.md', 'b.md']); }); }); From a7ecf0cda2576b26b9e60d50ddf4a56fefe8e33f Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 16:47:16 +1100 Subject: [PATCH 09/11] Split --rename (the name) from --rename-path (the whole path) --- README.md | 30 ++++++++--------- src/cli.ts | 15 +++++++-- src/engine.ts | 49 +++++++++++++++++++++------- test/cli/rename.test.js | 8 ++++- test/cli/run.sh | 5 +-- test/engine/rename-path.test.js | 45 +++++++++++++++++++++++++ test/engine/rename.test.js | 58 ++++++++++++++++++--------------- 7 files changed, 151 insertions(+), 59 deletions(-) create mode 100644 test/engine/rename-path.test.js diff --git a/README.md b/README.md index 91b6fbde..2cf3e558 100644 --- a/README.md +++ b/README.md @@ -220,9 +220,9 @@ Use --js-pipe to access the pipe data in your javascript code. --- -#### Renaming files +#### Renaming and moving files -With `--rename` the search and replace is done on the path of each file, and the file is renamed to the result. The content is not touched. +With `--rename` the search and replace is done on the name of each file, and the file is renamed to the result. The folder and the content are not touched. ```bash > rexreplace '\.markdown$' '.md' '**/*.markdown' --rename @@ -230,10 +230,17 @@ docs/intro.md docs/setup.md ``` -The path is the text, so a `/` in the replacement moves the file. Folders that are not there are made. +With `--replacement-js` the name is the `text`: ```bash -> rexreplace '^' 'archive/' '*.log' --rename +> rexreplace '^.*$' 'text.toLowerCase()' 'docs/*' --rename --replacement-js +docs/readme.md +``` + +With `--rename-path` it is done on the whole path, so a `/` in the replacement moves the file. Folders that are not there are made. + +```bash +> rexreplace '^' 'archive/' '*.log' --rename-path archive/a.log archive/b.log ``` @@ -261,14 +268,6 @@ If planned renames clash, the process is halted and no change is applied to any No further processing. ``` -`--output-match` shows what the pattern finds in the paths. The replacement is not used, so nothing would be renamed. - -```bash -> rexreplace '\.\w+$' _ 'docs/*' --rename --output-match 2>/dev/null -.md -.md -``` - --- ## Usage @@ -313,8 +312,9 @@ If planned renames clash, the process is halted and no change is applied to any | `-B` | **`--void-backup`** Avoid temporary backing up file. Works async (independent of -A flag) and will speed up things but at one point data lives only in memory, and you will lose the content if the process is interrupted. [boolean] | | `-b` | **`--keep-backup`** Keep a backup file of the original content. With --rename the old file is kept. [boolean] | | `-S` | **`--simulate`** Do everything but write the files. The names of the files that would be changed are printed as usual, and no backup is made. [boolean] | -| `-r` | **`--rename`** Search and replace in the path of each file, and rename the file to the result. The content is not touched. A / in the replacement moves the file, and folders that are not there are made. If planned renames clash the process is halted and no change is applied to any file. [boolean] | -| | **`--rename-force`** With --rename, write over a file that is there already. [boolean] | +| `-r` | **`--rename`** Search and replace in the name of each file, and rename the file to the result. The folder and the content are not touched. If planned renames clash the process is halted and no change is applied to any file. [boolean] | +| | **`--rename-path`** Like --rename, but on the whole path of each file, so a / in the replacement moves the file. Folders that are not there are made. [boolean] | +| | **`--rename-force`** With --rename or --rename-path, write over a file that is there already. [boolean] | | `-m` | **`--output-match`** Output each match on a new line. Will not replace any content but you still need to provide a dummy value (like `_`) as replacement parameter. If search pattern does not contain matching groups the full match will be outputted. If search pattern does contain matching groups only matching groups will be outputted (same line with no delimiter). [boolean] | | `-T` | **`--trim-pipe`** Trim piped data before processing. If piped data only consists of chars that can be trimmed (new line, space, tabs...) it will become an empty string. [boolean] | | `-p` | **`--pipe`** Read the piped data and make it available to --replacement-js as `pipe`. [boolean] | @@ -348,7 +348,7 @@ Files identified for potential processing are identified by: - Data can be piped in, both as the text to search and as the replacement - The names of the changed files are printed to stdout, so they can be piped on (to stderr with `--output` and `--output-match`) - Set the delimiter between names and between the content of files -- Rename and move files with a search and replace on their path (`--rename`). If planned renames clash the process is halted and no change is applied to any file, and `--simulate` shows each old name next to its new one +- Rename files with a search and replace on their name (`--rename`), or move them with one on their path (`--rename-path`). If planned renames clash the process is halted and no change is applied to any file, and `--simulate` shows each old name next to its new one - See the [release note](https://github.com/mathiasrw/rexreplace/releases) for a log of changes. Descriptions are given in latest patch version. ### Quirks diff --git a/src/cli.ts b/src/cli.ts index 97393036..4fd55707 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -250,13 +250,22 @@ const yargs = require('yargs') .boolean('r') .describe( 'r', - 'Search and replace in the path of each file, and rename the file to the result. The content is not touched. A / in the replacement moves the file, and folders that are not there are made. If planned renames clash the process is halted and no change is applied to any file.' + 'Search and replace in the name of each file, and rename the file to the result. The folder and the content are not touched. If planned renames clash the process is halted and no change is applied to any file.' ) .alias('r', 'rename') + .boolean('rename-path') + .describe( + 'rename-path', + 'Like --rename, but on the whole path of each file, so a / in the replacement moves the file. Folders that are not there are made.' + ) + .conflicts('r', 'rename-path') + .boolean('rename-force') - .describe('rename-force', 'With --rename, write over a file that is there already.') - .implies('rename-force', 'rename') + .describe( + 'rename-force', + 'With --rename or --rename-path, write over a file that is there already.' + ) .boolean('m') .describe( diff --git a/src/engine.ts b/src/engine.ts index 1a59d9a9..05d37c22 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -58,7 +58,7 @@ function newStatus(config) { const status: any = {files: {searched: [], updated: []}, pipe: null, warnings: [], errors: []}; // The old and the new path of each file that is renamed, or would be - if (config.rename) { + if (config.rename || config.renamePath) { status.renames = []; } @@ -137,8 +137,16 @@ async function run(config) { config.contentDelimiterTail = config.contentDelimiterTail ?? config.tail ?? !(piped && config.output); - if (config.rename && piped) { - panic('--rename needs files, as piped data has no name to change'); + if (config.rename && config.renamePath) { + panic('--rename can not be used with --rename-path'); + } + + if (config.renameForce && !config.rename && !config.renamePath) { + panic('--rename-force needs --rename or --rename-path'); + } + + if ((config.rename || config.renamePath) && piped) { + panic('--rename and --rename-path need files, as piped data has no name to change'); } if (piped) { @@ -162,7 +170,7 @@ async function run(config) { fs.existsSync(filepath) ? true : error('File not found:', filepath) ); - if (config.rename) { + if (config.rename || config.renamePath) { return renameFiles(files, config); } @@ -288,16 +296,31 @@ function updated(file, config) { info(file); } -// --rename: the path of each file is the content that is searched and replaced, and the file is renamed to the result +// --rename: the name of each file is the text that is searched and replaced, and the file is renamed to the result. +// --rename-path: the same with the whole path, so the file can be moved. function renameFiles(files, config) { + // With --rename a / in the new name would move the file + const slashed = []; const plan = files.map(file => { config.statusInfo.files.searched.push(file); - chat('Work on the path of: ' + file); + const text = config.renamePath ? file : path.basename(file); + chat('Work on the ' + (config.renamePath ? 'path' : 'name') + ' of: ' + file); const replacement = config.replacementJs && !config.outputMatch - ? dynamicReplacement(file, config, file) + ? dynamicReplacement(file, config, text) : config.replacement; - return {old: file, new: file.replace(config.regex, replacement)}; + const result = text.replace(config.regex, replacement); + if (result === text) { + return {old: file, new: file}; + } + if (config.renamePath) { + return {old: file, new: result}; + } + const item = {old: file, new: path.join(path.dirname(file), result)}; + if (/[\\/]/.test(result)) { + slashed.push(item); + } + return item; }); const renames = plan.filter(item => item.new !== item.old); @@ -309,7 +332,7 @@ function renameFiles(files, config) { ); } - findClashes(plan, renames, config); + findClashes(plan, renames, slashed, config); plan.forEach(item => { if (item.new === item.old) { @@ -339,7 +362,7 @@ function nameRename(item, config) { // and the case is ignored, as `A.md` and `a.md` are the same file on a file system that is case-insensitive. const pathKey = file => path.resolve(file).toLowerCase(); -function findClashes(plan, renames, config) { +function findClashes(plan, renames, slashed, config) { const byTarget = new Map(); renames.forEach(item => { const target = pathKey(item.new); @@ -354,7 +377,11 @@ function findClashes(plan, renames, config) { const clash = (title, items) => title + items.map(item => '\n From: ' + item.old).join('') + '\n To: ' + items[0].new; - const clashes = []; + const clashes = slashed.map(item => + clash('Renames with a / in the new name are not allowed. Use --rename-path to move files.', [ + item, + ]) + ); byTarget.forEach((items, target) => { const [item] = items; // The new path is another file, also when it differs from the old one only in case (on a file system that is case-sensitive) diff --git a/test/cli/rename.test.js b/test/cli/rename.test.js index 271aeae0..3c66a01a 100644 --- a/test/cli/rename.test.js +++ b/test/cli/rename.test.js @@ -15,10 +15,16 @@ describe('--rename', () => { expect(fs.readFileSync('a.txt', 'utf8')).toBe('xoo'); }); - test('--rename-force needs --rename', async () => { + test('--rename-force needs --rename or --rename-path', async () => { fs.writeFileSync('a.md', 'foo'); const result = await sh($`bun ${cli} md txt a.md --rename-force`); expect(result.exit).toBe(3); expect(fs.readdirSync('.')).toEqual(['a.md']); }); + + test('can not be used with --rename-path', async () => { + fs.writeFileSync('a.md', 'foo'); + expect((await sh($`bun ${cli} md txt a.md -r --rename-path`)).exit).toBe(3); + expect(fs.readdirSync('.')).toEqual(['a.md']); + }); }); diff --git a/test/cli/run.sh b/test/cli/run.sh index a7f4d151..30b51a93 100644 --- a/test/cli/run.sh +++ b/test/cli/run.sh @@ -233,11 +233,12 @@ assert "rexreplace 'oo' 'x' my.file your.file --simulate" 'my.file' assert "cat my.file" 'foobar' -# --rename (-r) does the search and replace on the path, and renames the file to the result +# --rename (-r) does the search and replace on the name, and renames the file to the result reset assert "rexreplace '^my' 'our' my.file --rename" 'our.file' assert "cat our.file" 'foobar' -assert "rexreplace '^' 'sub/' our.file -r" 'sub/our.file' +# --rename-path does it on the whole path, so the file can be moved +assert "rexreplace '^' 'sub/' our.file --rename-path" 'sub/our.file' assert "cat sub/our.file" 'foobar' rm -rf sub diff --git a/test/engine/rename-path.test.js b/test/engine/rename-path.test.js new file mode 100644 index 00000000..aa44af8d --- /dev/null +++ b/test/engine/rename-path.test.js @@ -0,0 +1,45 @@ +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import path from 'path'; +import {rr} from '../helper.js'; + +const files = () => fs.readdirSync('.', {recursive: true}).sort(); + +describe('--rename-path', () => { + test('moves a file with a / in the replacement, and makes the folders', async () => { + fs.writeFileSync('a.md', 'foo'); + const result = await rr('^', 'docs/old/', { + includeGlob: ['a.md'], + renamePath: true, + verbose: true, + }); + expect(result.out).toBe('docs/old/a.md\n'); + expect(result.err).toContain('Make folder: docs/old\nRename: a.md to docs/old/a.md\n'); + expect(fs.readFileSync('docs/old/a.md', 'utf8')).toBe('foo'); + }); + + test('works on the full path with --full-path', async () => { + fs.writeFileSync('a.md', 'foo'); + const config = {includeGlob: ['a.md'], renamePath: true, fullPath: true}; + expect((await rr('^.*/a\\.md$', path.resolve('b.md'), config)).out).toBe( + path.resolve('b.md') + '\n' + ); + expect(files()).toEqual(['b.md']); + }); +}); + +describe('--rename-path stops the run before any file is renamed, when planned renames clash', () => { + const stopped = + ' ❌ Planned renames clash, so no change is applied to any file\n No further processing.\n\n'; + + test('when the same new path is written in two ways', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'foo'); + const config = {includeGlob: ['a.md', './b.md'], renamePath: true}; + expect((await rr('[ab]\\.md$', 'c.md', config)).err).toBe( + ' ❌ Renames to the same path are not allowed.\n From: ./b.md\n From: a.md\n To: ./c.md\n\n' + + stopped + ); + expect(files()).toEqual(['a.md', 'b.md']); + }); +}); diff --git a/test/engine/rename.test.js b/test/engine/rename.test.js index 73e488f1..eace2a1c 100644 --- a/test/engine/rename.test.js +++ b/test/engine/rename.test.js @@ -16,7 +16,7 @@ const caseSensitive = (() => { })(); describe('--rename', () => { - test('renames each file to the result of the search and replace on its path', async () => { + test('renames each file to the result of the search and replace on its name', async () => { fs.writeFileSync('a.markdown', 'foo'); fs.writeFileSync('b.markdown', 'bar'); const config = {includeGlob: ['*.markdown'], rename: true}; @@ -39,25 +39,32 @@ describe('--rename', () => { expect(files()).toEqual(['a.text', 'b.md']); }); - test('moves a file with a / in the replacement, and makes the folders', async () => { - fs.writeFileSync('a.md', 'foo'); - const result = await rr('^', 'docs/old/', {includeGlob: ['a.md'], rename: true, verbose: true}); - expect(result.out).toBe('docs/old/a.md\n'); - expect(result.err).toContain('Make folder: docs/old\nRename: a.md to docs/old/a.md\n'); - expect(fs.readFileSync('docs/old/a.md', 'utf8')).toBe('foo'); + test('leaves the folder as it is', async () => { + fs.mkdirSync('docs'); + fs.writeFileSync('docs/docs.md', 'foo'); + expect((await rr('^docs', 'notes', {includeGlob: ['docs/*'], rename: true})).out).toBe( + 'docs/notes.md\n' + ); + expect(files()).toEqual(['docs', 'docs/notes.md']); }); - test('works on the full path with --full-path', async () => { + test('a / in the new name stops the run, as that is --rename-path', async () => { fs.writeFileSync('a.md', 'foo'); - const config = {includeGlob: ['a.md'], rename: true, fullPath: true}; - expect((await rr('a\\.md$', 'b.md', config)).out).toBe(path.resolve('b.md') + '\n'); - expect(files()).toEqual(['b.md']); + expect(await rr('^', 'sub/', {includeGlob: ['a.md'], rename: true})).toEqual({ + out: '', + err: + ' ❌ Renames with a / in the new name are not allowed. Use --rename-path to move files.\n From: a.md\n To: sub/a.md\n\n' + + ' ❌ Planned renames clash, so no change is applied to any file\n No further processing.\n\n', + exit: 3, + }); + expect(files()).toEqual(['a.md']); }); - test('runs the code of --replacement-js with the variables of the file', async () => { - fs.writeFileSync('a.md', 'foo'); - const config = {includeGlob: ['a.md'], rename: true, replacementJs: true}; - expect((await rr('^.*$', 'name.toUpperCase() + ext', config)).out).toBe('A.md\n'); + test('runs the code of --replacement-js with the name as the text', async () => { + fs.mkdirSync('docs'); + fs.writeFileSync('docs/Intro.md', 'foo'); + const config = {includeGlob: ['docs/*'], rename: true, replacementJs: true}; + expect((await rr('^.*$', 'text.toLowerCase()', config)).out).toBe('docs/intro.md\n'); }); test('renames only the case of a name', async () => { @@ -154,17 +161,6 @@ describe('--rename stops the run before any file is renamed, when planned rename expect(files()).toEqual(['a.md', 'b.md']); }); - test('when the same new path is written in two ways', async () => { - fs.writeFileSync('a.md', 'foo'); - fs.writeFileSync('b.md', 'foo'); - const config = {includeGlob: ['a.md', './b.md'], rename: true}; - expect((await rr('[ab]\\.md$', 'c.md', config)).err).toBe( - ' ❌ Renames to the same path are not allowed.\n From: ./b.md\n From: a.md\n To: ./c.md\n\n' + - stopped - ); - expect(files()).toEqual(['a.md', 'b.md']); - }); - test('when two files would swap names', async () => { fs.writeFileSync('a.md', 'foo'); fs.writeFileSync('b.md', 'bar'); @@ -259,9 +255,17 @@ describe('--rename stops the run before any file is renamed, when planned rename }); describe('--rename can not be used', () => { + test('with --rename-path', async () => { + fs.writeFileSync('a.md', 'foo'); + expect( + (await rr('md', 'txt', {includeGlob: ['a.md'], rename: true, renamePath: true})).exit + ).toBe(3); + expect(files()).toEqual(['a.md']); + }); + test('with piped data, as it has no name', async () => { const result = await rr('o', 'x', {pipedData: 'foo', rename: true}); - expect(result.err).toStartWith(' ❌ --rename needs files'); + expect(result.err).toStartWith(' ❌ --rename and --rename-path need files'); expect(result.exit).toBe(3); }); }); From b357dc3efb405fe654ab658255f8951db47df47b Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 17:00:15 +1100 Subject: [PATCH 10/11] Say names can't contain / --- src/engine.ts | 4 +--- test/engine/rename.test.js | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index 05d37c22..7724e7f1 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -378,9 +378,7 @@ function findClashes(plan, renames, slashed, config) { title + items.map(item => '\n From: ' + item.old).join('') + '\n To: ' + items[0].new; const clashes = slashed.map(item => - clash('Renames with a / in the new name are not allowed. Use --rename-path to move files.', [ - item, - ]) + clash("Names can't contain /. Use --rename-path to move files.", [item]) ); byTarget.forEach((items, target) => { const [item] = items; diff --git a/test/engine/rename.test.js b/test/engine/rename.test.js index eace2a1c..9c0a844a 100644 --- a/test/engine/rename.test.js +++ b/test/engine/rename.test.js @@ -53,7 +53,7 @@ describe('--rename', () => { expect(await rr('^', 'sub/', {includeGlob: ['a.md'], rename: true})).toEqual({ out: '', err: - ' ❌ Renames with a / in the new name are not allowed. Use --rename-path to move files.\n From: a.md\n To: sub/a.md\n\n' + + " ❌ Names can't contain /. Use --rename-path to move files.\n From: a.md\n To: sub/a.md\n\n" + ' ❌ Planned renames clash, so no change is applied to any file\n No further processing.\n\n', exit: 3, }); From 56528e9af3a93636f0bccb7124938b9ea7b85351 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 17:10:24 +1100 Subject: [PATCH 11/11] Say "/" is not allowed in names --- src/engine.ts | 2 +- test/engine/rename.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index 7724e7f1..08e29d9e 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -378,7 +378,7 @@ function findClashes(plan, renames, slashed, config) { title + items.map(item => '\n From: ' + item.old).join('') + '\n To: ' + items[0].new; const clashes = slashed.map(item => - clash("Names can't contain /. Use --rename-path to move files.", [item]) + clash('"/" is not allowed in names. Use --rename-path to move files.', [item]) ); byTarget.forEach((items, target) => { const [item] = items; diff --git a/test/engine/rename.test.js b/test/engine/rename.test.js index 9c0a844a..2f7ad927 100644 --- a/test/engine/rename.test.js +++ b/test/engine/rename.test.js @@ -53,7 +53,7 @@ describe('--rename', () => { expect(await rr('^', 'sub/', {includeGlob: ['a.md'], rename: true})).toEqual({ out: '', err: - " ❌ Names can't contain /. Use --rename-path to move files.\n From: a.md\n To: sub/a.md\n\n" + + ' ❌ "/" is not allowed in names. Use --rename-path to move files.\n From: a.md\n To: sub/a.md\n\n' + ' ❌ Planned renames clash, so no change is applied to any file\n No further processing.\n\n', exit: 3, });