From d3ef1765b70438ae9903e7cc13a6da43d48e4012 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Wed, 21 Oct 2026 15:09:16 +1100 Subject: [PATCH 1/4] WIP --- README.md | 61 ++++- devguide.md | 48 ++++ src/alias.ts | 23 ++ src/cli.ts | 95 ++++++- src/engine.ts | 97 ++++--- src/output.ts | 80 +++++- test/cli/run.sh | 152 ++++++++++- test/fuzzy/basics.test.js | 4 +- test/fuzzy/crossover.test.js | 25 +- test/fuzzy/output-match.test.js | 4 +- test/fuzzy/output.test.js | 33 ++- test/fuzzy/replacement-pipe.test.js | 4 +- test/js/basics.test.js | 28 +- test/js/crossover.test.js | 8 +- test/js/debug.test.js | 12 +- test/js/dot-all.test.js | 2 +- test/js/encoding.test.js | 4 +- test/js/exclude-glob.test.js | 2 +- test/js/exclude-re.test.js | 4 +- test/js/fix/682.test.js | 2 +- test/js/fix/683.test.js | 2 +- test/js/fix/691.test.js | 20 +- test/js/fix/699.test.js | 4 +- test/js/fix/703.test.js | 399 ++++++++++++++++++++++++++++ test/js/fix/705.test.js | 4 +- test/js/fix/719.test.js | 25 +- test/js/fix/726.test.js | 8 +- test/js/halt.test.js | 2 +- test/js/helper.js | 18 +- test/js/literal.test.js | 2 +- test/js/output-match.test.js | 34 ++- test/js/output.test.js | 24 +- test/js/quiet.test.js | 4 +- test/js/replacement-js.test.js | 15 +- test/js/replacement-pipe.test.js | 26 +- test/js/unicode.test.js | 2 +- test/js/verbose.test.js | 14 +- test/js/void-async.test.js | 33 ++- test/js/void-backup.test.js | 4 +- test/js/void-euro.test.js | 4 +- test/js/void-global.test.js | 2 +- test/js/void-ignore-case.test.js | 2 +- test/js/void-multiline.test.js | 2 +- 43 files changed, 1114 insertions(+), 224 deletions(-) create mode 100644 devguide.md create mode 100644 src/alias.ts create mode 100644 test/js/fix/703.test.js diff --git a/README.md b/README.md index 2df0357f..bff9e438 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,49 @@ Add creation time, name of the file and human readable file size as the first li > rexreplace '^' 'ctime_ + name_ + size + nl' -j -M 'dist/**/*.*' ``` +**Use the names of the affected files** + +The name of each affected file is printed to stdout, one name on each line. Add all the affected files to git: + +```bash +> rexreplace 'foo' 'bar' '**/*.md' | xargs git add +``` + +Use `--null` if a name can have a space or a line break in it: + +```bash +> rexreplace 'foo' 'bar' '**/*.md' --null | xargs -0 git add +``` + +Pick what to put after each name with `--name-delimiter`. Use `--no-name-delimiter-tail` to only have it between the names: + +```bash +> rexreplace 'foo' 'bar' '*.md' --name-delimiter ', ' --no-name-delimiter-tail +a.md, b.md +``` + +Get the full path of each file with `--absolute-path` (`-a`), and also the files where nothing was changed with `--name-all`: + +```bash +> rexreplace 'foo' 'bar' 'docs/*.md' --absolute-path +/Users/me/project/docs/a.md +/Users/me/project/docs/b.md +``` + +With `--output` or `--output-match` stdout has the result, so the names are printed to stderr. The content of each file with `--output`, and each match with `--output-match`, ends with a line break, unless it already does. Piped data is left as it is. Pick something else with `--content-delimiter`, or use `--content-delimiter ''` to join them: + +```bash +> rexreplace 'foo' 'bar' '*.md' --output --content-delimiter='---\n' 2>/dev/null +bar +--- +bar +--- +nothing +--- +``` + +`--delimiter` sets both delimiters, and `--null` is the same as `--delimiter '\0'`. The most specific flag wins, so `--null --name-delimiter ', '` gives `, ` after each name and a NUL byte between the content of each file. `--tail` puts the delimiter after the last name and the last content too, and `--no-tail` only between them. `--nl` (`-l`) makes sure what is printed to stdout and stderr ends with a line break, also for piped data, so `printf foo | rexreplace o x --nl` gives `fxx` on its own line. + **Matching pairs** Let both `"foo 'is' bar"` and `' foo "was" bar'` become `» foo ... bar «` independent of using `'` or `"` @@ -186,10 +229,20 @@ Let both `"foo 'is' bar"` and `' foo "was" bar'` become `» foo ... bar «` inde | `-E` | **`--engine`** What regex engine to use. `default` is the engine of the runtime: `V8` with Node and `JSC` (JavaScriptCore) with Bun. The engine of the other runtime can not be picked. [choices: "default", "V8", "JSC"] [default: "default"] | | `-q` | **`--quiet`** Only display errors (no other info) [boolean] | | `-Q` | **`--quiet-total`** Never display errors or info [boolean] | +| `-a` | **`--absolute-path`** Print the full path of affected files instead of the relative path. [boolean] | +| | **`--name-all`** Print the names of all identified files instead of just the files with content matching the search. [boolean] | +| | **`--name-delimiter`** String printed between the names of the relevant files. Default is a line break, unless the name already ends with one. [string] | +| | **`--content-delimiter`** String printed between the content of each file for --output, or between the matches for --output-match. Default is a line break, unless the content already ends with one. [string] | +| | **`--delimiter`** Sets --name-delimiter and --content-delimiter to the same value. Supports escape sequences like `\t`, `\n` and the `€` and `§` aliases. [string] | +| | **`--null`** Shorthand for --delimiter '\0'. Useful for piping content with spaces or line breaks (like for `xargs -0`). The specific delimiter values win over --delimiter, and --delimiter wins over --null. [boolean] | +| | **`--name-delimiter-tail`** Print the name delimiter after the last name. Defaults to true. Use --no-name-delimiter-tail or --name-delimiter-tail=false to not print the delimiter after the last name. [boolean] | +| | **`--content-delimiter-tail`** Print the content delimiter after the last content. Defaults to true, but to false for piped data so it is left as it is. [boolean] | +| | **`--tail`** Shorthand for setting both --name-delimiter-tail and --content-delimiter-tail. Use --no-tail to not print the delimiter after the last name and the last content. The specific tail flags win over --tail. [boolean] | +| `-l` | **`--nl`** Let what is printed to stdout and stderr (the names, or the content from -o, -m or piped data) always end with a new line. No extra new line if it already ends with a new line, or if nothing was printed. [boolean] | | `-H` | **`--halt`**, **`--bail`** Halt on first error [boolean] [default: false] | | `-d` | **`--debug`** Print debug info [boolean] | -| `-€` | **`--void-euro`** Void having `€` as alias for `$` in the pattern, replacement and --exclude-re parameters (the alias is never used for globs or piped data) [boolean] | -| `-§` | **`--void-section`** Void having `§` as alias for `\` in the pattern, replacement and --exclude-re parameters (the alias is never used for globs or piped data) [boolean] | +| `-€` | **`--void-euro`** Void having `€` as alias for `$`. The alias is used in all inputs, but never in globs, file names or piped data. [boolean] | +| `-§` | **`--void-section`** Void having `§` as alias for `\`. The alias is used in all inputs, but never in globs, file names or piped data. [boolean] | | `-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] | @@ -212,6 +265,8 @@ Let both `"foo 'is' bar"` and `' foo "was" bar'` become `» foo ... bar «` inde - Supports regex lookaheads in the pattern - Supports backreference to matching groups in the replacement - Data to be treated can be piped in (Ignore piped data via `-P`) +- The names of the affected files are printed to stdout, so they can be piped on (to stderr with `--output` and `--output-match`) +- Pick the delimiter between names and between the content of files, like `--null` for `xargs -0` - See the [release note](https://github.com/mathiasrw/rexreplace/releases) for a log of changes. Descriptions are given in latest patch version. ### Quirks @@ -222,7 +277,7 @@ Let both `"foo 'is' bar"` and `' foo "was" bar'` become `» foo ... bar «` inde - Per default `§` is treated as an alias for `\` in the pattern, the replacement and `--exclude-re`. The main reason is for you not to worry about escaping the escape of an escape. Use the `-§` flag if you need to search or replace the actual section char. -- The `€` and `§` aliases are only for what you write as pattern, replacement (also with `--replacement-js`) and `--exclude-re`. File globs, `--exclude-glob` and piped data (both content to search and a replacement given with `--replacement-pipe`) are used exactly as they are, so a file named `price€.txt` is found without any flags. +- The `€` and `§` aliases are only for what you write as pattern, replacement (also with `--replacement-js`), `--exclude-re` and the delimiters. File globs, `--exclude-glob` and piped data (both content to search and a replacement given with `--replacement-pipe`) are used exactly as they are, so a file named `price€.txt` is found without any flags. - RexReplace reads from stdin whenever stdin is not a terminal, and waits for it to close. Some environments (CI runners, cron, `docker exec`, editors spawning processes) leave stdin open without ever writing to it, which makes the command hang. Add the `--void-pipe` flag (`-P`) to ignore piped data. Can also be set with the environment variable `RR_VOID_PIPE`: diff --git a/devguide.md b/devguide.md new file mode 100644 index 00000000..f2ee143b --- /dev/null +++ b/devguide.md @@ -0,0 +1,48 @@ +# Dev guide + +How we like the code in RexReplace to be. + +## The code + +- **Slim and simple over features.** If a feature makes the code complicated, we drop it instead of building it. +- **Beautiful code: strict, lean, focused.** Each function does one thing. No extra options or "just in case" code. +- **Read like the code around it.** Use the same names, idioms and amount of comments as the code next to it. +- **Comments say why, not what.** Write them in plain, simple English. Leave the comment out if the code already says it. +- **No sloppy shortcuts.** If two cases need different calls, write a plain `if`/`else` instead of one call that only half fits both. +- **Everything is printed with `write()` in `src/output.ts`,** so `--nl` knows how each stream ends. +- **stdout is the result.** That is the names of the affected files, or the content from `--output`, `--output-match` or piped data. Everything else goes to stderr, so the result can always be piped. + +## Telling what is going on + +All output goes through `src/output.ts`. Pick the function by who the message is for: + +| Function | Shown when | For | Example | +| --- | --- | --- | --- | +| `print(item, delimiter, tail, stream)` | Always | The result. Puts the delimiter after each item, or only between them without a tail. Without a delimiter it is a line break, unless the item already ends with one. | A name, the content of a file, a match | +| `printDone()` | At the end of a run, also when it is stopped | Ends stdout and stderr with a line break when `--nl` is set | | +| `info(name)` | Unless `--quiet` | The name of an affected file. stdout, or stderr when stdout has content. | `a.md` | +| `warn(msg)` | Unless `--quiet` | Something is likely a mistake, but the run goes on | `Data is piped in, but no flag tells what to do with it` | +| `error(msg, data)` | Unless `--quiet-total` | Something went wrong. The run goes on, unless `--halt` is set. | `File not found: a.md` | +| `die(msg, data, help)` | Unless `--quiet-total` | Something went wrong and the run can not go on. Exits with 1. | `No data piped into replacement` | +| `chat(msg)` | `--verbose` (or `--debug`) | The user: what happens to each file | `Open sync: a.md`, `Nothing changed in: a.md` | +| `debug(data)` | `--debug` | The developer: what happens inside, at points that matter | `Work on content from: a.md` | +| `step(data)` | `--verbose` and `--debug` | The developer: a trace of every step, with the data at that step | `Get final regex`, the full config | + +- The three levels of telling go from little to a lot: `chat`, then `debug`, then `step`. +- `chat` falls back to `debug` without `--verbose`, so `--debug` alone also shows what happens to each file. +- `debug` and `step` print their data as JSON. That keeps line breaks and spaces visible. +- `chat`, `debug` and `step` only ever write to stderr. + +## Tests + +- **Tests are strict, lean and focused too.** One thing per test, and the name of the test says what it is. +- Expect the full result where it matters (`{out, err, exit}`), so nothing extra slips through. +- A known bug is a `test.todo('BUG #: ...')` until it is fixed. Then it becomes a real test. +- A test for an issue goes in `test/js/fix/.test.js`. +- Use Bun native file IO (`Bun.write`, `Bun.file`) and `Bun.spawnSync` in new tests (#734). +- Names in tests say what they hold, like `expectedOutput`, not `named`. +- Async work can finish in any order. Do not expect an order the code does not promise. + +## Issues and pull requests + +Write in plain, simple English, with sections like `## Today`, `## Change`, `## To decide`, `## Good to know` and `## Reference`. diff --git a/src/alias.ts b/src/alias.ts new file mode 100644 index 00000000..a362b791 --- /dev/null +++ b/src/alias.ts @@ -0,0 +1,23 @@ +// The `€` and `§` aliases are for the shell, where `$` and `\` are hard to type, so the CLI uses them and not the engine. +// Globs, file names and piped data never use them, as `€` and `§` can be part of the text. +export function alias(config) { + const swap = (str) => { + if (!str) { + return str; + } + if (!config.voidEuro) { + str = str.replace(/€/g, '$'); + } + if (!config.voidSection) { + str = str.replace(/§/g, '\\'); + } + return str; + }; + + config.pattern = swap(config.pattern); + config.replacement = swap(config.replacement); + config.excludeRe = config.excludeRe.map(swap); + config.delimiter = swap(config.delimiter); + config.nameDelimiter = swap(config.nameDelimiter); + config.contentDelimiter = swap(config.contentDelimiter); +} diff --git a/src/cli.ts b/src/cli.ts index 46c7661a..fa6e2361 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,6 +2,7 @@ import * as rexreplace from './engine.ts'; import {outputConfig, die, warn} from './output.ts'; +import {alias} from './alias.ts'; // Make it visible when Bun is the runtime (like with rexreplace-bun) as the regex engine is then not the one from Node. // Added as semver build metadata (`+bun`) as a `-bun` would make it a prerelease. @@ -110,6 +111,64 @@ const yargs = require('yargs') .describe('Q', 'Never display errors or info') .alias('Q', 'quiet-total') + .boolean('a') + .describe('a', 'Print the full path of affected files instead of the relative path.') + .alias('a', 'absolute-path') + + .boolean('name-all') + .describe( + 'name-all', + 'Print the names of all identified files instead of just the files with content matching the search.' + ) + + .string('name-delimiter') + .describe( + 'name-delimiter', + 'String printed between the names of the relevant files. Default is a line break, unless the name already ends with one.' + ) + .string('content-delimiter') + .describe( + 'content-delimiter', + 'String printed between the content of each file for --output, or between the matches for --output-match. Default is a line break, unless the content already ends with one.' + ) + + .string('delimiter') + .describe( + 'delimiter', + 'Sets --name-delimiter and --content-delimiter to the same value. Supports escape sequences like `\\t`, `\\n` and the `€` and `§` aliases.' + ) + + .boolean('null') + .describe( + 'null', + "Shorthand for --delimiter '\\0'. Useful for piping content with spaces or line breaks (like for `xargs -0`). The specific delimiter values win over --delimiter, and --delimiter wins over --null." + ) + + .boolean('name-delimiter-tail') + .describe( + 'name-delimiter-tail', + 'Print the name delimiter after the last name. Defaults to true. Use --no-name-delimiter-tail or --name-delimiter-tail=false to not print the delimiter after the last name.' + ) + + .boolean('content-delimiter-tail') + .describe( + 'content-delimiter-tail', + 'Print the content delimiter after the last content. Defaults to true, but to false for piped data so it is left as it is.' + ) + + .boolean('tail') + .describe( + 'tail', + 'Shorthand for setting both --name-delimiter-tail and --content-delimiter-tail. Use --no-tail to not print the delimiter after the last name and the last content. The specific tail flags win over --tail.' + ) + + .boolean('l') + .describe( + 'l', + 'Let what is printed to stdout and stderr (the names, or the content from -o, -m or piped data) always end with a new line. No extra new line if it already ends with a new line, or if nothing was printed.' + ) + .alias('l', 'nl') + .boolean('H') .describe('H', 'Halt on first error') .alias('H', 'halt') @@ -123,14 +182,14 @@ const yargs = require('yargs') .boolean('€') .describe( '€', - "Void having '€' as alias for '$' in the pattern, replacement and --exclude-re parameters (the alias is never used for globs or piped data)" + "Void having '€' as alias for '$'. The alias is used in all inputs, but never in globs, file names or piped data." ) .alias('€', 'void-euro') .boolean('§') .describe( '§', - "Void having '§' as alias for '\\' in the pattern, replacement and --exclude-re parameters (the alias is never used for globs or piped data)" + "Void having '§' as alias for '\\'. The alias is used in all inputs, but never in globs, file names or piped data." ) .alias('§', 'void-section') @@ -411,17 +470,33 @@ function unescapeString(str = '') { config.excludeRe = [].concat(yargs.argv.excludeRe).filter(Boolean); - if (config.replacementJs) { - config.replacement = replacement; - } else { + config.replacement = replacement; + + // The aliases come before the escape sequences are interpreted, so `§n` gives a line break just like `\n` + const typed = {...config}; + alias(config); + + // The error shows the value as it was typed + const unescape = (key, name) => { try { - // `§` must be a `\` before the escape sequences are interpreted, so `§n` gives a line break just like `\n` - config.replacement = unescapeString( - config.voidSection ? replacement : replacement.replace(/§/g, '\\') - ); + config[key] = unescapeString(config[key]); } catch (e) { - return die('Invalid escape sequence in replacement:', replacement); + die('Invalid escape sequence in ' + name + ':', typed[key]); } + }; + + if (!config.replacementJs) { + unescape('replacement', 'replacement'); + } + + if (config.delimiter) { + unescape('delimiter', '--delimiter'); + } + if (config.nameDelimiter) { + unescape('nameDelimiter', '--name-delimiter'); + } + if (config.contentDelimiter) { + unescape('contentDelimiter', '--content-delimiter'); } /*if(Boolean(process.stdout.isTTY)){ diff --git a/src/engine.ts b/src/engine.ts index 6cb51667..bfbcb95f 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -6,11 +6,20 @@ const globs = require('fast-glob'); const now = new Date(); -import {outputConfig, step, debug, chat, info, warn, error, die} from './output.ts'; +import { + outputConfig, + step, + debug, + chat, + info, + print, + printDone, + warn, + error, + die, +} from './output.ts'; const re = { - euro: /€/g, - section: /§/g, mctime: /[mc]time/, colon: /:/g, capturedGroupRef: /\$\d/, @@ -39,12 +48,27 @@ export async function engine(config: any = {engine: 'default'}) { config.replacementOri = config.replacement; + // The most specific delimiter wins. Without one there is a line break after each item, unless it already ends with one. + const delimiter = config.delimiter ?? (config.null ? '\0' : null); + config.nameDelimiter = config.nameDelimiter ?? delimiter; + config.contentDelimiter = config.contentDelimiter ?? delimiter; + + // --output-match wins over --output + config.output = config.output && !config.outputMatch; + config.regex = getFinalRegex(config.pattern, config) || ''; step(config); - if (handlePipedData(config)) { - return doReplacement('Piped data', config, config.pipedData); + const piped = handlePipedData(config); + + // The more specific tail wins. Piped data is left as it is. + config.nameDelimiterTail = config.nameDelimiterTail ?? config.tail ?? true; + config.contentDelimiterTail = config.contentDelimiterTail ?? config.tail ?? !piped; + + if (piped) { + doReplacement('Piped data', config, config.pipedData); + return printDone(); } config.files = getFilePaths(config); @@ -72,6 +96,7 @@ export async function engine(config: any = {engine: 'default'}) { .map((filepath) => openFile(filepath, config)); await Promise.all(work); + return printDone(); } function openFile(file, config) { @@ -99,19 +124,25 @@ function doReplacement(_file_rr: string, _config_rr: any, _data_rr: string) { : _config_rr.replacement ); - // The output of matched strings is done from the replacement, so no need to continue - if (_config_rr.outputMatch) { + if (_config_rr.output || _config_rr.outputMatch) { + // With --output-match the matches are printed by the replacement + if (_config_rr.output) { + debug('Output result from: ' + _file_rr); + print(result, _config_rr.contentDelimiter, _config_rr.contentDelimiterTail); + } + // The file is not changed, but it is still named if something was found in it + if ((_config_rr.nameAll || result !== _data_rr) && !_config_rr.dataIsPiped) { + info(_file_rr); + } return; } - if (_config_rr.output) { - debug('Output result from: ' + _file_rr); - return process.stdout.write(result); - } - // Nothing replaced = no need for writing file again if (result === _data_rr) { chat('Nothing changed in: ' + _file_rr); + if (_config_rr.nameAll) { + info(_file_rr); + } return; } @@ -172,7 +203,8 @@ function handlePipedData(config) { if (null !== config.pipedData && !config.pipedDataUsed) { config.dataIsPiped = true; - config.output = true; + // Piped data is printed, unless only the matches are + config.output = !config.outputMatch; return true; } @@ -181,7 +213,6 @@ function handlePipedData(config) { function getFinalPattern(pattern, conf: any) { step('Get final pattern'); - pattern = replacePlaceholders(pattern, conf); if (conf.literal) { pattern = pattern.replace(re.regexSpecialChars, '\\$&'); @@ -202,8 +233,6 @@ function getFinalReplacement(replacement, conf: any) { return oneLinerFromFile(fs.readFileSync(replacement,'utf8')); }*/ - replacement = replacePlaceholders(replacement, conf); - if (conf.replacementPipe) { step('Piping replacement'); conf.pipedDataUsed = true; @@ -220,19 +249,16 @@ function getFinalReplacement(replacement, conf: any) { return die('outputMatch is only supported in node 6+'); } - return function () { + return function (match, ...rest) { step(arguments); - if (arguments.length === 3) { - step('Printing full match'); - process.stdout.write(arguments[0] + '\n'); - return ''; - } - - for (var i = 1; i < arguments.length - 2; i++) { - process.stdout.write(arguments[i]); - } - process.stdout.write('\n'); + // Without groups the full match is printed, otherwise the groups are printed together + const groups = rest.slice(0, -2); + print( + groups.length ? groups.join('') : match, + conf.contentDelimiter, + conf.contentDelimiterTail + ); return ''; }; } @@ -628,18 +654,6 @@ function localTimeString(dateObj = new Date()) { ).slice(-2)}.${('00' + dateObj.getMilliseconds()).slice(-3)}`; } -function replacePlaceholders(str = '', conf: any) { - if (!conf.voidEuro) { - str = str.replace(re.euro, '$'); - } - - if (!conf.voidSection) { - str = str.replace(re.section, '\\'); - } - - return str; -} - function getFilePaths(conf) { let {includeGlob, excludeGlob, excludeRe} = conf; @@ -658,5 +672,8 @@ function getFilePaths(conf) { filesToInclude = filesToInclude.filter((el) => !filesToExclude.includes(el)); } - return filesToInclude.sort(); + filesToInclude.sort(); + + // With --absolute-path each file is known by its full path from here on, so that is the name that is printed + return conf.absolutePath ? filesToInclude.map((file) => path.resolve(file)) : filesToInclude; } diff --git a/src/output.ts b/src/output.ts index b1847c73..b6b064e3 100644 --- a/src/output.ts +++ b/src/output.ts @@ -5,27 +5,83 @@ font.red = font.green = font.gray = (str) => str; let config: any = null; +// The char code of the last character of the last item printed to each stream (10 is a line break). Names and content never share a stream. +const lastItemChar = new Map(); + +// The char code of the last character printed to each stream +const lastPrintedChar = new Map(); + export const outputConfig = function (_config) { config = _config; + lastItemChar.clear(); + lastPrintedChar.clear(); +}; + +// Everything is printed from here, so the last character of each stream is known +const write = function (text, stream) { + stream.write(text); + if (text) { + lastPrintedChar.set(stream, text.charCodeAt(text.length - 1)); + } +}; + +// Without a delimiter there is a line break after an item, unless the item already ends with one. +// An empty item has the char code NaN, so it gets a line break too. +const after = function (end, delimiter) { + return delimiter ?? (10 === end ? '' : '\n'); +}; + +// With a tail the delimiter goes after every item, without one it goes in front of every item but the first +export const print = function (item, delimiter, tail, stream = process.stdout) { + if (tail) { + return write(item + after(item.charCodeAt(item.length - 1), delimiter), stream); + } + write( + (lastItemChar.has(stream) ? after(lastItemChar.get(stream), delimiter) : '') + item, + stream + ); + lastItemChar.set(stream, item.charCodeAt(item.length - 1)); }; -export const info = function (msg, data = '') { +// With --nl what is printed ends with a line break +export const printDone = function () { + if (!config.nl) { + return; + } + lastPrintedChar.forEach((code, stream) => { + if (10 !== code) { + write('\n', stream); + } + }); +}; + +// The name of an affected file goes to stdout so the names can be piped +export const info = function (name) { + 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; + print(name, config.nameDelimiter, config.nameDelimiterTail, stream); +}; + +const note = function (msg) { if (config.quiet || config.quietTotal) { return; } - console.error(font.gray(msg), data); + write(font.gray(msg) + '\n', process.stderr); }; export const warn = function (msg) { // The line break gives an empty line under the warning - info(' 🟡 ' + msg, '\n'); + note(' 🟡 ' + msg + '\n'); }; -export const chat = function (msg, data = '') { +export const chat = function (msg) { if (config.verbose) { - info(msg, data); + note(msg); } else { - debug(msg + ' ' + data); + debug(msg); } }; @@ -34,13 +90,17 @@ export const die = function (msg = '', data = '', displayHelp = false) { config.showHelp(); } // The line break gives an empty line under the error - msg && error(' ❌ ' + msg, data + '\n'); + if (msg && data) { + error(' ❌ ' + msg, data + '\n'); + } else if (msg) { + error(' ❌ ' + msg + '\n'); + } kill(); }; export const error = function (msg, data = '') { if (!config.quietTotal) { - console.error(font.red(msg), data); + write(font.red(msg) + (data ? ' ' + data : '') + '\n', process.stderr); } if (config.halt) { kill(); @@ -50,7 +110,7 @@ export const error = function (msg, data = '') { export function debug(data) { if (config.debug) { - console.error(font.gray(JSON.stringify(data, null, 4))); + write(font.gray(JSON.stringify(data, null, 4)) + '\n', process.stderr); } } @@ -61,5 +121,7 @@ export function step(data) { } function kill(exitCode = 1) { + // What was printed before the stop still ends with a line break + printDone(); process.exit(exitCode); } diff --git a/test/cli/run.sh b/test/cli/run.sh index 3454688c..22a6c1a5 100644 --- a/test/cli/run.sh +++ b/test/cli/run.sh @@ -191,8 +191,8 @@ assert "rexreplace '(f?(o))o(.*)' '€3€1€2' my.file -o" "barfoo" # globs reset -echo foobar >> my_file -assert "rexreplace o x my*le -o" "fxxbar\nfxxbar" +echo barfoo > my_file +assert "rexreplace o x my*le -o" "fxxbar\nbarfxx" rm my_file @@ -296,7 +296,7 @@ assert_failure "printf x | rexreplace 'b' _ my.file -P -T" # Warning when piped data is not used reset -assert "printf x | rexreplace 'o' 'x' my.file 2>&1 | grep 'Data is piped in'" ' 🟡 Data is piped in, but no flag tells what to do with it. It is not used. ' +assert "printf x | rexreplace 'o' 'x' my.file 2>&1 | grep 'Data is piped in'" ' 🟡 Data is piped in, but no flag tells what to do with it. It is not used.' assert "cat my.file" 'fxxbar' assert_failure "printf x | rexreplace 'o' 'x' my.file -q 2>&1 | grep 'Data is piped in'" assert_failure "printf x | rexreplace 'o' 'x' my.file -P 2>&1 | grep 'Data is piped in'" @@ -309,22 +309,158 @@ assert_failure "printf '\r\n' | rexreplace 'o' 'x' my.file 2>&1 | grep 'Data is # RR_VOID_PIPE reset -assert "printf x | RR_VOID_PIPE=1 rexreplace 'foo' 'pipe' my.file -o --replacement-js 2>&1" 'nullbar' +assert "printf x | RR_VOID_PIPE=1 rexreplace 'foo' 'pipe' my.file -o --replacement-js 2>&1" 'nullbar\nmy.file' assert "printf x | RR_VOID_PIPE= rexreplace 'foo' 'pipe' my.file -o --replacement-js" 'xbar' assert "printf x | RR_VOID_PIPE=1 rexreplace 'foo' 'pipe' my.file -o --replacement-js -T" 'nullbar' -assert "printf x | RR_VOID_PIPE=1 rexreplace 'foo' 'pipe' my.file -o --replacement-js -T 2>&1 >/dev/null" ' 🟡 The flag --trim-pipe conflicts with the env. var RR_VOID_PIPE as no data will be piped. ' +assert "printf x | RR_VOID_PIPE=1 rexreplace 'foo' 'pipe' my.file -o --replacement-js -T 2>&1 >/dev/null" ' 🟡 The flag --trim-pipe conflicts with the env. var RR_VOID_PIPE as no data will be piped.\n\nmy.file' assert "printf x | RR_VOID_PIPE=1 rexreplace 'foo' 'pipe' my.file -o --replacement-js -T -q 2>&1" 'nullbar' assert "printf x | RR_VOID_PIPE=1 rexreplace 'foo' 'pipe' my.file -o --replacement-js -T -Q 2>&1" 'nullbar' assert_failure "printf x | RR_VOID_PIPE=1 rexreplace 'b' _ my.file -R" -assert "printf x | RR_VOID_PIPE=1 rexreplace 'b' _ my.file -R 2>&1 | grep -E 'RR_VOID_PIPE as|No data piped'" ' 🟡 The flag --replacement-pipe conflicts with the env. var RR_VOID_PIPE as no data will be piped. \n ❌ No data piped into replacement ' -assert "printf x | RR_VOID_PIPE=1 rexreplace 'b' _ my.file -R -T 2>&1 | grep -E 'RR_VOID_PIPE as|No data piped'" ' 🟡 The flag --replacement-pipe conflicts with the env. var RR_VOID_PIPE as no data will be piped. \n 🟡 The flag --trim-pipe conflicts with the env. var RR_VOID_PIPE as no data will be piped. \n ❌ No data piped into replacement ' +assert "printf x | RR_VOID_PIPE=1 rexreplace 'b' _ my.file -R 2>&1 | grep -E 'RR_VOID_PIPE as|No data piped'" ' 🟡 The flag --replacement-pipe conflicts with the env. var RR_VOID_PIPE as no data will be piped.\n ❌ No data piped into replacement' +assert "printf x | RR_VOID_PIPE=1 rexreplace 'b' _ my.file -R -T 2>&1 | grep -E 'RR_VOID_PIPE as|No data piped'" ' 🟡 The flag --replacement-pipe conflicts with the env. var RR_VOID_PIPE as no data will be piped.\n 🟡 The flag --trim-pipe conflicts with the env. var RR_VOID_PIPE as no data will be piped.\n ❌ No data piped into replacement' assert_failure "printf x | RR_VOID_PIPE=1 rexreplace 'b' _ my.file -R -q" -assert "printf x | RR_VOID_PIPE=1 rexreplace 'b' _ my.file -R -q 2>&1 | grep -v 'exited with code'" ' ❌ No data piped into replacement ' +assert "printf x | RR_VOID_PIPE=1 rexreplace 'b' _ my.file -R -q 2>&1 | grep -v 'exited with code'" ' ❌ No data piped into replacement' assert_failure "printf x | RR_VOID_PIPE=1 rexreplace 'b' _ my.file -R -Q" assert "printf x | RR_VOID_PIPE=1 rexreplace 'b' _ my.file -R -Q 2>&1 | grep -v 'exited with code'" '' assert "cat my.file" 'foobar' +# Names of affected files go to stdout (#703) +reset +assert "rexreplace 'b' 'x' my.file your.file -A" 'my.file\nyour.file' +assert "cat my.file" 'fooxar' + +reset +assert "rexreplace 'b' 'x' my.file 2>&1 >/dev/null" '' + +reset +assert "rexreplace 'b' 'x' my.file -V 2>/dev/null" 'my.file' + +reset +assert "rexreplace 'b' 'x' my.file -V 2>&1 >/dev/null | grep -c 'my.file'" '1' + +reset +assert "rexreplace 'b' 'x' my.file -q" '' +assert "cat my.file" 'fooxar' + +# With --output and --output-match stdout has the result, so the names go to stderr +reset +assert "rexreplace 'b' 'x' my.file -o 2>/dev/null" 'fooxar' +assert "rexreplace 'b' 'x' my.file -o 2>&1 >/dev/null" 'my.file' +assert "rexreplace 'b' 'x' my.file -o -q 2>&1 >/dev/null" '' +assert "rexreplace 'z' 'x' my.file -o 2>&1 >/dev/null" '' +assert "rexreplace 'b' _ my.file -m 2>/dev/null" 'b' +assert "rexreplace 'b' _ my.file -m 2>&1 >/dev/null" 'my.file' +assert "rexreplace 'b' _ my.file -m -q 2>&1 >/dev/null" '' +assert "rexreplace 'b' 'x' my.file your.file -o --name-delimiter ', ' 2>&1 >/dev/null" 'my.file, your.file, ' +assert "cat my.file" 'foobar' + +reset +assert "rexreplace 'b' 'x' '*.file' -A | xargs cat" 'fooxar\naxc123' + +# --null +reset +assert "rexreplace 'b' 'x' my.file your.file -A --null | tr '\0' '|'" 'my.file|your.file|' + +reset +assert "rexreplace 'b' 'x' my.file your.file -A --null | xargs -0 cat" 'fooxar\naxc123' + +# --name-delimiter +reset +assert "rexreplace 'b' 'x' my.file your.file -A --name-delimiter ', '" 'my.file, your.file, ' + +reset +assert "rexreplace 'b' 'x' my.file your.file -A --name-delimiter '\t§t€'" 'my.file\t\t$your.file\t\t$' + +reset +assert "rexreplace 'b' 'x' my.file your.file -A --name-delimiter ',' --null" 'my.file,your.file,' + +# --no-name-delimiter-tail +reset +assert "rexreplace 'b' 'x' my.file your.file -A --name-delimiter ', ' --no-name-delimiter-tail" 'my.file, your.file' + +reset +assert "rexreplace 'b' 'x' my.file your.file -A --name-delimiter ', ' --name-delimiter-tail=false" 'my.file, your.file' + +reset +assert "rexreplace 'b' 'x' my.file --name-delimiter ', ' --no-name-delimiter-tail" 'my.file' + +reset +assert "rexreplace 'b' 'x' my.file your.file -A --name-delimiter '§t€' --no-name-delimiter-tail -§ -€" 'my.file§t€your.file' + +# --name-all +reset +assert "rexreplace 'z' 'x' my.file your.file -A --name-all" 'my.file\nyour.file' +assert "cat my.file" 'foobar' + +reset +assert "rexreplace 'b' 'x' my.file your.file -o --name-all 2>&1 >/dev/null" 'my.file\nyour.file' + +# By default there is a line break after the content of each file, unless it already ends with one (tr shows the line breaks) +reset +assert "rexreplace 'b' 'x' my.file your.file -o 2>/dev/null | tr '\n' '+'" 'fooxar+axc123+' +printf foo > a.file +printf bar > b.file +assert "rexreplace 'o' 'x' a.file b.file -o 2>/dev/null | tr '\n' '+'" 'fxx+bar+' +assert "rexreplace '[ab]' _ a.file b.file -m 2>/dev/null | tr '\n' '+'" 'b+a+' +assert "printf foo | rexreplace 'o' 'x' | tr '\n' '+'" 'fxx' + +# A given --content-delimiter is used as it is +reset +assert "rexreplace 'b' 'x' my.file your.file -o --delimiter '' 2>/dev/null" 'fooxar\naxc123' +assert "rexreplace '[bc]' _ my.file your.file -m --delimiter ',' 2>/dev/null" 'b,b,c,' +assert "rexreplace '[bc]' _ my.file your.file -m --delimiter ',' --no-tail 2>/dev/null" 'b,b,c' +assert "rexreplace 'b' 'x' my.file your.file -o --content-delimiter '§t-\n' 2>/dev/null" 'fooxar\n\t-\naxc123\n\t-' +assert "rexreplace 'b' 'x' my.file your.file -o --content-delimiter '|' --no-content-delimiter-tail 2>/dev/null" 'fooxar\n|axc123' +assert "printf foo | rexreplace 'o' 'x' --content-delimiter '|'" 'fxx' +assert "printf foo | rexreplace 'o' 'x' --content-delimiter '|' --content-delimiter-tail" 'fxx|' +assert "cat my.file" 'foobar' + +# --delimiter sets both, and the most specific flag wins +reset +assert "rexreplace 'b' 'x' my.file your.file -o --delimiter '|' 2>/dev/null" 'fooxar\n|axc123\n|' +assert "rexreplace 'b' 'x' my.file your.file -o --delimiter '|' 2>&1 >/dev/null" 'my.file|your.file|' +assert "rexreplace 'b' 'x' my.file your.file -o --null 2>/dev/null | tr '\0' '|'" 'fooxar\n|axc123\n|' +assert "rexreplace 'b' 'x' my.file your.file -o --null --delimiter ', ' 2>&1 >/dev/null" 'my.file, your.file, ' +assert "rexreplace 'b' 'x' my.file your.file -o --delimiter ', ' --content-delimiter '|' 2>/dev/null" 'fooxar\n|axc123\n|' + +# --tail sets both tails, and the specific tail flag wins +reset +assert "rexreplace '[bc]' _ my.file your.file -m --delimiter ',' --tail 2>/dev/null" 'b,b,c,' +assert "rexreplace 'b' 'x' my.file your.file -o --delimiter '|' --no-tail 2>&1 >/dev/null" 'my.file|your.file' +assert "rexreplace 'b' 'x' my.file your.file -o --delimiter '|' --tail 2>/dev/null" 'fooxar\n|axc123\n|' +assert "rexreplace 'b' 'x' my.file your.file -o --delimiter '|' --no-tail --name-delimiter-tail 2>&1 >/dev/null" 'my.file|your.file|' + +# --nl ends stdout and stderr with a line break, unless they already do (tr shows the line breaks) +reset +assert "rexreplace '[bc]' _ my.file your.file -m --delimiter ',' --no-tail --nl 2>/dev/null | tr '\n' '+'" 'b,b,c+' +assert "rexreplace '[bc]' _ my.file your.file -m --delimiter ',' --no-tail -l 2>/dev/null | tr '\n' '+'" 'b,b,c+' +assert "rexreplace '[bc]' _ my.file your.file -m --delimiter ',' --tail --nl 2>/dev/null | tr '\n' '+'" 'b,b,c,+' +assert "rexreplace 'b' 'x' my.file -o --nl 2>/dev/null | tr '\n' '+'" 'fooxar+' +assert "printf foo | rexreplace 'o' 'x' --nl | tr '\n' '+'" 'fxx+' +assert "printf foo | rexreplace 'nope' 'x' -m --nl | tr '\n' '+'" '' +assert "rexreplace 'b' 'x' my.file your.file -o --name-delimiter ',' --no-name-delimiter-tail --nl 2>&1 >/dev/null | tr '\n' '+'" 'my.file,your.file+' +assert "rexreplace 'b' 'x' my.file your.file -A --name-delimiter ',' --no-name-delimiter-tail --nl | tr '\n' '+'" 'my.file,your.file+' + +reset +assert_failure "rexreplace 'b' 'x' my.file --name-delimiter '\x4'" +assert "rexreplace 'b' 'x' my.file --name-delimiter '\x4' 2>&1 | grep -c 'Invalid escape sequence in --name-delimiter'" '1' +assert "rexreplace 'b' 'x' my.file --content-delimiter '\x4' 2>&1 | grep -c 'Invalid escape sequence in --content-delimiter'" '1' +assert "rexreplace 'b' 'x' my.file --delimiter '\x4' 2>&1 | grep -c 'Invalid escape sequence in --delimiter'" '1' +assert "cat my.file" 'foobar' + +# --absolute-path +reset +assert "rexreplace 'b' 'x' my.file -a" "$(pwd -P)/my.file" + +reset +assert "rexreplace 'b' 'x' $(pwd -P)/my.file" "$(pwd -P)/my.file" + +reset +assert "rexreplace 'b' 'x' $(pwd -P)/my.file --absolute-path" "$(pwd -P)/my.file" +assert "cat my.file" 'fooxar' + + # -L diff --git a/test/fuzzy/basics.test.js b/test/fuzzy/basics.test.js index c836f14d..dca96c54 100644 --- a/test/fuzzy/basics.test.js +++ b/test/fuzzy/basics.test.js @@ -1,6 +1,6 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr, random} from '../js/helper.js'; +import {rr, random, ended} from '../js/helper.js'; describe('basics', () => { test('gives the same as String.replace with the flags gim', async () => { @@ -36,7 +36,7 @@ describe('basics', () => { fs.writeFileSync('my.file', text); expect( (await rr(pattern, replacement, {includeGlob: ['my.file'], output: true})).out - ).toBe(expected); + ).toBe(ended(expected)); await rr(pattern, replacement, {includeGlob: ['my.file']}); expect( diff --git a/test/fuzzy/crossover.test.js b/test/fuzzy/crossover.test.js index b7b1b4a0..f3d4b245 100644 --- a/test/fuzzy/crossover.test.js +++ b/test/fuzzy/crossover.test.js @@ -1,7 +1,7 @@ // Random combinations of flags and input compared with what plain javascript gives import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr, random} from '../js/helper.js'; +import {rr, random, ended} from '../js/helper.js'; const flags = [ 'verbose', @@ -49,12 +49,11 @@ describe('crossover', () => { (config.unicode ? 'u' : '') ); const replaced = text.replace(regex, () => replacement); - const matches = ( - config.voidGlobal ? [text.match(regex)] : Array.from(text.matchAll(regex)) - ) - .filter(Boolean) - .map((match) => (1 < match.length ? match.slice(1).join('') : match[0]) + '\n') - .join(''); + const matches = ended( + ...(config.voidGlobal ? [text.match(regex)] : Array.from(text.matchAll(regex))) + .filter(Boolean) + .map((match) => (1 < match.length ? match.slice(1).join('') : match[0])) + ); const given = config.replacementJs ? JSON.stringify(replacement) : replacement; const note = JSON.stringify({config, pattern, text, replacement}); @@ -69,8 +68,16 @@ describe('crossover', () => { }); expect(result.exit, note).toBe(null); + // The name of the file is only printed if the file was changed + const named = replaced !== text && !config.quiet && !config.quietTotal; expect(result.out, note).toBe( - config.outputMatch ? matches : config.output ? replaced : '' + config.outputMatch + ? matches + : config.output + ? ended(replaced) + : named + ? file + '\n' + : '' ); const expected = written ? replaced : text; @@ -112,7 +119,7 @@ describe('crossover', () => { }) ).out, note - ).toBe(piped); + ).toBe(ended(piped)); await rr(pattern, replacement, {...config, includeGlob: ['my.file']}); expect(fs.readFileSync('my.file', 'utf8'), note).toBe(piped); diff --git a/test/fuzzy/output-match.test.js b/test/fuzzy/output-match.test.js index 840f7812..fef22b63 100644 --- a/test/fuzzy/output-match.test.js +++ b/test/fuzzy/output-match.test.js @@ -11,7 +11,7 @@ describe('--output-match', () => { expect( (await rr(pattern, '_', {pipedData: text, outputMatch: true})).out, JSON.stringify({pattern, text}) - ).toBe(matches.map((match) => match + '\n').join('')); + ).toBe(matches.join('\n')); } }); @@ -24,7 +24,7 @@ describe('--output-match', () => { expect( (await rr(pattern, '_', {pipedData: text, outputMatch: true})).out, JSON.stringify({pattern, text}) - ).toBe(matches.map((match) => match.slice(1).join('') + '\n').join('')); + ).toBe(matches.map((match) => match.slice(1).join('')).join('\n')); } }); }); diff --git a/test/fuzzy/output.test.js b/test/fuzzy/output.test.js index c2851084..5b239c05 100644 --- a/test/fuzzy/output.test.js +++ b/test/fuzzy/output.test.js @@ -1,24 +1,33 @@ import {describe, test, expect} from 'bun:test'; -import fs from 'fs'; -import {rr, random} from '../js/helper.js'; +import {rr, random, ended} from '../js/helper.js'; + +const read = (names) => Promise.all(names.map((name) => Bun.file(name).text())); describe('--output', () => { - test('sends exactly what would have been written to the files', async () => { + test('sends exactly what would have been written to the files with the content delimiter after each', async () => { const rnd = random(1); for (let i = 0; i < 100; i++) { - const names = ['a', 'b', 'c', 'd'] - .slice(0, 1 + rnd.int(3)) + const names = ['a', 'b', 'c', 'd', 'e', 'f'] + .slice(0, 1 + rnd.int(5)) .map((name) => name + '.file'); - const texts = names.map(() => rnd.text('abAB \næ中', 0, 20)); - const pattern = rnd.pick(['a', 'b+', '^.', '\\s', 'ab']); - names.forEach((name, n) => fs.writeFileSync(name, texts[n])); + const texts = names.map(() => rnd.text('abAB01 \t\r\n\0\\\'"`$€§æ中🦖', 0, 200)); + const pattern = rnd.pick(['a', 'b+', '^.', '\\s', 'ab', '$', '\\r?\\n']); + // null is the default: a line break, unless the content already ends with one + const contentDelimiter = rnd.pick([null, '', '\n', '\0', ', ']); + for (const [n, name] of names.entries()) { + await Bun.write(name, texts[n]); + } - const out = (await rr(pattern, '_', {includeGlob: names, output: true})).out; - expect(names.map((name) => fs.readFileSync(name, 'utf8'))).toEqual(texts); + const config = {includeGlob: names, output: true, contentDelimiter}; + // As UTF-8 like stdout and the files, so half an emoji (from `.` without --unicode) becomes `�` in both + const out = Buffer.from((await rr(pattern, '_', config)).out).toString(); + expect(await read(names)).toEqual(texts); await rr(pattern, '_', {includeGlob: names}); - expect(out, JSON.stringify({pattern, texts})).toBe( - names.map((name) => fs.readFileSync(name, 'utf8')).join('') + expect(out, JSON.stringify({pattern, contentDelimiter, texts})).toBe( + null === contentDelimiter + ? ended(...(await read(names))) + : (await read(names)).map((text) => text + contentDelimiter).join('') ); } }); diff --git a/test/fuzzy/replacement-pipe.test.js b/test/fuzzy/replacement-pipe.test.js index b74a810c..ac2df810 100644 --- a/test/fuzzy/replacement-pipe.test.js +++ b/test/fuzzy/replacement-pipe.test.js @@ -1,6 +1,6 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr, random} from '../js/helper.js'; +import {rr, random, ended} from '../js/helper.js'; describe('--replacement-pipe', () => { test('inserts any piped text without a $ exactly as it is', async () => { @@ -15,7 +15,7 @@ describe('--replacement-pipe', () => { output: true, }; expect((await rr('-', '_', config)).out, JSON.stringify(pipedData)).toBe( - `a${pipedData}b${pipedData}c` + `a${pipedData}b${pipedData}c\n` ); } }); diff --git a/test/js/basics.test.js b/test/js/basics.test.js index cb3f4548..a02a2efc 100644 --- a/test/js/basics.test.js +++ b/test/js/basics.test.js @@ -1,6 +1,7 @@ // What rexreplace does when no flags are given import {describe, test, expect} from 'bun:test'; import fs from 'fs'; +import path from 'path'; import {rr} from './helper.js'; describe('pattern', () => { @@ -82,11 +83,11 @@ describe('replacement', () => { }); describe('files', () => { - test('are changed in place and named on stderr', async () => { + test('are changed in place and named on stdout', async () => { fs.writeFileSync('my.file', 'foobar'); const result = await rr('o', 'x', {includeGlob: ['my.file']}); expect(fs.readFileSync('my.file', 'utf8')).toBe('fxxbar'); - expect(result).toEqual({out: '', err: 'my.file \n', exit: null}); + expect(result).toEqual({out: 'my.file\n', err: '', exit: null}); expect(fs.readdirSync('.')).toEqual(['my.file']); }); @@ -129,13 +130,13 @@ describe('files', () => { includeGlob: ['my.file', '*.file', './my.file', 'my.*'], }); expect(fs.readFileSync('my.file', 'utf8')).toBe('foooobar'); - expect(result.err).toBe('my.file \n'); + expect(result.out).toBe('my.file\n'); }); test('are handled in alphabetical order', async () => { ['c', 'a', 'b'].forEach((name) => fs.writeFileSync(name + '.file', name)); expect((await rr('$', '!', {includeGlob: ['c.file', '*.file'], output: true})).out).toBe( - 'a!b!c!' + 'a!\nb!\nc!\n' ); }); @@ -176,13 +177,24 @@ describe('files', () => { fs.mkdirSync('dir.file'); fs.writeFileSync('.hidden.file', 'foobar'); const result = await rr('o', 'x', {includeGlob: ['*']}); - expect(result.err).toBe('0 files found \n'); + expect(result.err).toBe('0 files found\n'); expect(fs.readFileSync('.hidden.file', 'utf8')).toBe('foobar'); }); + // The file names are read by src/cli.ts, so this runs the CLI itself + test.todo('BUG #733: can have a name that is only a number', async () => { + await Bun.write('2024', 'foobar'); + const cli = path.join(import.meta.dir, '../../src/cli.ts'); + const {stdout, exitCode} = Bun.spawnSync([process.execPath, cli, 'o', 'x', '2024'], { + stdin: 'ignore', + }); + expect({out: stdout.toString(), exit: exitCode}).toEqual({out: '2024\n', exit: 0}); + expect(await Bun.file('2024').text()).toBe('fxxbar'); + }); + test('are reported if none are found', async () => { const result = await rr('o', 'x', {includeGlob: ['nope.file']}); - expect(result.err).toBe('0 files found \n'); + expect(result.err).toBe('0 files found\n'); expect(result.out).toBe(''); }); }); @@ -207,7 +219,7 @@ describe('piped data', () => { test('is ignored if files are given', async () => { fs.writeFileSync('my.file', 'foobar'); const result = await rr('o', 'x', {pipedData: 'boo', includeGlob: ['my.file']}); - expect(result.out).toBe(''); + expect(result.out).toBe('my.file\n'); expect(fs.readFileSync('my.file', 'utf8')).toBe('fxxbar'); }); @@ -217,6 +229,6 @@ describe('piped data', () => { }); test('leaves nothing to do if neither data nor files are given', async () => { - expect(await rr('o', 'x')).toEqual({out: '', err: '0 files found \n', exit: null}); + expect(await rr('o', 'x')).toEqual({out: '', err: '0 files found\n', exit: null}); }); }); diff --git a/test/js/crossover.test.js b/test/js/crossover.test.js index 25358595..f6324156 100644 --- a/test/js/crossover.test.js +++ b/test/js/crossover.test.js @@ -76,8 +76,10 @@ describe('every pair of flags', () => { ? 'o\n' : 'o\no\n' : config.output - ? replaced - : '' + ? replaced + '\n' + : config.quiet || config.quietTotal + ? '' + : 'my.file\n' ); expect(fs.readFileSync('my.file', 'utf8')).toBe(written ? replaced : 'foo'); @@ -233,6 +235,6 @@ describe('many flags at once', () => { verbose: true, halt: true, }); - expect(result).toEqual({out: 'fxxbar', err: '', exit: null}); + expect(result).toEqual({out: 'fxxbar\n', err: '', exit: null}); }); }); diff --git a/test/js/debug.test.js b/test/js/debug.test.js index 0ea41136..0cfa2de5 100644 --- a/test/js/debug.test.js +++ b/test/js/debug.test.js @@ -8,14 +8,14 @@ describe('--debug', () => { const result = await rr('o', 'x', {includeGlob: ['my.file'], debug: true}); expect(result.err).toBe( [ - '"1 files found "', - '"Open sync: my.file "', + '"1 files found"', + '"Open sync: my.file"', '"Work on content from: my.file"', '"Write new content to: my.file"', - 'my.file ', '', ].join('\n') ); + expect(result.out).toBe('my.file\n'); }); test('prints what happens to piped data', async () => { @@ -65,7 +65,7 @@ describe('--debug', () => { debug: true, output: true, }); - expect(result.out).toBe('fxxbar'); + expect(result.out).toBe('fxxbar\n'); expect(result.err).toContain('"Output result from: my.file"'); expect(result.err).not.toContain('Write new content'); }); @@ -73,14 +73,14 @@ describe('--debug', () => { test('with a file where nothing is replaced', async () => { fs.writeFileSync('my.file', 'foobar'); const result = await rr('nope', 'x', {includeGlob: ['my.file'], debug: true}); - expect(result.err).toContain('"Nothing changed in: my.file "'); + expect(result.err).toContain('"Nothing changed in: my.file"'); }); test('with --replacement-js and --output-match', async () => { const js = await rr('o', '1+1', {pipedData: 'fo', debug: true, replacementJs: true}); expect(js.out).toBe('f2'); const match = await rr('o', '_', {pipedData: 'fo', debug: true, outputMatch: true}); - expect(match.out).toBe('o\n'); + expect(match.out).toBe('o'); expect(match.err).toBe('"Work on content from: Piped data"\n'); }); }); diff --git a/test/js/dot-all.test.js b/test/js/dot-all.test.js index 33d1045a..ea2d8579 100644 --- a/test/js/dot-all.test.js +++ b/test/js/dot-all.test.js @@ -46,7 +46,7 @@ describe('--dot-all', () => { test('with --output-match a match can span lines', async () => { const config = {pipedData: 'a\nb a\nc', dotAll: true, outputMatch: true}; - expect((await rr('a.b', '_', config)).out).toBe('a\nb\n'); + expect((await rr('a.b', '_', config)).out).toBe('a\nb'); }); test('with --literal the dot is just a dot', async () => { diff --git a/test/js/encoding.test.js b/test/js/encoding.test.js index 8d7d665f..35ceb2b5 100644 --- a/test/js/encoding.test.js +++ b/test/js/encoding.test.js @@ -81,7 +81,7 @@ describe('--encoding', () => { test('with --output the result is sent as text', async () => { fs.writeFileSync('my.file', latin1()); const config = {includeGlob: ['my.file'], encoding: 'latin1', output: true}; - expect((await rr('o', 'x', config)).out).toBe('fæx'); + expect((await rr('o', 'x', config)).out).toBe('fæx\n'); expect(fs.readFileSync('my.file')).toEqual(latin1()); }); @@ -111,7 +111,7 @@ describe('--encoding', () => { replacementJs: true, output: true, }; - expect((await rr('æ', 'text.length + text', config)).out).toBe('f3fæoo'); + expect((await rr('æ', 'text.length + text', config)).out).toBe('f3fæoo\n'); }); test.todo( diff --git a/test/js/exclude-glob.test.js b/test/js/exclude-glob.test.js index a9e034c8..c27eab29 100644 --- a/test/js/exclude-glob.test.js +++ b/test/js/exclude-glob.test.js @@ -46,7 +46,7 @@ describe('--exclude-glob', () => { test('reports when all files are left out', async () => { fs.writeFileSync('my.file', 'foo'); const result = await rr('foo', 'x', {includeGlob: ['*.file'], excludeGlob: ['*.file']}); - expect(result.err).toBe('0 files found \n'); + expect(result.err).toBe('0 files found\n'); expect(fs.readFileSync('my.file', 'utf8')).toBe('foo'); }); diff --git a/test/js/exclude-re.test.js b/test/js/exclude-re.test.js index 486be0d9..b9f7e614 100644 --- a/test/js/exclude-re.test.js +++ b/test/js/exclude-re.test.js @@ -47,7 +47,7 @@ describe('--exclude-re', () => { test('reports when all files are left out', async () => { fs.writeFileSync('my.file', 'foo'); const result = await rr('foo', 'x', {includeGlob: ['my.file'], excludeRe: ['.']}); - expect(result.err).toBe('0 files found \n'); + expect(result.err).toBe('0 files found\n'); expect(fs.readFileSync('my.file', 'utf8')).toBe('foo'); }); @@ -101,7 +101,7 @@ describe('--exclude-re', () => { fs.writeFileSync('a.file', 'foo'); fs.writeFileSync('b.file', 'boo'); const config = {includeGlob: ['*.file'], excludeRe: ['^a'], output: true}; - expect((await rr('o', 'x', config)).out).toBe('bxx'); + expect((await rr('o', 'x', config)).out).toBe('bxx\n'); }); test('with piped data it has nothing to do', async () => { diff --git a/test/js/fix/682.test.js b/test/js/fix/682.test.js index 05d1f65a..5176e780 100644 --- a/test/js/fix/682.test.js +++ b/test/js/fix/682.test.js @@ -25,7 +25,7 @@ describe('#682 --quiet still displays errors', () => { ); }); - test('still hides the info about changed files', async () => { + test('still hides the info about affected files', async () => { fs.writeFileSync('my.file', 'foobar'); expect((await rr('o', 'x', {includeGlob: ['my.file'], quiet: true})).err).toBe(''); }); diff --git a/test/js/fix/683.test.js b/test/js/fix/683.test.js index 4fc1ddaa..4e462c32 100644 --- a/test/js/fix/683.test.js +++ b/test/js/fix/683.test.js @@ -15,7 +15,7 @@ describe('#683 --exclude-re follows the regex flags of the main search', () => { test('the example from the issue', async () => { fs.writeFileSync('a.file', 'x\n'); const config = {includeGlob: ['a.file'], excludeRe: ['A'], output: true}; - expect(await rr('x', 'y', config)).toEqual({out: '', err: '0 files found \n', exit: null}); + expect(await rr('x', 'y', config)).toEqual({out: '', err: '0 files found\n', exit: null}); expect((await rr('x', 'y', {...config, voidIgnoreCase: true})).out).toBe('y\n'); }); diff --git a/test/js/fix/691.test.js b/test/js/fix/691.test.js index c6f66bbc..fb3cc7c0 100644 --- a/test/js/fix/691.test.js +++ b/test/js/fix/691.test.js @@ -8,12 +8,16 @@ import path from 'path'; const cli = path.join(import.meta.dir, '../../../src/cli.ts'); -// Replace each `o` in `foobar` with the replacement and return what the CLI gives back +// With --output the name of the file goes to stderr +const expectedOutput = 'my.file\n'; + +// Replace each `o` in `foobar` with the replacement and return what the CLI gives back. +// Without the tail only the result of the replacement is given. function replaceO(replacement, ...flags) { fs.writeFileSync('my.file', 'foobar'); const {stdout, stderr, status} = spawnSync( process.execPath, - [cli, 'o', replacement, 'my.file', '--output', ...flags], + [cli, 'o', replacement, 'my.file', '--output', '--no-content-delimiter-tail', ...flags], {input: '', encoding: 'utf8'} ); return {out: stdout, err: stderr, exit: status}; @@ -21,22 +25,22 @@ function replaceO(replacement, ...flags) { describe('#691 replacement that used to crash the CLI', () => { test('a real line break is inserted as a line break', () => { - expect(replaceO('a\nb')).toEqual({out: 'fa\nba\nbbar', err: '', exit: 0}); + expect(replaceO('a\nb')).toEqual({out: 'fa\nba\nbbar', err: expectedOutput, exit: 0}); expect(replaceO('a\r\nb').out).toBe('fa\r\nba\r\nbbar'); expect(replaceO('\u2028\u2029').out).toBe('f\u2028\u2029\u2028\u2029bar'); }); test("`\\'` gives a single quote", () => { - expect(replaceO("\\'")).toEqual({out: "f''bar", err: '', exit: 0}); + expect(replaceO("\\'")).toEqual({out: "f''bar", err: expectedOutput, exit: 0}); }); test('a lone backslash at the end gives a backslash', () => { - expect(replaceO('\\')).toEqual({out: 'f\\\\bar', err: '', exit: 0}); + expect(replaceO('\\')).toEqual({out: 'f\\\\bar', err: expectedOutput, exit: 0}); expect(replaceO('x\\').out).toBe('fx\\x\\bar'); }); test('a backslash in front of a real line break still joins the lines (as before)', () => { - expect(replaceO('a\\\nb')).toEqual({out: 'fababbar', err: '', exit: 0}); + expect(replaceO('a\\\nb')).toEqual({out: 'fababbar', err: expectedOutput, exit: 0}); expect(replaceO('a\\\r\nb').out).toBe('fababbar'); expect(replaceO('a \\\n b \\\n c').out).toBe('fa b ca b cbar'); }); @@ -45,7 +49,7 @@ describe('#691 replacement that used to crash the CLI', () => { const result = replaceO("\\'+process.exit(3)//"); expect(result).toEqual({ out: "f'+process.exit(3)//'+process.exit(3)//bar", - err: '', + err: expectedOutput, exit: 0, }); }); @@ -72,7 +76,7 @@ describe('#691 replacement that used to crash the CLI', () => { ])('%j still gives %j', (replacement, expected) => { expect(replaceO(replacement)).toEqual({ out: `f${expected}${expected}bar`, - err: '', + err: expectedOutput, exit: 0, }); }); diff --git a/test/js/fix/699.test.js b/test/js/fix/699.test.js index 2b9cc6a9..ffb318f6 100644 --- a/test/js/fix/699.test.js +++ b/test/js/fix/699.test.js @@ -86,8 +86,8 @@ describe('#699 € and § are only aliases in the pattern, the replacement and - pipedData: '[€1§n]', output: true, }; - expect((await rr('(b)', '_', config)).out).toBe('foo[€1§n]ar'); - expect((await rr('(b)', '_', {...config, ...voided})).out).toBe('foo[€1§n]ar'); + expect((await rr('(b)', '_', config)).out).toBe('foo[€1§n]ar\n'); + expect((await rr('(b)', '_', {...config, ...voided})).out).toBe('foo[€1§n]ar\n'); }); }); diff --git a/test/js/fix/703.test.js b/test/js/fix/703.test.js new file mode 100644 index 00000000..cfdda85e --- /dev/null +++ b/test/js/fix/703.test.js @@ -0,0 +1,399 @@ +// https://github.com/mathiasrw/rexreplace/issues/703 +// The names of the affected files were printed to stderr, so they could not be piped on their own. +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import path from 'path'; +import {rr} from '../helper.js'; + +// Note that rr() from the helper sets voidAsync per default, so the names come in the order of the file list + +// Replace in a.file, b.file and c.file where b.file has nothing to replace +async function run(flags = {}) { + await Bun.write('a.file', 'foo'); + await Bun.write('b.file', 'bar'); + await Bun.write('c.file', 'foo'); + return rr('o', 'x', {includeGlob: ['*.file'], ...flags}); +} + +describe('#703 the names of affected files', () => { + test('go to stdout with one name on each line', async () => { + expect(await run()).toEqual({out: 'a.file\nc.file\n', err: '', exit: null}); + expect(await Bun.file('a.file').text()).toBe('fxx'); + }); + + // A file is named when the result differs from the content, so `rexreplace o o` names nothing + test.todo( + 'could be faster by tracking if there was a match instead of comparing the result with the content' + ); + + test('all come with the async flow', async () => { + const result = await run({voidAsync: false}); + expect(result.out.split('\n').sort()).toEqual(['', 'a.file', 'c.file']); + expect(result.err).toBe(''); + }); + + test('are alone on stdout with --verbose and --debug', async () => { + const result = await run({verbose: true, debug: true}); + expect(result.out).toBe('a.file\nc.file\n'); + expect(result.err).toContain('Open sync: a.file'); + expect(result.err).toContain('Nothing changed in: b.file'); + }); + + test.each(['quiet', 'quietTotal'])('are not printed with --%s', async (flag) => { + expect(await run({[flag]: true})).toEqual({out: '', err: '', exit: null}); + expect(await Bun.file('c.file').text()).toBe('fxx'); + }); + + test('are not printed for piped data', async () => { + expect(await rr('o', 'x', {pipedData: 'foo'})).toEqual({out: 'fxx', err: '', exit: null}); + expect(await rr('o', 'x', {pipedData: 'foo', outputMatch: true})).toEqual({ + out: 'o\no', + err: '', + exit: null, + }); + }); + + test('are printed the way the path was given', async () => { + await Bun.write('sub/my.file', 'foo'); + expect((await rr('o', 'x', {includeGlob: ['sub/*.file']})).out).toBe('sub/my.file\n'); + + const file = path.resolve('sub/my.file'); + expect((await rr('x', 'o', {includeGlob: [file]})).out).toBe(file + '\n'); + }); + + describe('--null', () => { + test('ends each name with a NUL byte', async () => { + expect(await run({null: true})).toEqual({out: 'a.file\0c.file\0', err: '', exit: null}); + }); + + test('keeps a name with a line break in one piece', async () => { + await Bun.write('my\n.file', 'foo'); + expect((await rr('o', 'x', {includeGlob: ['*.file'], null: true})).out).toBe( + 'my\n.file\0' + ); + }); + }); + + describe('--name-delimiter', () => { + test('is put after each name', async () => { + expect(await run({nameDelimiter: ', '})).toEqual({ + out: 'a.file, c.file, ', + err: '', + exit: null, + }); + }); + + test('can be an empty string', async () => { + expect((await run({nameDelimiter: ''})).out).toBe('a.filec.file'); + }); + + test('has the € and § aliases like the replacement', async () => { + expect((await run({nameDelimiter: '€§'})).out).toBe('a.file$\\c.file$\\'); + expect((await run({nameDelimiter: '€§', voidEuro: true})).out).toBe( + 'a.file€\\c.file€\\' + ); + expect((await run({nameDelimiter: '€§', voidSection: true})).out).toBe( + 'a.file$§c.file$§' + ); + }); + }); + + describe('--no-name-delimiter-tail', () => { + test('puts the delimiter only between the names', async () => { + expect((await run({nameDelimiter: ', ', nameDelimiterTail: false})).out).toBe( + 'a.file, c.file' + ); + expect((await run({nameDelimiterTail: false})).out).toBe('a.file\nc.file'); + }); + + test('prints no delimiter when only one file is changed', async () => { + await Bun.write('my.file', 'foo'); + const config = { + includeGlob: ['my.file'], + nameDelimiter: ', ', + nameDelimiterTail: false, + }; + expect((await rr('o', 'x', config)).out).toBe('my.file'); + }); + + test('does not start the next run with a delimiter', async () => { + await run({nameDelimiter: ', ', nameDelimiterTail: false}); + expect((await run({nameDelimiter: ', ', nameDelimiterTail: false})).out).toBe( + 'a.file, c.file' + ); + }); + + // The names must not be held back until all files are done + test('prints the files changed before the run is stopped by --halt', async () => { + await Bun.write('a.file', 'foo'); + await Bun.write('locked/b.file', 'foo'); + fs.chmodSync('locked', 0o555); + try { + const result = await rr('o', 'x', { + includeGlob: ['**/*.file'], + nameDelimiter: ', ', + nameDelimiterTail: false, + halt: true, + }); + expect(result.out).toBe('a.file'); + expect(result.err).toContain('EACCES'); + expect(result.exit).toBe(1); + } finally { + fs.chmodSync('locked', 0o755); + } + }); + + test('with --nl still ends with a line break when the run is stopped by --halt', async () => { + await Bun.write('a.file', 'foo'); + await Bun.write('locked/b.file', 'foo'); + fs.chmodSync('locked', 0o555); + try { + const result = await rr('o', 'x', { + includeGlob: ['**/*.file'], + nameDelimiterTail: false, + halt: true, + nl: true, + }); + expect(result.out).toBe('a.file\n'); + expect(result.exit).toBe(1); + } finally { + fs.chmodSync('locked', 0o755); + } + }); + }); + + describe('--name-all', () => { + test('also names the files where nothing is changed', async () => { + expect(await run({nameAll: true})).toEqual({ + out: 'a.file\nb.file\nc.file\n', + err: '', + exit: null, + }); + expect(await Bun.file('b.file').text()).toBe('bar'); + }); + + test('names every file printed with --output', async () => { + expect(await run({nameAll: true, output: true})).toEqual({ + out: 'fxx\nbar\nfxx\n', + err: 'a.file\nb.file\nc.file\n', + exit: null, + }); + }); + + test('does not name piped data', async () => { + expect(await rr('o', 'x', {pipedData: 'foo', nameAll: true})).toEqual({ + out: 'fxx', + err: '', + exit: null, + }); + }); + }); + + describe('--content-delimiter', () => { + test('is a line break after each file and each match by default', async () => { + expect((await run({output: true})).out).toBe('fxx\nbar\nfxx\n'); + expect((await run({outputMatch: true})).out).toBe('o\no\no\no\n'); + }); + + test('adds no line break by default to content that already ends with one', async () => { + await Bun.write('a.file', 'foo\n'); + await Bun.write('b.file', ''); + await Bun.write('c.file', 'bar'); + const config = {includeGlob: ['*.file'], output: true}; + expect((await rr('o', 'x', config)).out).toBe('fxx\n\nbar\n'); + }); + + test('is put after the content of each file', async () => { + expect((await run({output: true, contentDelimiter: '\t'})).out).toBe('fxx\tbar\tfxx\t'); + }); + + test('is put after each match with --output-match, and not after the names', async () => { + const result = await run({outputMatch: true, contentDelimiter: ', '}); + expect(result).toEqual({out: 'o, o, o, o, ', err: 'a.file\nc.file\n', exit: null}); + }); + + test('is only put between the content with --no-content-delimiter-tail', async () => { + const config = {output: true, contentDelimiter: ', ', contentDelimiterTail: false}; + expect((await run(config)).out).toBe('fxx, bar, fxx'); + }); + + test('leaves piped data as it is by default', async () => { + expect((await rr('o', 'x', {pipedData: 'foo'})).out).toBe('fxx'); + expect((await rr('o', 'x', {pipedData: 'foo', contentDelimiter: ', '})).out).toBe( + 'fxx' + ); + }); + + test('is put after piped data with --content-delimiter-tail', async () => { + const tail = {pipedData: 'foo', contentDelimiterTail: true}; + expect((await rr('o', 'x', {...tail, contentDelimiter: ', '})).out).toBe('fxx, '); + expect((await rr('o', 'x', tail)).out).toBe('fxx\n'); + }); + }); + + describe('--tail', () => { + test('puts the delimiter after the last name and the last content', async () => { + expect(await run({output: true, delimiter: ', ', tail: true})).toEqual({ + out: 'fxx, bar, fxx, ', + err: 'a.file, c.file, ', + exit: null, + }); + expect((await run({outputMatch: true, tail: true})).out).toBe('o\no\no\no\n'); + }); + + test('--no-tail puts the delimiter only between the names and the content', async () => { + expect(await run({output: true, delimiter: ', ', tail: false})).toEqual({ + out: 'fxx, bar, fxx', + err: 'a.file, c.file', + exit: null, + }); + }); + + test('the specific tail flags win', async () => { + const config = {output: true, delimiter: ', ', tail: false, nameDelimiterTail: true}; + expect(await run(config)).toEqual({ + out: 'fxx, bar, fxx', + err: 'a.file, c.file, ', + exit: null, + }); + }); + }); + + describe('--nl', () => { + test('ends the names with a line break', async () => { + const config = {nameDelimiter: ', ', nameDelimiterTail: false, nl: true}; + expect((await run(config)).out).toBe('a.file, c.file\n'); + }); + + test('ends the content and the names on stderr with a line break', async () => { + const config = {outputMatch: true, delimiter: ', ', tail: false, nl: true}; + expect(await run(config)).toEqual({ + out: 'o, o, o, o\n', + err: 'a.file, c.file\n', + exit: null, + }); + expect((await rr('o', 'x', {pipedData: 'foo', nl: true})).out).toBe('fxx\n'); + }); + + test('adds nothing when the output already ends with a line break', async () => { + expect((await rr('o', 'x', {pipedData: 'foo\n\n', nl: true})).out).toBe('fxx\n\n'); + await Bun.write('a.file', 'foo\n'); + await Bun.write('b.file', ''); + const config = {includeGlob: ['*.file'], output: true, nl: true}; + expect((await rr('o', 'x', config)).out).toBe('fxx\n\n'); + }); + + test('adds nothing when nothing is printed', async () => { + expect( + (await rr('nope', 'x', {pipedData: 'foo', outputMatch: true, nl: true})).out + ).toBe(''); + await Bun.write('a.file', 'foo'); + expect((await rr('nope', 'x', {includeGlob: ['a.file'], nl: true})).out).toBe(''); + }); + + test('comes after the tail', async () => { + const config = { + outputMatch: true, + contentDelimiter: ', ', + contentDelimiterTail: true, + nl: true, + }; + expect((await run(config)).out).toBe('o, o, o, o, \n'); + expect((await run({outputMatch: true, tail: true, nl: true})).out).toBe('o\no\no\no\n'); + }); + }); + + describe('--delimiter and --null', () => { + test('--delimiter sets both delimiters', async () => { + expect(await run({output: true, delimiter: ', '})).toEqual({ + out: 'fxx, bar, fxx, ', + err: 'a.file, c.file, ', + exit: null, + }); + }); + + test('--null is the same as --delimiter with a NUL byte', async () => { + expect(await run({output: true, null: true})).toEqual({ + out: 'fxx\0bar\0fxx\0', + err: 'a.file\0c.file\0', + exit: null, + }); + }); + + test('the most specific delimiter wins', async () => { + expect((await run({output: true, delimiter: ', ', null: true})).out).toBe( + 'fxx, bar, fxx, ' + ); + expect( + await run({ + output: true, + delimiter: ', ', + contentDelimiter: '|', + nameDelimiter: ';', + }) + ).toEqual({out: 'fxx|bar|fxx|', err: 'a.file;c.file;', exit: null}); + expect(await run({output: true, null: true, nameDelimiter: ', '})).toEqual({ + out: 'fxx\0bar\0fxx\0', + err: 'a.file, c.file, ', + exit: null, + }); + }); + }); + + // stdout has the result, and no file is changed. b.file has nothing to replace, so it is not named. + describe.each([ + ['output', 'fxx\nbar\nfxx\n'], + ['outputMatch', 'o\no\no\no\n'], + ])('with --%s', (flag, out) => { + test('go to stderr', async () => { + expect(await run({[flag]: true})).toEqual({out, err: 'a.file\nc.file\n', exit: null}); + expect(await Bun.file('a.file').text()).toBe('foo'); + }); + + test('are the same names as without the flag', async () => { + expect((await run({[flag]: true})).err).toBe((await run()).out); + }); + + test.each(['quiet', 'quietTotal'])('are not printed with --%s', async (quiet) => { + expect(await run({[flag]: true, [quiet]: true})).toEqual({out, err: '', exit: null}); + }); + + test('follow --null, --name-delimiter and --absolute-path', async () => { + expect((await run({[flag]: true, null: true})).err).toBe('a.file\0c.file\0'); + expect((await run({[flag]: true, nameDelimiter: ', '})).err).toBe('a.file, c.file, '); + expect((await run({[flag]: true, absolutePath: true})).err).toBe( + path.resolve('a.file') + '\n' + path.resolve('c.file') + '\n' + ); + }); + }); + + describe('--absolute-path', () => { + test('prints the full path of each name', async () => { + expect(await run({absolutePath: true})).toEqual({ + out: path.resolve('a.file') + '\n' + path.resolve('c.file') + '\n', + err: '', + exit: null, + }); + }); + + test('leaves a path that is already full as it is', async () => { + fs.mkdirSync('work'); + const file = path.resolve('other', 'my.file'); + await Bun.write(file, 'foo'); + process.chdir('work'); + expect((await rr('o', 'x', {includeGlob: [file], absolutePath: true})).out).toBe( + file + '\n' + ); + expect(await Bun.file(file).text()).toBe('fxx'); + }); + + test.each([ + ['null', {null: true}, '\0', '\0'], + ['name-delimiter', {nameDelimiter: ', ', nameDelimiterTail: false}, ', ', ''], + ])('works with --%s', async (name, flags, between, last) => { + expect((await run({absolutePath: true, ...flags})).out).toBe( + path.resolve('a.file') + between + path.resolve('c.file') + last + ); + }); + }); +}); diff --git a/test/js/fix/705.test.js b/test/js/fix/705.test.js index 17511b45..069f24c7 100644 --- a/test/js/fix/705.test.js +++ b/test/js/fix/705.test.js @@ -25,7 +25,7 @@ describe('#705 the engine returns a promise that resolves when all files are don ])('nothing is left to do with %s', async (name, flags, files) => { fs.writeFileSync('my.file', 'foobar'); const result = await rr('o', 'x', {includeGlob: ['my.file'], ...flags}); - expect(result).toEqual({out: '', err: 'my.file \n', exit: null}); + expect(result).toEqual({out: 'my.file\n', err: '', exit: null}); expect(fs.readFileSync('my.file', 'utf8')).toBe('fxxbar'); expect(fs.readdirSync('.')).toHaveLength(files); }); @@ -37,7 +37,7 @@ describe('#705 the engine returns a promise that resolves when all files are don const result = await rr('o', 'x', {includeGlob: ['*.file'], voidAsync: false}); expect(content(names)).toEqual(names.map(() => 'fxx')); - expect(result.err.trim().split(' \n').sort()).toEqual(names.sort()); + expect(result.out.trim().split('\n').sort()).toEqual(names.sort()); expect(fs.readdirSync('.')).toHaveLength(names.length); }); diff --git a/test/js/fix/719.test.js b/test/js/fix/719.test.js index e1cbadd2..96ad634d 100644 --- a/test/js/fix/719.test.js +++ b/test/js/fix/719.test.js @@ -3,7 +3,8 @@ import {describe, test, expect} from 'bun:test'; import {rr} from '../helper.js'; -const warning = ' 🟡 Data is piped in, but no flag tells what to do with it. It is not used. \n\n'; +const warning = ' 🟡 Data is piped in, but no flag tells what to do with it. It is not used.\n\n'; +const expectedOutput = 'my.file\n'; // Run on my.file (with the content `foobar`) with `x` piped in async function piped(flags = {}, replacement = '_') { @@ -13,31 +14,39 @@ async function piped(flags = {}, replacement = '_') { describe('#719 a warning when piped data is not used', () => { test('is given when data is piped in and files are also given', async () => { - expect(await piped()).toEqual({out: '', err: warning + 'my.file \n', exit: null}); + expect(await piped()).toEqual({out: expectedOutput, err: warning, exit: null}); expect(await Bun.file('my.file').text()).toBe('foo_ar'); }); test.each(['quiet', 'quietTotal'])('is not shown with --%s', async (flag) => { - expect((await piped({[flag]: true})).err).toBe(''); + expect(await piped({[flag]: true})).toEqual({out: '', err: '', exit: null}); }); test('does not stop the run with --halt', async () => { expect(await piped({halt: true})).toEqual({ - out: '', - err: warning + 'my.file \n', + out: expectedOutput, + err: warning, exit: null, }); expect(await Bun.file('my.file').text()).toBe('foo_ar'); }); test('is not given when the piped data is used', async () => { - expect((await piped({replacementPipe: true})).err).toBe('my.file \n'); - expect((await piped({replacementJs: true}, 'pipe')).err).toBe('my.file \n'); + expect(await piped({replacementPipe: true})).toEqual({ + out: expectedOutput, + err: '', + exit: null, + }); + expect(await piped({replacementJs: true}, 'pipe')).toEqual({ + out: expectedOutput, + err: '', + exit: null, + }); expect(await Bun.file('my.file').text()).toBe('fooxar'); }); test.each([null, '', '\n', '\r\n'])('is not given when %j is piped in', async (pipedData) => { - expect((await piped({pipedData})).err).toBe('my.file \n'); + expect(await piped({pipedData})).toEqual({out: expectedOutput, err: '', exit: null}); }); test('is not given when only data is piped in', async () => { diff --git a/test/js/fix/726.test.js b/test/js/fix/726.test.js index 3edf7f83..57d0b47a 100644 --- a/test/js/fix/726.test.js +++ b/test/js/fix/726.test.js @@ -25,7 +25,7 @@ describe('#726 a file given with an absolute path is changed', () => { test.each(flows)('as a path with %s', async (name, flags) => { const file = await outside('my.file'); const result = await rr('o', 'x', {includeGlob: [file], ...flags}); - expect(result).toEqual({out: '', err: file + ' \n', exit: null}); + expect(result).toEqual({out: file + '\n', err: '', exit: null}); expect(await Bun.file(file).text()).toBe('fxxbar'); expect(fs.readdirSync(path.dirname(file))).toEqual(['my.file']); }); @@ -34,7 +34,7 @@ describe('#726 a file given with an absolute path is changed', () => { const file = await outside('my.file'); const glob = path.join(path.dirname(file), '*.file'); const result = await rr('o', 'x', {includeGlob: [glob], ...flags}); - expect(result).toEqual({out: '', err: file + ' \n', exit: null}); + expect(result).toEqual({out: file + '\n', err: '', exit: null}); expect(await Bun.file(file).text()).toBe('fxxbar'); expect(fs.readdirSync(path.dirname(file))).toEqual(['my.file']); }); @@ -43,7 +43,7 @@ describe('#726 a file given with an absolute path is changed', () => { await Bun.write('my.file', 'foobar'); const file = path.resolve('my.file'); const result = await rr('o', 'x', {includeGlob: [file]}); - expect(result).toEqual({out: '', err: file + ' \n', exit: null}); + expect(result).toEqual({out: file + '\n', err: '', exit: null}); expect(await Bun.file(file).text()).toBe('fxxbar'); expect(fs.readdirSync('.')).toEqual(['my.file']); }); @@ -82,7 +82,7 @@ describe('#726 a file given with an absolute path is changed', () => { path.join('..', 'other', 'my.file'), path.join('..', 'other'), 'string', - ].join('|') + ].join('|') + '\n' ); }); diff --git a/test/js/halt.test.js b/test/js/halt.test.js index 8f4d16a2..0738388a 100644 --- a/test/js/halt.test.js +++ b/test/js/halt.test.js @@ -31,7 +31,7 @@ describe('--halt', () => { test('changes nothing when all goes well', async () => { fs.writeFileSync('my.file', 'foobar'); const result = await rr('o', 'x', {includeGlob: ['my.file'], halt: true}); - expect(result).toEqual({out: '', err: 'my.file \n', exit: null}); + expect(result).toEqual({out: 'my.file\n', err: '', exit: null}); expect(fs.readFileSync('my.file', 'utf8')).toBe('fxxbar'); }); diff --git a/test/js/helper.js b/test/js/helper.js index ca662ffb..45a896ab 100644 --- a/test/js/helper.js +++ b/test/js/helper.js @@ -4,6 +4,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import {engine} from '../../src/engine.ts'; +import {alias} from '../../src/alias.ts'; // Every test runs in its own empty directory const root = process.cwd(); @@ -19,7 +20,7 @@ afterEach(() => { fs.rmSync(dir, {recursive: true, force: true}); }); -// Run the engine in this process (no CLI involved) with the defaults the CLI would give it. +// Run the engine in this process (no CLI involved) with the defaults and the `€` and `§` aliases the CLI would give it. // Gives what was sent to stdout (`out`) and stderr (`err`) plus the exit code if the engine wanted to stop the process (`exit`). // Files are handled one at a time unless `voidAsync: false` is given, so the output comes in the same order on every run. export async function rr(pattern, replacement, config = {}) { @@ -34,6 +35,10 @@ export async function rr(pattern, replacement, config = {}) { spyOn(console, 'error').mockImplementation((...data) => { result.err += data.join(' ') + '\n'; }), + spyOn(process.stderr, 'write').mockImplementation((data) => { + result.err += data; + return true; + }), spyOn(process, 'exit').mockImplementation((code) => { result.exit = code; throw halt; @@ -41,7 +46,7 @@ export async function rr(pattern, replacement, config = {}) { ]; try { - await engine({ + const options = { engine: 'default', encoding: 'utf8', pipedData: null, @@ -52,7 +57,9 @@ export async function rr(pattern, replacement, config = {}) { pattern, replacement, ...config, - }); + }; + alias(options); + await engine(options); } catch (e) { if (e !== halt) { throw e; @@ -64,6 +71,11 @@ export async function rr(pattern, replacement, config = {}) { return result; } +// What the content of the files gives by default: a line break after each item, unless it already ends with one +export function ended(...items) { + return items.map((item) => (item.endsWith('\n') ? item : item + '\n')).join(''); +} + // Fuzz tests get new input on every run. Set RR_FUZZ_SEED to repeat a run. const fuzzSeed = Number(process.env.RR_FUZZ_SEED) || Math.floor(Math.random() * 0xffffffff); let fuzzSeedShown = false; diff --git a/test/js/literal.test.js b/test/js/literal.test.js index 8c5152a4..d24e76ee 100644 --- a/test/js/literal.test.js +++ b/test/js/literal.test.js @@ -67,7 +67,7 @@ describe('--literal', () => { test('with --output-match', async () => { expect( (await rr('o.', '_', {pipedData: 'foo. o.k', literal: true, outputMatch: true})).out - ).toBe('o.\no.\n'); + ).toBe('o.\no.'); }); test('with --replacement-js', async () => { diff --git a/test/js/output-match.test.js b/test/js/output-match.test.js index 5f16affd..40947812 100644 --- a/test/js/output-match.test.js +++ b/test/js/output-match.test.js @@ -5,17 +5,15 @@ import {rr} from './helper.js'; describe('--output-match', () => { test('prints each match on its own line', async () => { expect((await rr('[fb].', '_', {pipedData: 'foobar', outputMatch: true})).out).toBe( - 'fo\nba\n' + 'fo\nba' ); }); test('prints only the groups (with nothing in between) if the pattern has groups', async () => { expect((await rr('([fb](.))', '_', {pipedData: 'foobar', outputMatch: true})).out).toBe( - 'foo\nbaa\n' - ); - expect((await rr('(f)o(o)', '_', {pipedData: 'foobar', outputMatch: true})).out).toBe( - 'fo\n' + 'foo\nbaa' ); + expect((await rr('(f)o(o)', '_', {pipedData: 'foobar', outputMatch: true})).out).toBe('fo'); }); test('prints nothing if there is no match', async () => { @@ -28,14 +26,14 @@ describe('--output-match', () => { test('ignores the replacement', async () => { expect((await rr('o', 'anything $1 $&', {pipedData: 'foo', outputMatch: true})).out).toBe( - 'o\no\n' + 'o\no' ); }); test('does not change the file', async () => { fs.writeFileSync('my.file', 'foobar'); const result = await rr('[fb].', '_', {includeGlob: ['my.file'], outputMatch: true}); - expect(result).toEqual({out: 'fo\nba\n', err: '', exit: null}); + expect(result).toEqual({out: 'fo\nba\n', err: 'my.file\n', exit: null}); expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar'); expect(fs.readdirSync('.')).toEqual(['my.file']); }); @@ -49,34 +47,34 @@ describe('--output-match', () => { }); test('prints a match that spans lines as it is', async () => { - expect((await rr('a\\nb', '_', {pipedData: 'a\nb', outputMatch: true})).out).toBe('a\nb\n'); + expect((await rr('a\\nb', '_', {pipedData: 'a\nb', outputMatch: true})).out).toBe('a\nb'); }); test('prints an empty line for an empty match', async () => { - expect((await rr('^', '_', {pipedData: 'a\nb', outputMatch: true})).out).toBe('\n\n'); + expect((await rr('^', '_', {pipedData: 'a\nb', outputMatch: true})).out).toBe('\n'); }); - test.todo('BUG #684: a group that did not take part in the match prints nothing', async () => { + test('prints nothing for a group that did not take part in the match (#684)', async () => { expect((await rr('(f)|(b)', '_', {pipedData: 'foobar', outputMatch: true})).out).toBe( - 'f\nb\n' + 'f\nb' ); }); test.todo('BUG #685: named groups are printed like any other group', async () => { expect( (await rr('(?f)(?o)', '_', {pipedData: 'foobar', outputMatch: true})).out - ).toBe('fo\n'); + ).toBe('fo'); }); describe('combined', () => { test('with --void-global only the first match is printed', async () => { const config = {pipedData: 'foobar', outputMatch: true, voidGlobal: true}; - expect((await rr('[fb].', '_', config)).out).toBe('fo\n'); + expect((await rr('[fb].', '_', config)).out).toBe('fo'); }); test('with --void-ignore-case', async () => { const config = {pipedData: 'Foo foo', outputMatch: true, voidIgnoreCase: true}; - expect((await rr('f.', '_', config)).out).toBe('fo\n'); + expect((await rr('f.', '_', config)).out).toBe('fo'); }); test('with --dot-all and --void-multiline', async () => { @@ -86,19 +84,19 @@ describe('--output-match', () => { dotAll: true, voidMultiline: true, }; - expect((await rr('^(.)(.*)(.)$', '_', config)).out).toBe('ab\ncd\n'); + expect((await rr('^(.)(.*)(.)$', '_', config)).out).toBe('ab\ncd'); }); test('with --literal', async () => { expect( (await rr('a.', '_', {pipedData: 'ab a.', outputMatch: true, literal: true})).out - ).toBe('a.\n'); + ).toBe('a.'); }); test('with --replacement-js the code is not run', async () => { const config = {pipedData: 'foobar', outputMatch: true, replacementJs: true}; expect((await rr('[fb].', 'throw new Error("should not run")', config)).out).toBe( - 'fo\nba\n' + 'fo\nba' ); }); @@ -116,7 +114,7 @@ describe('--output-match', () => { test('with --quiet-total the matches are still printed', async () => { const config = {pipedData: 'foobar', outputMatch: true, quietTotal: true}; - expect((await rr('[fb].', '_', config)).out).toBe('fo\nba\n'); + expect((await rr('[fb].', '_', config)).out).toBe('fo\nba'); }); test('with --keep-backup there is nothing to back up', async () => { diff --git a/test/js/output.test.js b/test/js/output.test.js index f427b340..f948c923 100644 --- a/test/js/output.test.js +++ b/test/js/output.test.js @@ -6,34 +6,34 @@ describe('--output', () => { test('sends the result to stdout and leaves the file alone', async () => { fs.writeFileSync('my.file', 'foobar'); const result = await rr('o', 'x', {includeGlob: ['my.file'], output: true}); - expect(result).toEqual({out: 'fxxbar', err: '', exit: null}); + expect(result).toEqual({out: 'fxxbar\n', err: 'my.file\n', exit: null}); expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar'); }); test('is not the default for files', async () => { fs.writeFileSync('my.file', 'foobar'); - expect((await rr('o', 'x', {includeGlob: ['my.file']})).out).toBe(''); + expect((await rr('o', 'x', {includeGlob: ['my.file']})).out).toBe('my.file\n'); }); test('sends the content even if nothing was replaced', async () => { fs.writeFileSync('my.file', 'foobar'); - expect((await rr('x', 'x', {includeGlob: ['my.file'], output: true})).out).toBe('foobar'); + expect((await rr('x', 'x', {includeGlob: ['my.file'], output: true})).out).toBe('foobar\n'); }); - test('adds nothing to the content', async () => { + test('adds a line break after the content, unless it already ends with one', async () => { fs.writeFileSync('my.file', 'foo\n\n'); fs.writeFileSync('empty.file', ''); expect((await rr('o', 'x', {includeGlob: ['my.file'], output: true})).out).toBe('fxx\n\n'); - expect((await rr('o', 'x', {includeGlob: ['empty.file'], output: true})).out).toBe(''); + expect((await rr('o', 'x', {includeGlob: ['empty.file'], output: true})).out).toBe('\n'); }); - test('sends several files right after each other in alphabetical order', async () => { + test('sends several files in alphabetical order with a line break after each', async () => { fs.writeFileSync('b.file', 'boo\n'); fs.writeFileSync('a.file', 'foo'); fs.writeFileSync('c.file', 'zoo'); expect( (await rr('o', 'x', {includeGlob: ['c.file', 'b.file', 'a.file'], output: true})).out - ).toBe('fxxbxx\nzxx'); + ).toBe('fxx\nbxx\nzxx\n'); }); test('is always on for piped data', async () => { @@ -57,15 +57,15 @@ describe('--output', () => { test('with --quiet and --quiet-total the result is still sent', async () => { fs.writeFileSync('my.file', 'foobar'); const config = {includeGlob: ['my.file'], output: true}; - expect((await rr('o', 'x', {...config, quiet: true})).out).toBe('fxxbar'); - expect((await rr('o', 'x', {...config, quietTotal: true})).out).toBe('fxxbar'); + expect((await rr('o', 'x', {...config, quiet: true})).out).toBe('fxxbar\n'); + expect((await rr('o', 'x', {...config, quietTotal: true})).out).toBe('fxxbar\n'); }); test('with --verbose and --debug the chatter goes to stderr only', async () => { fs.writeFileSync('my.file', 'foobar'); const config = {includeGlob: ['my.file'], output: true, verbose: true, debug: true}; const result = await rr('o', 'x', config); - expect(result.out).toBe('fxxbar'); + expect(result.out).toBe('fxxbar\n'); expect(result.err).toContain('Output result from: my.file'); }); @@ -80,7 +80,7 @@ describe('--output', () => { fs.writeFileSync('a.file', 'foo'); fs.writeFileSync('b.file', 'boo'); const config = {includeGlob: ['*.file'], excludeGlob: ['a.*'], output: true}; - expect((await rr('o', 'x', config)).out).toBe('bxx'); + expect((await rr('o', 'x', config)).out).toBe('bxx\n'); }); test.each([{voidAsync: true}, {voidBackup: true}, {voidAsync: true, voidBackup: true}])( @@ -92,7 +92,7 @@ describe('--output', () => { output: true, ...config, }); - expect(result.out).toBe('fxxbar'); + expect(result.out).toBe('fxxbar\n'); expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar'); } ); diff --git a/test/js/quiet.test.js b/test/js/quiet.test.js index f053c531..93878e3d 100644 --- a/test/js/quiet.test.js +++ b/test/js/quiet.test.js @@ -12,7 +12,7 @@ describe('--quiet', () => { test('is not the default', async () => { fs.writeFileSync('my.file', 'foobar'); - expect((await rr('o', 'x', {includeGlob: ['my.file']})).err).toBe('my.file \n'); + expect((await rr('o', 'x', {includeGlob: ['my.file']})).out).toBe('my.file\n'); }); test('still stops on an invalid pattern', async () => { @@ -42,7 +42,7 @@ describe('--quiet', () => { fs.writeFileSync('my.file', 'foobar'); const result = await rr('o', 'x', {includeGlob: ['my.file'], quiet: true, debug: true}); expect(result.err).toContain('Work on content from: my.file'); - expect(result.err).not.toContain('my.file \n'); + expect(result.out).toBe(''); }); test.each(['output', 'outputMatch'])('with --%s stdout is not affected', async (flag) => { diff --git a/test/js/replacement-js.test.js b/test/js/replacement-js.test.js index 51a4ea86..a59f0722 100644 --- a/test/js/replacement-js.test.js +++ b/test/js/replacement-js.test.js @@ -15,6 +15,8 @@ const inFile = async (code, text = 'foobar', file = 'my.file') => { includeGlob: [file], replacementJs: true, output: true, + // Without the tail only the value of the code is given + contentDelimiterTail: false, voidMultiline: true, }) ).out; @@ -130,8 +132,8 @@ describe('--replacement-js', () => { expect(await js('o', 'pipe', 'fo')).toBe('ffo'); fs.writeFileSync('my.file', 'foobar'); const config = {includeGlob: ['my.file'], replacementJs: true, output: true}; - expect((await rr('foo', 'pipe', {...config, pipedData: 'boo'})).out).toBe('boobar'); - expect((await rr('foo', 'String(pipe)', config)).out).toBe('nullbar'); + expect((await rr('foo', 'pipe', {...config, pipedData: 'boo'})).out).toBe('boobar\n'); + expect((await rr('foo', 'String(pipe)', config)).out).toBe('nullbar\n'); }); test('file, file_rel, dirpath, dirpath_rel, dirname, filename, name and ext tell about the file', async () => { @@ -202,7 +204,12 @@ describe('--replacement-js', () => { fs.writeFileSync('my.file', 'foobar'); const longAgo = new Date('2001-02-03T04:05:06.789Z'); fs.utimesSync('my.file', longAgo, longAgo); - const config = {includeGlob: ['my.file'], replacementJs: true, output: true}; + const config = { + includeGlob: ['my.file'], + replacementJs: true, + output: true, + contentDelimiterTail: false, + }; expect((await rr('foobar', 'mtime', config)).out).toMatch(timestamp); expect((await rr('foobar', 'mtime_obj.toISOString()', config)).out).toBe( @@ -321,7 +328,7 @@ describe('--replacement-js', () => { replacementJs: true, output: true, }; - expect((await rr('foo', 'name', config)).out).toBe('b'); + expect((await rr('foo', 'name', config)).out).toBe('b\n'); }); }); }); diff --git a/test/js/replacement-pipe.test.js b/test/js/replacement-pipe.test.js index 095514e1..64fdfd09 100644 --- a/test/js/replacement-pipe.test.js +++ b/test/js/replacement-pipe.test.js @@ -17,23 +17,23 @@ describe('--replacement-pipe', () => { pipedData: 'x', output: true, }; - expect((await rr('b', 'ignored $& $1', config)).out).toBe('fooxar'); + expect((await rr('b', 'ignored $& $1', config)).out).toBe('fooxar\n'); }); test('is not the default', async () => { fs.writeFileSync('my.file', 'foobar'); const config = {includeGlob: ['my.file'], pipedData: 'x', output: true}; - expect((await rr('b', '_', config)).out).toBe('foo_ar'); + expect((await rr('b', '_', config)).out).toBe('foo_ar\n'); }); test('uses the piped data exactly as it is', async () => { fs.writeFileSync('my.file', 'foobar'); const config = {includeGlob: ['my.file'], replacementPipe: true, output: true}; - expect((await rr('b', '_', {...config, pipedData: ' x\n'})).out).toBe('foo x\nar'); - expect((await rr('b', '_', {...config, pipedData: `'"\`\\n`})).out).toBe(`foo'"\`\\nar`); - expect((await rr('b', '_', {...config, pipedData: '€§'})).out).toBe('foo€§ar'); + expect((await rr('b', '_', {...config, pipedData: ' x\n'})).out).toBe('foo x\nar\n'); + expect((await rr('b', '_', {...config, pipedData: `'"\`\\n`})).out).toBe(`foo'"\`\\nar\n`); + expect((await rr('b', '_', {...config, pipedData: '€§'})).out).toBe('foo€§ar\n'); expect((await rr('b', '_', {...config, pipedData: 'line 1\nline 2\n'})).out).toBe( - 'fooline 1\nline 2\nar' + 'fooline 1\nline 2\nar\n' ); }); @@ -41,7 +41,7 @@ describe('--replacement-pipe', () => { fs.writeFileSync('my.file', 'foobar'); const config = {includeGlob: ['my.file'], replacementPipe: true, output: true}; expect((await rr('(o)(b)', '_', {...config, pipedData: '$2$1[$&]'})).out).toBe( - 'fobo[ob]ar' + 'fobo[ob]ar\n' ); }); @@ -53,7 +53,7 @@ describe('--replacement-pipe', () => { pipedData: '', output: true, }; - expect((await rr('o', '_', config)).out).toBe('fbar'); + expect((await rr('o', '_', config)).out).toBe('fbar\n'); }); test('stops with an error if nothing is piped in', async () => { @@ -66,7 +66,7 @@ describe('--replacement-pipe', () => { test('does not treat the piped data as content to search in', async () => { const result = await rr('o', '_', {replacementPipe: true, pipedData: 'foo'}); - expect(result).toEqual({out: '', err: '0 files found \n', exit: null}); + expect(result).toEqual({out: '', err: '0 files found\n', exit: null}); }); describe('combined', () => { @@ -79,7 +79,7 @@ describe('--replacement-pipe', () => { output: true, }; expect((await rr('(o+)', '_', {...config, pipedData: '$1.length + name'})).out).toBe( - 'f2mybar' + 'f2mybar\n' ); }); @@ -92,7 +92,7 @@ describe('--replacement-pipe', () => { literal: true, output: true, }; - expect((await rr('.', '_', config)).out).toBe('a[.]b'); + expect((await rr('.', '_', config)).out).toBe('a[.]b\n'); }); test('with --void-global and --void-ignore-case', async () => { @@ -131,9 +131,9 @@ describe('--replacement-pipe', () => { pipedData: '€1§n', output: true, }; - expect((await rr('(b)', '_', config)).out).toBe('foo€1§nar'); + expect((await rr('(b)', '_', config)).out).toBe('foo€1§nar\n'); expect((await rr('(b)', '_', {...config, voidEuro: true, voidSection: true})).out).toBe( - 'foo€1§nar' + 'foo€1§nar\n' ); }); }); diff --git a/test/js/unicode.test.js b/test/js/unicode.test.js index a4b0474d..6d5f2540 100644 --- a/test/js/unicode.test.js +++ b/test/js/unicode.test.js @@ -63,7 +63,7 @@ describe('--unicode', () => { test('with --output-match', async () => { const config = {pipedData: 'a🦖 b🦕', unicode: true, outputMatch: true}; - expect((await rr('\\w(.)', '_', config)).out).toBe('🦖\n🦕\n'); + expect((await rr('\\w(.)', '_', config)).out).toBe('🦖\n🦕'); }); test('with --replacement-js', async () => { diff --git a/test/js/verbose.test.js b/test/js/verbose.test.js index 0c842dd8..02c438e3 100644 --- a/test/js/verbose.test.js +++ b/test/js/verbose.test.js @@ -6,13 +6,17 @@ describe('--verbose', () => { test('tells what is going on', async () => { fs.writeFileSync('my.file', 'foobar'); const result = await rr('o', 'x', {includeGlob: ['my.file'], verbose: true}); - expect(result.err).toBe('1 files found \nOpen sync: my.file \nmy.file \n'); - expect(result.out).toBe(''); + expect(result.err).toBe('1 files found\nOpen sync: my.file\n'); + expect(result.out).toBe('my.file\n'); }); test('is not the default', async () => { fs.writeFileSync('my.file', 'foobar'); - expect((await rr('o', 'x', {includeGlob: ['my.file']})).err).toBe('my.file \n'); + expect(await rr('o', 'x', {includeGlob: ['my.file']})).toEqual({ + out: 'my.file\n', + err: '', + exit: null, + }); }); test('tells when nothing was changed in a file', async () => { @@ -70,8 +74,8 @@ describe('--verbose', () => { verbose: true, output: true, }); - expect(result.out).toBe('fxxbar'); - expect(result.err).toBe('1 files found \nOpen sync: my.file \n'); + expect(result.out).toBe('fxxbar\n'); + expect(result.err).toBe('1 files found\nOpen sync: my.file\nmy.file\n'); }); test('with --replacement-js piped data counts as used', async () => { diff --git a/test/js/void-async.test.js b/test/js/void-async.test.js index e8ed9739..26dd7690 100644 --- a/test/js/void-async.test.js +++ b/test/js/void-async.test.js @@ -7,22 +7,33 @@ import {rr} from './helper.js'; describe('--void-async', () => { test('is done with a file before the next one is opened', async () => { ['a', 'b'].forEach((name) => fs.writeFileSync(name + '.file', 'foo')); - const config = {includeGlob: ['*.file'], voidAsync: true, verbose: true}; - expect((await rr('o', 'x', config)).err).toBe( - '2 files found \nOpen sync: a.file \na.file \nOpen sync: b.file \nb.file \n' - ); + const config = {includeGlob: ['*.file'], voidAsync: true, debug: true}; + const result = await rr('o', 'x', config); + expect(result.err.trim().split('\n')).toEqual([ + '"2 files found"', + '"Open sync: a.file"', + '"Work on content from: a.file"', + '"Write new content to: a.file"', + '"Open sync: b.file"', + '"Work on content from: b.file"', + '"Write new content to: b.file"', + ]); + expect(result.out).toBe('a.file\nb.file\n'); }); test('is not the default: all files are opened before the first one is done', async () => { ['a', 'b'].forEach((name) => fs.writeFileSync(name + '.file', 'foo')); - const config = {includeGlob: ['*.file'], voidAsync: false, verbose: true}; - const lines = (await rr('o', 'x', config)).err.split(' \n'); + const config = {includeGlob: ['*.file'], voidAsync: false, debug: true}; + const result = await rr('o', 'x', config); + const lines = result.err.split('\n'); expect(lines.slice(0, 3)).toEqual([ - '2 files found', - 'Open async: a.file', - 'Open async: b.file', + '"2 files found"', + '"Open async: a.file"', + '"Open async: b.file"', ]); - expect(lines.slice(3).sort()).toEqual(['', 'a.file', 'b.file']); + // Either file can be read first + expect(lines[3]).toMatch(/^"Work on content from: [ab]\.file"$/); + expect(result.out.split('\n').sort()).toEqual(['', 'a.file', 'b.file']); expect(['a', 'b'].map((name) => fs.readFileSync(name + '.file', 'utf8'))).toEqual([ 'fxx', 'fxx', @@ -32,7 +43,7 @@ describe('--void-async', () => { test('handles the files one by one in alphabetical order', async () => { ['c', 'a', 'd', 'b'].forEach((name) => fs.writeFileSync(name + '.file', 'foo')); const result = await rr('o', 'x', {includeGlob: ['*.file'], voidAsync: true}); - expect(result.err).toBe('a.file \nb.file \nc.file \nd.file \n'); + expect(result.out).toBe('a.file\nb.file\nc.file\nd.file\n'); }); test('leaves no temporary files behind', async () => { diff --git a/test/js/void-backup.test.js b/test/js/void-backup.test.js index 30584c5c..0a0a846c 100644 --- a/test/js/void-backup.test.js +++ b/test/js/void-backup.test.js @@ -8,7 +8,7 @@ describe('--void-backup', () => { const result = await rr('o', 'x', {includeGlob: ['my.file'], voidBackup: true}); expect(fs.readFileSync('my.file', 'utf8')).toBe('fxxbar'); expect(fs.readdirSync('.')).toEqual(['my.file']); - expect(result.err).toBe('my.file \n'); + expect(result.out).toBe('my.file\n'); }); test('does not write a file where nothing was replaced', async () => { @@ -47,7 +47,7 @@ describe('--void-backup', () => { test('with --output the file is left alone', async () => { fs.writeFileSync('my.file', 'foobar'); const config = {includeGlob: ['my.file'], voidBackup: true, output: true}; - expect((await rr('o', 'x', config)).out).toBe('fxxbar'); + expect((await rr('o', 'x', config)).out).toBe('fxxbar\n'); expect(fs.readFileSync('my.file', 'utf8')).toBe('foobar'); }); diff --git a/test/js/void-euro.test.js b/test/js/void-euro.test.js index 8d96d97c..15730a64 100644 --- a/test/js/void-euro.test.js +++ b/test/js/void-euro.test.js @@ -70,9 +70,9 @@ describe('--void-euro', () => { test('with --replacement-pipe the piped replacement is never converted', async () => { fs.writeFileSync('my.file', 'foo'); const config = {includeGlob: ['my.file'], output: true, replacementPipe: true}; - expect((await rr('(o)', '_', {...config, pipedData: '€1'})).out).toBe('f€1€1'); + expect((await rr('(o)', '_', {...config, pipedData: '€1'})).out).toBe('f€1€1\n'); expect((await rr('(o)', '_', {...config, pipedData: '€1', voidEuro: true})).out).toBe( - 'f€1€1' + 'f€1€1\n' ); }); diff --git a/test/js/void-global.test.js b/test/js/void-global.test.js index a17c9882..21099801 100644 --- a/test/js/void-global.test.js +++ b/test/js/void-global.test.js @@ -28,7 +28,7 @@ describe('--void-global', () => { describe('combined', () => { test('with --output-match only the first match is printed', async () => { const config = {pipedData: 'foobar', outputMatch: true, voidGlobal: true}; - expect((await rr('[fb].', '_', config)).out).toBe('fo\n'); + expect((await rr('[fb].', '_', config)).out).toBe('fo'); }); test('with the default multiline only the first line is touched', async () => { diff --git a/test/js/void-ignore-case.test.js b/test/js/void-ignore-case.test.js index 46e69cd9..312df035 100644 --- a/test/js/void-ignore-case.test.js +++ b/test/js/void-ignore-case.test.js @@ -39,7 +39,7 @@ describe('--void-ignore-case', () => { test('with --output-match', async () => { const config = {pipedData: 'foo Foo FOO', outputMatch: true, voidIgnoreCase: true}; - expect((await rr('F.o', '_', config)).out).toBe('Foo\n'); + expect((await rr('F.o', '_', config)).out).toBe('Foo'); }); test('with --replacement-js', async () => { diff --git a/test/js/void-multiline.test.js b/test/js/void-multiline.test.js index bd89acbb..0b4b794e 100644 --- a/test/js/void-multiline.test.js +++ b/test/js/void-multiline.test.js @@ -52,7 +52,7 @@ describe('--void-multiline', () => { test('with --output-match', async () => { const config = {pipedData: 'ab\ncd\nef', voidMultiline: true, outputMatch: true}; - expect((await rr('^.|.$', '_', config)).out).toBe('a\nf\n'); + expect((await rr('^.|.$', '_', config)).out).toBe('a\nf'); }); test('with --replacement-js', async () => { From 86b7bad81a34f278d59f313bb6098c35854ad58d Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Thu, 22 Oct 2026 00:28:02 +1100 Subject: [PATCH 2/4] readme --- README.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index eeb7883a..ca1235bb 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,28 @@ Require has been given the alias `r` and both are expanded to understand relativ > rexreplace 'VERSION' 'r("package").version' -j dist/*.js ``` +--- + +#### Piping data in + +Pipe data in without giving any files, and the piped data is the text to search (unless `-R` uses it as the replacement). The result is printed. + +```bash +> cat myfile.md | rexreplace 'foo' 'bar' +``` + +Let the output of another command be the replacement with `-R`. The piped data is then used as the replacement, so give the files to work on. Here the hash of the latest commit goes into your distribution js files (use the string `COMMIT` in your source files). The `_` is a dummy value, as the replacement comes from the pipe, and `-T` trims away the line break at the end. + +```bash +> git rev-parse --short HEAD | rexreplace 'COMMIT' _ -R -T dist/*.js +``` + +Want to shape the piped data first? `--js-pipe` hands it to your javascript as `pipe`. + +```bash +> git log -1 --format=%an | rexreplace 'AUTHOR' 'pipe.trim().toUpperCase()' --js-pipe docs/*.md +``` + #### Reference a matching group Let 'foobar' become 'barfoo' in myfile.md @@ -265,7 +287,7 @@ Let both `"foo 'is' bar"` and `' foo "was" bar'` become `» foo ... bar «` inde - Pattern defaults to global multiline case-insensitive search - Supports regex lookaheads in the pattern - Supports backreference to matching groups in the replacement -- Data to be treated can be piped in +- Data can be piped in, both as the text to search and as the replacement - The names of the affected files are printed to stdout, so they can be piped on (to stderr with `--output` and `--output-match`) - Pick the delimiter between names and between the content of files, like `--null` for `xargs -0` - See the [release note](https://github.com/mathiasrw/rexreplace/releases) for a log of changes. Descriptions are given in latest patch version. @@ -280,6 +302,8 @@ Let both `"foo 'is' bar"` and `' foo "was" bar'` become `» foo ... bar «` inde - The `€` and `§` aliases are only for what you write as pattern, replacement (also with `--replacement-js`), `--exclude-re` and the delimiters. File globs, `--exclude-glob` and piped data (both content to search and a replacement given with `--replacement-pipe`) are used exactly as they are, so a file named `price€.txt` is found without any flags. +- Just like `grep`, RexReplace only waits for piped data when it needs it: when no files are given, or when you ask for it with `-R` or `--pipe`. So a CI runner, a cron job or `docker exec` that leaves stdin open only makes it wait when piped data is expected. + ### Limitations From 032cd46fa4d44c7ad65218cccd44286d424852b1 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Thu, 22 Oct 2026 01:01:43 +1100 Subject: [PATCH 3/4] Readme --- README.md | 78 ++++++++++++++++++--------------------------------- src/cli.ts | 6 ++-- src/engine.ts | 2 +- src/output.ts | 2 +- 4 files changed, 33 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index ca1235bb..6fa031c1 100644 --- a/README.md +++ b/README.md @@ -102,26 +102,6 @@ Require has been given the alias `r` and both are expanded to understand relativ --- -#### Piping data in - -Pipe data in without giving any files, and the piped data is the text to search (unless `-R` uses it as the replacement). The result is printed. - -```bash -> cat myfile.md | rexreplace 'foo' 'bar' -``` - -Let the output of another command be the replacement with `-R`. The piped data is then used as the replacement, so give the files to work on. Here the hash of the latest commit goes into your distribution js files (use the string `COMMIT` in your source files). The `_` is a dummy value, as the replacement comes from the pipe, and `-T` trims away the line break at the end. - -```bash -> git rev-parse --short HEAD | rexreplace 'COMMIT' _ -R -T dist/*.js -``` - -Want to shape the piped data first? `--js-pipe` hands it to your javascript as `pipe`. - -```bash -> git log -1 --format=%an | rexreplace 'AUTHOR' 'pipe.trim().toUpperCase()' --js-pipe docs/*.md -``` - #### Reference a matching group Let 'foobar' become 'barfoo' in myfile.md @@ -166,6 +146,8 @@ RexReplace defaults to treating `§` as an alias for `\` so the following give s } ``` + + --- #### More relevant examples @@ -178,55 +160,50 @@ Add creation time, name of the file and human readable file size as the first li > rexreplace '^' 'ctime_ + name_ + size + nl' -j -M 'dist/**/*.*' ``` -**Use the names of the affected files** +**List changed files** -The name of each affected file is printed to stdout, one name on each line. Add all the affected files to git: +The path of each file that changed is printed to stdout, one name on each line. ```bash > rexreplace 'foo' 'bar' '**/*.md' | xargs git add ``` -Use `--null` if a name can have a space or a line break in it: +Use `--null` to separate names with `\0` instead of a new line. Can be helpful when chaining data to avoid problems with space and newlins. ```bash > rexreplace 'foo' 'bar' '**/*.md' --null | xargs -0 git add ``` -Pick what to put after each name with `--name-delimiter`. Use `--no-name-delimiter-tail` to only have it between the names: -```bash -> rexreplace 'foo' 'bar' '*.md' --name-delimiter ', ' --no-name-delimiter-tail -a.md, b.md -``` +**Matching pairs** -Get the full path of each file with `--absolute-path` (`-a`), and also the files where nothing was changed with `--name-all`: +Let both `"foo 'is' bar"` and `' foo "was" bar'` become `» foo ... bar «` independent of using `'` or `"` ```bash -> rexreplace 'foo' 'bar' 'docs/*.md' --absolute-path -/Users/me/project/docs/a.md -/Users/me/project/docs/b.md +> rexreplace '(['"])([^\1]+)\1' '» $2 «' myfile.md ``` -With `--output` or `--output-match` stdout has the result, so the names are printed to stderr. The content of each file with `--output`, and each match with `--output-match`, ends with a line break, unless it already does. Piped data is left as it is. Pick something else with `--content-delimiter`, or use `--content-delimiter ''` to join them: +--- + + +#### Piping data + +If you provide no files/globs as parameters, RexReplace expects that information to be provided via the piped data. One glob/file per line. ```bash -> rexreplace 'foo' 'bar' '*.md' --output --content-delimiter='---\n' 2>/dev/null -bar ---- -bar ---- -nothing ---- +> cat myfile.md | rexreplace 'foo' 'bar' ``` -`--delimiter` sets both delimiters, and `--null` is the same as `--delimiter '\0'`. The most specific flag wins, so `--null --name-delimiter ', '` gives `, ` after each name and a NUL byte between the content of each file. `--tail` puts the delimiter after the last name and the last content too, and `--no-tail` only between them. `--nl` (`-l`) makes sure what is printed to stdout and stderr ends with a line break, also for piped data, so `printf foo | rexreplace o x --nl` gives `fxx` on its own line. +Use the piped data as replacement with the `-R` flag. The `-T` flag trims the input. -**Matching pairs** +```bash +> git rev-parse --short HEAD | rexreplace 'COMMIT' _ -R -T dist/*.js +``` -Let both `"foo 'is' bar"` and `' foo "was" bar'` become `» foo ... bar «` independent of using `'` or `"` +Use --js-pipe to access the pipe data in your javascript code. ```bash -> rexreplace '(['"])([^\1]+)\1' '» $2 «' myfile.md +> git log -1 --format=%an | rexreplace 'AUTHOR' 'pipe.trim().toUpperCase()' --js-pipe docs/*.md ``` --- @@ -251,9 +228,9 @@ Let both `"foo 'is' bar"` and `' foo "was" bar'` become `» foo ... bar «` inde | `-E` | **`--engine`** What regex engine to use. `default` is the engine of the runtime: `V8` with Node and `JSC` (JavaScriptCore) with Bun. The engine of the other runtime can not be picked. [choices: "default", "V8", "JSC"] [default: "default"] | | `-q` | **`--quiet`** Only display errors (no other info) [boolean] | | `-Q` | **`--quiet-total`** Never display errors or info [boolean] | -| `-a` | **`--absolute-path`** Print the full path of affected files instead of the relative path. [boolean] | -| | **`--name-all`** Print the names of all identified files instead of just the files with content matching the search. [boolean] | -| | **`--name-delimiter`** String printed between the names of the relevant files. Default is a line break, unless the name already ends with one. [string] | +| `-a` | **`--absolute-path`** Print the full path of the named files instead of the relative path. [boolean] | +| | **`--name-all`** Print the names of all files found by the globs, also the ones where nothing was changed. [boolean] | +| | **`--name-delimiter`** String printed between the names of the files. Default is a line break, unless the name already ends with one. [string] | | | **`--content-delimiter`** String printed between the content of each file for --output, or between the matches for --output-match. Default is a line break, unless the content already ends with one. [string] | | | **`--delimiter`** Sets --name-delimiter and --content-delimiter to the same value. Supports escape sequences like `\t`, `\n` and the `€` and `§` aliases. [string] | | | **`--null`** Shorthand for --delimiter '\0'. Useful for piping content with spaces or line breaks (like for `xargs -0`). The specific delimiter values win over --delimiter, and --delimiter wins over --null. [boolean] | @@ -288,7 +265,7 @@ Let both `"foo 'is' bar"` and `' foo "was" bar'` become `» foo ... bar «` inde - Supports regex lookaheads in the pattern - Supports backreference to matching groups in the replacement - Data can be piped in, both as the text to search and as the replacement -- The names of the affected files are printed to stdout, so they can be piped on (to stderr with `--output` and `--output-match`) +- The names of the changed files are printed to stdout, so they can be piped on (to stderr with `--output` and `--output-match`) - Pick the delimiter between names and between the content of files, like `--null` for `xargs -0` - See the [release note](https://github.com/mathiasrw/rexreplace/releases) for a log of changes. Descriptions are given in latest patch version. @@ -300,9 +277,10 @@ Let both `"foo 'is' bar"` and `' foo "was" bar'` become `» foo ... bar «` inde - Per default `§` is treated as an alias for `\` in the pattern, the replacement and `--exclude-re`. The main reason is for you not to worry about escaping the escape of an escape. Use the `-§` flag if you need to search or replace the actual section char. -- The `€` and `§` aliases are only for what you write as pattern, replacement (also with `--replacement-js`), `--exclude-re` and the delimiters. File globs, `--exclude-glob` and piped data (both content to search and a replacement given with `--replacement-pipe`) are used exactly as they are, so a file named `price€.txt` is found without any flags. +- The `€` and `§` aliases apply to pattern, replacement, `--exclude-re` values and delimiters. File globs, `--exclude-glob` and piped data are used exactly as they are, so a file named `price€.txt` is found without any flags. + +- The defaults delimiter is a magic newline ensuring that there is _at least_ one `\n` after printed file content, replacemnt matche or names. This means that if the content already ends on a `\n` no additional `\n` is added. This beahvour can not be recreated with other delimiter values. -- Just like `grep`, RexReplace only waits for piped data when it needs it: when no files are given, or when you ask for it with `-R` or `--pipe`. So a CI runner, a cron job or `docker exec` that leaves stdin open only makes it wait when piped data is expected. ### Limitations diff --git a/src/cli.ts b/src/cli.ts index 16534751..620a713c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -112,19 +112,19 @@ const yargs = require('yargs') .alias('Q', 'quiet-total') .boolean('a') - .describe('a', 'Print the full path of affected files instead of the relative path.') + .describe('a', 'Print the full path of the named files instead of the relative path.') .alias('a', 'absolute-path') .boolean('name-all') .describe( 'name-all', - 'Print the names of all identified files instead of just the files with content matching the search.' + 'Print the names of all files found by the globs, also the ones where nothing was changed.' ) .string('name-delimiter') .describe( 'name-delimiter', - 'String printed between the names of the relevant files. Default is a line break, unless the name already ends with one.' + 'String printed between the names of the files. Default is a line break, unless the name already ends with one.' ) .string('content-delimiter') .describe( diff --git a/src/engine.ts b/src/engine.ts index 15c993d9..a5a8b494 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -130,7 +130,7 @@ function doReplacement(_file_rr: string, _config_rr: any, _data_rr: string) { debug('Output result from: ' + _file_rr); print(result, _config_rr.contentDelimiter, _config_rr.contentDelimiterTail); } - // The file is not changed, but it is still named if something was found in it + // The file is not changed, but it is still named if its content would change if ((_config_rr.nameAll || result !== _data_rr) && !_config_rr.dataIsPiped) { info(_file_rr); } diff --git a/src/output.ts b/src/output.ts index b6b064e3..73096afe 100644 --- a/src/output.ts +++ b/src/output.ts @@ -55,7 +55,7 @@ export const printDone = function () { }); }; -// The name of an affected file goes to stdout so the names can be piped +// The name of a changed file goes to stdout so the names can be piped export const info = function (name) { if (config.quiet || config.quietTotal) { return; From 2c241412346014f8df09c3f3b7a5cbdcdae3a239 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Thu, 22 Oct 2026 01:43:58 +1100 Subject: [PATCH 4/4] Treat matches from piped data like any other matches, and print named groups with --output-match to close #685 --- README.md | 8 +++++--- src/cli.ts | 2 +- src/engine.ts | 10 ++++++---- test/fuzzy/output-match.test.js | 4 ++-- test/js/debug.test.js | 2 +- test/js/dot-all.test.js | 2 +- test/js/fix/703.test.js | 10 +++++++++- test/js/literal.test.js | 2 +- test/js/output-match.test.js | 32 +++++++++++++++++--------------- test/js/unicode.test.js | 2 +- test/js/void-global.test.js | 2 +- test/js/void-ignore-case.test.js | 2 +- test/js/void-multiline.test.js | 2 +- 13 files changed, 47 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 6fa031c1..b18f9400 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ Let both `"foo 'is' bar"` and `' foo "was" bar'` become `» foo ... bar «` inde #### Piping data -If you provide no files/globs as parameters, RexReplace expects that information to be provided via the piped data. One glob/file per line. +If you provide no files/globs as parameters, the piped data is the text to search, and the result is printed just as it came in, with nothing added at the end. ```bash > cat myfile.md | rexreplace 'foo' 'bar' @@ -235,7 +235,7 @@ Use --js-pipe to access the pipe data in your javascript code. | | **`--delimiter`** Sets --name-delimiter and --content-delimiter to the same value. Supports escape sequences like `\t`, `\n` and the `€` and `§` aliases. [string] | | | **`--null`** Shorthand for --delimiter '\0'. Useful for piping content with spaces or line breaks (like for `xargs -0`). The specific delimiter values win over --delimiter, and --delimiter wins over --null. [boolean] | | | **`--name-delimiter-tail`** Print the name delimiter after the last name. Defaults to true. Use --no-name-delimiter-tail or --name-delimiter-tail=false to not print the delimiter after the last name. [boolean] | -| | **`--content-delimiter-tail`** Print the content delimiter after the last content. Defaults to true, but to false for piped data so it is left as it is. [boolean] | +| | **`--content-delimiter-tail`** Print the content delimiter after the last content. Defaults to true, but to false when piped data is printed, so it is left as it came in. Matches from --output-match always get it, from a file or a pipe. [boolean] | | | **`--tail`** Shorthand for setting both --name-delimiter-tail and --content-delimiter-tail. Use --no-tail to not print the delimiter after the last name and the last content. The specific tail flags win over --tail. [boolean] | | `-l` | **`--nl`** Let what is printed to stdout and stderr (the names, or the content from -o, -m or piped data) always end with a new line. No extra new line if it already ends with a new line, or if nothing was printed. [boolean] | | `-H` | **`--halt`**, **`--bail`** Halt on first error [boolean] [default: false] | @@ -279,7 +279,9 @@ Use --js-pipe to access the pipe data in your javascript code. - The `€` and `§` aliases apply to pattern, replacement, `--exclude-re` values and delimiters. File globs, `--exclude-glob` and piped data are used exactly as they are, so a file named `price€.txt` is found without any flags. -- The defaults delimiter is a magic newline ensuring that there is _at least_ one `\n` after printed file content, replacemnt matche or names. This means that if the content already ends on a `\n` no additional `\n` is added. This beahvour can not be recreated with other delimiter values. +- The default delimiter is a magic newline ensuring that there is _at least_ one `\n` after printed file content, matches or names. This means that if the content already ends on a `\n` no additional `\n` is added. This behaviour can not be recreated with other delimiter values. + +- Piped data that is printed is left as it came in, so no delimiter is added after the end of it. Matches from `--output-match` are different: they are new output, so they get the delimiter whether they come from a file or from piped data. diff --git a/src/cli.ts b/src/cli.ts index 620a713c..c5346cb4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -153,7 +153,7 @@ const yargs = require('yargs') .boolean('content-delimiter-tail') .describe( 'content-delimiter-tail', - 'Print the content delimiter after the last content. Defaults to true, but to false for piped data so it is left as it is.' + 'Print the content delimiter after the last content. Defaults to true, but to false when piped data is printed, so it is left as it came in. Matches from --output-match always get it, from a file or a pipe.' ) .boolean('tail') diff --git a/src/engine.ts b/src/engine.ts index a5a8b494..89aa1bc8 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -62,9 +62,10 @@ export async function engine(config: any = {engine: 'default'}) { const piped = handlePipedData(config); - // The more specific tail wins. Piped data is left as it is. + // The more specific tail wins. Piped data that is printed is left as it came in, but matches are matches, from a file or a pipe. config.nameDelimiterTail = config.nameDelimiterTail ?? config.tail ?? true; - config.contentDelimiterTail = config.contentDelimiterTail ?? config.tail ?? !piped; + config.contentDelimiterTail = + config.contentDelimiterTail ?? config.tail ?? !(piped && config.output); if (piped) { doReplacement('Piped data', config, config.pipedData); @@ -252,8 +253,9 @@ function getFinalReplacement(replacement, conf: any) { return function (match, ...rest) { step(arguments); - // Without groups the full match is printed, otherwise the groups are printed together - const groups = rest.slice(0, -2); + // The groups are followed by the offset and the full text, and by the named groups if the pattern has any. + // Without groups the full match is printed, otherwise the groups are printed together. + const groups = rest.slice(0, 'object' === typeof rest[rest.length - 1] ? -3 : -2); print( groups.length ? groups.join('') : match, conf.contentDelimiter, diff --git a/test/fuzzy/output-match.test.js b/test/fuzzy/output-match.test.js index fef22b63..840f7812 100644 --- a/test/fuzzy/output-match.test.js +++ b/test/fuzzy/output-match.test.js @@ -11,7 +11,7 @@ describe('--output-match', () => { expect( (await rr(pattern, '_', {pipedData: text, outputMatch: true})).out, JSON.stringify({pattern, text}) - ).toBe(matches.join('\n')); + ).toBe(matches.map((match) => match + '\n').join('')); } }); @@ -24,7 +24,7 @@ describe('--output-match', () => { expect( (await rr(pattern, '_', {pipedData: text, outputMatch: true})).out, JSON.stringify({pattern, text}) - ).toBe(matches.map((match) => match.slice(1).join('')).join('\n')); + ).toBe(matches.map((match) => match.slice(1).join('') + '\n').join('')); } }); }); diff --git a/test/js/debug.test.js b/test/js/debug.test.js index 0cfa2de5..2e10b9e2 100644 --- a/test/js/debug.test.js +++ b/test/js/debug.test.js @@ -80,7 +80,7 @@ describe('--debug', () => { const js = await rr('o', '1+1', {pipedData: 'fo', debug: true, replacementJs: true}); expect(js.out).toBe('f2'); const match = await rr('o', '_', {pipedData: 'fo', debug: true, outputMatch: true}); - expect(match.out).toBe('o'); + expect(match.out).toBe('o\n'); expect(match.err).toBe('"Work on content from: Piped data"\n'); }); }); diff --git a/test/js/dot-all.test.js b/test/js/dot-all.test.js index ea2d8579..33d1045a 100644 --- a/test/js/dot-all.test.js +++ b/test/js/dot-all.test.js @@ -46,7 +46,7 @@ describe('--dot-all', () => { test('with --output-match a match can span lines', async () => { const config = {pipedData: 'a\nb a\nc', dotAll: true, outputMatch: true}; - expect((await rr('a.b', '_', config)).out).toBe('a\nb'); + expect((await rr('a.b', '_', config)).out).toBe('a\nb\n'); }); test('with --literal the dot is just a dot', async () => { diff --git a/test/js/fix/703.test.js b/test/js/fix/703.test.js index cfdda85e..ea457072 100644 --- a/test/js/fix/703.test.js +++ b/test/js/fix/703.test.js @@ -47,7 +47,7 @@ describe('#703 the names of affected files', () => { test('are not printed for piped data', async () => { expect(await rr('o', 'x', {pipedData: 'foo'})).toEqual({out: 'fxx', err: '', exit: null}); expect(await rr('o', 'x', {pipedData: 'foo', outputMatch: true})).toEqual({ - out: 'o\no', + out: 'o\no\n', err: '', exit: null, }); @@ -224,6 +224,14 @@ describe('#703 the names of affected files', () => { ); }); + test('is put after the last match, from a file or a pipe', async () => { + await Bun.write('a.file', 'foo'); + const fromFile = await rr('o', '_', {includeGlob: ['a.file'], outputMatch: true}); + const fromPipe = await rr('o', '_', {pipedData: 'foo', outputMatch: true}); + expect(fromFile.out).toBe('o\no\n'); + expect(fromPipe.out).toBe('o\no\n'); + }); + test('is put after piped data with --content-delimiter-tail', async () => { const tail = {pipedData: 'foo', contentDelimiterTail: true}; expect((await rr('o', 'x', {...tail, contentDelimiter: ', '})).out).toBe('fxx, '); diff --git a/test/js/literal.test.js b/test/js/literal.test.js index d24e76ee..8c5152a4 100644 --- a/test/js/literal.test.js +++ b/test/js/literal.test.js @@ -67,7 +67,7 @@ describe('--literal', () => { test('with --output-match', async () => { expect( (await rr('o.', '_', {pipedData: 'foo. o.k', literal: true, outputMatch: true})).out - ).toBe('o.\no.'); + ).toBe('o.\no.\n'); }); test('with --replacement-js', async () => { diff --git a/test/js/output-match.test.js b/test/js/output-match.test.js index 40947812..a598017d 100644 --- a/test/js/output-match.test.js +++ b/test/js/output-match.test.js @@ -5,15 +5,17 @@ import {rr} from './helper.js'; describe('--output-match', () => { test('prints each match on its own line', async () => { expect((await rr('[fb].', '_', {pipedData: 'foobar', outputMatch: true})).out).toBe( - 'fo\nba' + 'fo\nba\n' ); }); test('prints only the groups (with nothing in between) if the pattern has groups', async () => { expect((await rr('([fb](.))', '_', {pipedData: 'foobar', outputMatch: true})).out).toBe( - 'foo\nbaa' + 'foo\nbaa\n' + ); + expect((await rr('(f)o(o)', '_', {pipedData: 'foobar', outputMatch: true})).out).toBe( + 'fo\n' ); - expect((await rr('(f)o(o)', '_', {pipedData: 'foobar', outputMatch: true})).out).toBe('fo'); }); test('prints nothing if there is no match', async () => { @@ -26,7 +28,7 @@ describe('--output-match', () => { test('ignores the replacement', async () => { expect((await rr('o', 'anything $1 $&', {pipedData: 'foo', outputMatch: true})).out).toBe( - 'o\no' + 'o\no\n' ); }); @@ -47,34 +49,34 @@ describe('--output-match', () => { }); test('prints a match that spans lines as it is', async () => { - expect((await rr('a\\nb', '_', {pipedData: 'a\nb', outputMatch: true})).out).toBe('a\nb'); + expect((await rr('a\\nb', '_', {pipedData: 'a\nb', outputMatch: true})).out).toBe('a\nb\n'); }); test('prints an empty line for an empty match', async () => { - expect((await rr('^', '_', {pipedData: 'a\nb', outputMatch: true})).out).toBe('\n'); + expect((await rr('^', '_', {pipedData: 'a\nb', outputMatch: true})).out).toBe('\n\n'); }); test('prints nothing for a group that did not take part in the match (#684)', async () => { expect((await rr('(f)|(b)', '_', {pipedData: 'foobar', outputMatch: true})).out).toBe( - 'f\nb' + 'f\nb\n' ); }); - test.todo('BUG #685: named groups are printed like any other group', async () => { + test('prints named groups like any other group (#685)', async () => { expect( (await rr('(?f)(?o)', '_', {pipedData: 'foobar', outputMatch: true})).out - ).toBe('fo'); + ).toBe('fo\n'); }); describe('combined', () => { test('with --void-global only the first match is printed', async () => { const config = {pipedData: 'foobar', outputMatch: true, voidGlobal: true}; - expect((await rr('[fb].', '_', config)).out).toBe('fo'); + expect((await rr('[fb].', '_', config)).out).toBe('fo\n'); }); test('with --void-ignore-case', async () => { const config = {pipedData: 'Foo foo', outputMatch: true, voidIgnoreCase: true}; - expect((await rr('f.', '_', config)).out).toBe('fo'); + expect((await rr('f.', '_', config)).out).toBe('fo\n'); }); test('with --dot-all and --void-multiline', async () => { @@ -84,19 +86,19 @@ describe('--output-match', () => { dotAll: true, voidMultiline: true, }; - expect((await rr('^(.)(.*)(.)$', '_', config)).out).toBe('ab\ncd'); + expect((await rr('^(.)(.*)(.)$', '_', config)).out).toBe('ab\ncd\n'); }); test('with --literal', async () => { expect( (await rr('a.', '_', {pipedData: 'ab a.', outputMatch: true, literal: true})).out - ).toBe('a.'); + ).toBe('a.\n'); }); test('with --replacement-js the code is not run', async () => { const config = {pipedData: 'foobar', outputMatch: true, replacementJs: true}; expect((await rr('[fb].', 'throw new Error("should not run")', config)).out).toBe( - 'fo\nba' + 'fo\nba\n' ); }); @@ -114,7 +116,7 @@ describe('--output-match', () => { test('with --quiet-total the matches are still printed', async () => { const config = {pipedData: 'foobar', outputMatch: true, quietTotal: true}; - expect((await rr('[fb].', '_', config)).out).toBe('fo\nba'); + expect((await rr('[fb].', '_', config)).out).toBe('fo\nba\n'); }); test('with --keep-backup there is nothing to back up', async () => { diff --git a/test/js/unicode.test.js b/test/js/unicode.test.js index 6d5f2540..a4b0474d 100644 --- a/test/js/unicode.test.js +++ b/test/js/unicode.test.js @@ -63,7 +63,7 @@ describe('--unicode', () => { test('with --output-match', async () => { const config = {pipedData: 'a🦖 b🦕', unicode: true, outputMatch: true}; - expect((await rr('\\w(.)', '_', config)).out).toBe('🦖\n🦕'); + expect((await rr('\\w(.)', '_', config)).out).toBe('🦖\n🦕\n'); }); test('with --replacement-js', async () => { diff --git a/test/js/void-global.test.js b/test/js/void-global.test.js index 21099801..a17c9882 100644 --- a/test/js/void-global.test.js +++ b/test/js/void-global.test.js @@ -28,7 +28,7 @@ describe('--void-global', () => { describe('combined', () => { test('with --output-match only the first match is printed', async () => { const config = {pipedData: 'foobar', outputMatch: true, voidGlobal: true}; - expect((await rr('[fb].', '_', config)).out).toBe('fo'); + expect((await rr('[fb].', '_', config)).out).toBe('fo\n'); }); test('with the default multiline only the first line is touched', async () => { diff --git a/test/js/void-ignore-case.test.js b/test/js/void-ignore-case.test.js index 312df035..46e69cd9 100644 --- a/test/js/void-ignore-case.test.js +++ b/test/js/void-ignore-case.test.js @@ -39,7 +39,7 @@ describe('--void-ignore-case', () => { test('with --output-match', async () => { const config = {pipedData: 'foo Foo FOO', outputMatch: true, voidIgnoreCase: true}; - expect((await rr('F.o', '_', config)).out).toBe('Foo'); + expect((await rr('F.o', '_', config)).out).toBe('Foo\n'); }); test('with --replacement-js', async () => { diff --git a/test/js/void-multiline.test.js b/test/js/void-multiline.test.js index 0b4b794e..bd89acbb 100644 --- a/test/js/void-multiline.test.js +++ b/test/js/void-multiline.test.js @@ -52,7 +52,7 @@ describe('--void-multiline', () => { test('with --output-match', async () => { const config = {pipedData: 'ab\ncd\nef', voidMultiline: true, outputMatch: true}; - expect((await rr('^.|.$', '_', config)).out).toBe('a\nf'); + expect((await rr('^.|.$', '_', config)).out).toBe('a\nf\n'); }); test('with --replacement-js', async () => {