diff --git a/README.md b/README.md index e6a7e315..b18f9400 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,8 @@ Require has been given the alias `r` and both are expanded to understand relativ > rexreplace 'VERSION' 'r("package").version' -j dist/*.js ``` +--- + #### Reference a matching group Let 'foobar' become 'barfoo' in myfile.md @@ -144,6 +146,8 @@ RexReplace defaults to treating `§` as an alias for `\` so the following give s } ``` + + --- #### More relevant examples @@ -156,6 +160,21 @@ Add creation time, name of the file and human readable file size as the first li > rexreplace '^' 'ctime_ + name_ + size + nl' -j -M 'dist/**/*.*' ``` +**List changed files** + +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` 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 +``` + + **Matching pairs** Let both `"foo 'is' bar"` and `' foo "was" bar'` become `» foo ... bar «` independent of using `'` or `"` @@ -166,6 +185,29 @@ Let both `"foo 'is' bar"` and `' foo "was" bar'` become `» foo ... bar «` inde --- + +#### Piping data + +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' +``` + +Use the piped data as replacement with the `-R` flag. The `-T` flag trims the input. + +```bash +> git rev-parse --short HEAD | rexreplace 'COMMIT' _ -R -T dist/*.js +``` + +Use --js-pipe to access the pipe data in your javascript code. + +```bash +> git log -1 --format=%an | rexreplace 'AUTHOR' 'pipe.trim().toUpperCase()' --js-pipe docs/*.md +``` + +--- + ## Usage ```bash @@ -186,10 +228,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 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] | +| | **`--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 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] | | `-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,7 +264,9 @@ 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 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. ### Quirks @@ -223,7 +277,12 @@ 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 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 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. + ### Limitations 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 499d8dd1..c5346cb4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,6 +2,7 @@ import * as rexreplace from './engine.ts'; import {outputConfig, die} 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 the named files instead of the relative path.') + .alias('a', 'absolute-path') + + .boolean('name-all') + .describe( + 'name-all', + '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 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 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') + .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') @@ -412,17 +471,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 f340cfa4..89aa1bc8 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,28 @@ 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 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.output); + + if (piped) { + doReplacement('Piped data', config, config.pipedData); + return printDone(); } config.files = getFilePaths(config); @@ -72,6 +97,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 +125,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 its content would change + 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 +204,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 +214,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 +234,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 +250,17 @@ 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'); + // 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, + conf.contentDelimiterTail + ); return ''; }; } @@ -629,18 +657,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; @@ -659,5 +675,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..73096afe 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 a changed 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 b3ea0bdd..27b01895 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 "printf ' x ' | rexreplace 'foo' 'pipe' my.file -o --js-pipe -T" ' # Warning when piped data is not used reset -assert "printf x | rexreplace 'o' 'x' my.file --pipe 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 --pipe 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 2>&1 | grep 'Data is piped in'" assert_failure "printf x | rexreplace 'o' 'x' my.file --pipe -q 2>&1 | grep 'Data is piped in'" @@ -307,6 +307,142 @@ assert_failure "printf '\n' | rexreplace 'o' 'x' my.file --pipe 2>&1 | grep 'Da assert_failure "printf '\r\n' | rexreplace 'o' 'x' my.file --pipe 2>&1 | grep 'Data is piped in'" +# 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.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..2e10b9e2 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,7 +73,7 @@ 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 () => { 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..ea457072 --- /dev/null +++ b/test/js/fix/703.test.js @@ -0,0 +1,407 @@ +// 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\n', + 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 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, '); + 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/output-match.test.js b/test/js/output-match.test.js index 5f16affd..a598017d 100644 --- a/test/js/output-match.test.js +++ b/test/js/output-match.test.js @@ -35,7 +35,7 @@ describe('--output-match', () => { 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']); }); @@ -56,13 +56,13 @@ describe('--output-match', () => { expect((await rr('^', '_', {pipedData: 'a\nb', outputMatch: true})).out).toBe('\n\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' ); }); - 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\n'); 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 c5d862d4..c9fcf852 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; @@ -131,14 +133,14 @@ 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', 'pipe', {...config, pipedData: 'boo'})).out).toBe('boobar\n'); }); test('pipe is null and pipe_ is ❌ without piped data', async () => { fs.writeFileSync('my.file', 'foobar'); const config = {includeGlob: ['my.file'], replacementJs: true, output: true}; - expect((await rr('foo', 'String(pipe) + pipe_', config)).out).toBe('null❌ bar'); - expect((await rr('(o)', 'pipe_ + €1', config)).out).toBe('f❌ o❌ obar'); + expect((await rr('foo', 'String(pipe) + pipe_', config)).out).toBe('null❌ bar\n'); + expect((await rr('(o)', 'pipe_ + €1', config)).out).toBe('f❌ o❌ obar\n'); }); test('file, file_rel, dirpath, dirpath_rel, dirname, filename, name and ext tell about the file', async () => { @@ -209,7 +211,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( @@ -327,7 +334,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/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' ); });