From b6f6e35ad43a176b46528616e53d7164c4511096 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 21:03:13 +1100 Subject: [PATCH] Add --replacement-file to take the replacement from a file --- README.md | 19 ++++++ src/cli.ts | 16 +++++ src/engine.ts | 56 +++++++++++++++-- test/cli/run.sh | 5 ++ test/cli/trim.test.js | 14 +++++ test/engine/replacement-file.test.js | 90 +++++++++++++++++++++++++++- test/engine/trim-file.test.js | 20 +++++++ 7 files changed, 212 insertions(+), 8 deletions(-) create mode 100644 test/cli/trim.test.js create mode 100644 test/engine/trim-file.test.js diff --git a/README.md b/README.md index 680f5e0d..957aacac 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,22 @@ Use --js-pipe to access the pipe data in your javascript code. --- +#### Templates + +With `--replacement-file` the replacement is the path to a file, and the content of the file goes in. `--trim-file` takes away the line break at the end of the file. + +```bash +> rexreplace '\{\{footer\}\}' partials/footer.html 'site/*.html' --replacement-file --trim-file +``` + +With `--js` the code gives the path, so each `{{name}}` gets the content of `partials/name.html`: + +```bash +> rexreplace '\{\{(\w+)\}\}' '"partials/" + $1 + ".html"' 'site/*.html' --js --replacement-file --trim-file +``` + +--- + #### Renaming and moving files 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. @@ -293,6 +309,7 @@ If planned renames clash, the process is halted and no change is applied to any | `-V` | **`--verbose`** More chatty output [boolean] | | `-L` | **`--literal`** Literal string search (no regex used when searching) [boolean] | | `-l` | **`--literal-replacement`** Use the replacement as it is. Without it, `$1`, `$&` and the other `$` signs in the replacement mean the groups and the match, as in javascript. [boolean] | +| | **`--replacement-file`** The replacement is the path to a file, and the content of the file is used as the replacement. With --js the code gives the path. [boolean] | | `-I` | **`--void-ignore-case`** Void case insensitive search. Applies to the pattern and to all globs. [boolean] | | `-G` | **`--void-global`** Void global search (stop looking after the first match). [boolean] | | `-s` | **`--dot-all`** Have `.` also match newline. [boolean] | @@ -333,6 +350,8 @@ If planned renames clash, the process is halted and no change is applied to any | | **`--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. The replacement is not used, so --js code is not run. 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] | +| | **`--trim-file`** Trim the content of the replacement file. [boolean] | +| | **`--trim`** Shorthand for --trim-pipe and --trim-file. [boolean] | | | **`--js-pipe`** Treat the replacement as javascript, and give the code the piped data as `pipe`. [boolean] | | `-g` | **`--glob-pipe`** Each line of the piped data is a path or a glob of the files to work on. They are used together with the ones given as arguments. [boolean] | | | **`--null-in`** The piped paths and globs of --glob-pipe are separated by null bytes, like from `find -print0` or --null-out. [boolean] | diff --git a/src/cli.ts b/src/cli.ts index d9899bc4..25ef77b5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -82,6 +82,18 @@ const yargs = require('yargs') ) .alias('l', 'literal-replacement') + .boolean('replacement-file') + .describe( + 'replacement-file', + 'The replacement is the path to a file, and the content of the file is used as the replacement. With --js the code gives the path.' + ) + + .boolean('trim-file') + .describe('trim-file', 'Trim the content of the replacement file.') + + .boolean('trim') + .describe('trim', 'Shorthand for --trim-pipe and --trim-file.') + .boolean('I') .describe('I', 'Void case insensitive search. Applies to the pattern and to all globs.') .alias('I', 'void-ignore-case') @@ -547,6 +559,10 @@ Object.keys(yargs.argv).forEach(key => { } }); +if (config.trim) { + config.trimPipe = config.trimFile = true; +} + if (config.jsPipe) { config.replacementJs = config.pipe = true; } diff --git a/src/engine.ts b/src/engine.ts index be0466aa..f879d67d 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -253,7 +253,7 @@ function doReplacement(_file_rr: string, _config_rr: any, _data_rr: string) { const result = _data_rr.replace( _config_rr.regex, _config_rr.replacementJs && !_config_rr.outputMatch - ? dynamicReplacement(_file_rr, _config_rr, _data_rr) + ? codeReplacement(_file_rr, _config_rr, _data_rr) : _config_rr.replacement ); @@ -342,7 +342,7 @@ function renameFiles(files, config) { chat('Work on the ' + (config.renamePath ? 'path' : 'name') + ' of: ' + file); const replacement = config.replacementJs && !config.outputMatch - ? dynamicReplacement(file, config, text) + ? codeReplacement(file, config, text) : config.replacement; const result = text.replace(config.regex, replacement); if (result === text) { @@ -565,10 +565,6 @@ function getFinalPattern(pattern, conf: any) { } function getFinalReplacement(replacement, conf: any) { - /*if(config.replacementFile){ - return oneLinerFromFile(fs.readFileSync(replacement,'utf8')); - }*/ - if (conf.replacementPipe) { conf.pipedDataUsed = true; if (null === conf.pipedData) { @@ -578,6 +574,11 @@ function getFinalReplacement(replacement, conf: any) { } if (conf.outputMatch) { + if (conf.replacementFile) { + warn( + 'The replacement file is not read with --output-match, as only the matches are printed.' + ); + } if (parseInt(process.versions.node) < 6) { return panic('outputMatch is only supported in node 6+'); } @@ -605,9 +606,52 @@ function getFinalReplacement(replacement, conf: any) { return panic('Captured groups for javascript replacement is only supported in node 6+'); } + // --replacement-file: the replacement is the path to the file with the replacement. + // With --js the code gives the path, so the file is read when the code has run. + if (conf.replacementFile && !conf.replacementJs) { + try { + return readReplacementFile(replacement, conf); + } catch (e) { + return panic('The replacement file can not be read:', replacement); + } + } + return replacement; } +// Each replacement file is read once, also when the code of --js gives the same path for many matches +function readReplacementFile(filepath, conf) { + conf.replacementFiles ??= new Map(); + if (!conf.replacementFiles.has(filepath)) { + chat('Read the replacement file: ' + filepath); + const content = fs.readFileSync(filepath, conf.encoding); + conf.replacementFiles.set(filepath, conf.trimFile ? content.trim() : content); + } + return conf.replacementFiles.get(filepath); +} + +// The code of --js. With --replacement-file its value is the path to the file with the replacement. +function codeReplacement(file, config, text) { + const code = dynamicReplacement(file, config, text); + if (!config.replacementFile) { + return code; + } + // A path that can not be read is an error once, and its matches are left as they are + const failed = new Set(); + return (...args) => { + const filepath = String(code(...args)); + if (!failed.has(filepath)) { + try { + return readReplacementFile(filepath, config); + } catch (e) { + failed.add(filepath); + error('The replacement file can not be read:', filepath); + } + } + return args[0]; + }; +} + /*function oneLinerFromFile(str){ let lines = str.split("\n"); if(lines.length===1){ diff --git a/test/cli/run.sh b/test/cli/run.sh index 152f6319..920beb57 100644 --- a/test/cli/run.sh +++ b/test/cli/run.sh @@ -178,6 +178,11 @@ reset assert "rexreplace '*+*' 'b' my.file -o --literal" 'foobar' +# --replacement-file: the replacement is the path to a file, and its content goes in +reset +echo 'baz' > rep.file +assert "rexreplace 'foo' rep.file my.file -o --replacement-file --trim-file" 'bazbar' + # --literal-replacement (-l) uses the replacement as it is, so `$1` is just text reset assert "rexreplace '(foo)' '€1' my.file -o --literal-replacement" '$1bar' diff --git a/test/cli/trim.test.js b/test/cli/trim.test.js new file mode 100644 index 00000000..079a37fe --- /dev/null +++ b/test/cli/trim.test.js @@ -0,0 +1,14 @@ +// --trim is --trim-pipe and --trim-file together +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {$} from 'bun'; +import {cli, sh} from '../helper.js'; + +describe('--trim', () => { + test('trims the piped data and the content of the replacement file', async () => { + fs.writeFileSync('footer.html', '